fix(core): enhance sandbox usability and fix build error (#24460)

Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>
This commit is contained in:
Gal Zahavi
2026-04-01 16:51:06 -07:00
committed by GitHub
parent ca78a0f177
commit 13ccc16457
22 changed files with 1285 additions and 53 deletions

View File

@@ -16,6 +16,7 @@ import {
} from 'vitest';
const mockPlatform = vi.hoisted(() => vi.fn());
const mockHomedir = vi.hoisted(() => vi.fn());
const mockShellExecutionService = vi.hoisted(() => vi.fn());
const mockShellBackground = vi.hoisted(() => vi.fn());
@@ -34,8 +35,10 @@ vi.mock('node:os', async (importOriginal) => {
default: {
...actualOs,
platform: mockPlatform,
homedir: mockHomedir,
},
platform: mockPlatform,
homedir: mockHomedir,
};
});
vi.mock('crypto');
@@ -57,7 +60,11 @@ import { isSubpath } from '../utils/paths.js';
import * as crypto from 'node:crypto';
import * as summarizer from '../utils/summarizer.js';
import { ToolErrorType } from './tool-error.js';
import { ToolConfirmationOutcome } from './tools.js';
import {
ToolConfirmationOutcome,
type ToolSandboxExpansionConfirmationDetails,
type ToolExecuteConfirmationDetails,
} from './tools.js';
import { SHELL_TOOL_NAME } from './tool-names.js';
import { WorkspaceContext } from '../utils/workspaceContext.js';
import {
@@ -69,6 +76,7 @@ import {
type UpdatePolicy,
} from '../confirmation-bus/types.js';
import { type MessageBus } from '../confirmation-bus/message-bus.js';
import { type SandboxManager } from '../services/sandboxManager.js';
interface TestableMockMessageBus extends MessageBus {
defaultToolDecision: 'allow' | 'deny' | 'ask_user';
@@ -84,6 +92,7 @@ describe('ShellTool', () => {
let shellTool: ShellTool;
let mockConfig: Config;
let mockSandboxManager: SandboxManager;
let mockShellOutputCallback: (event: ShellOutputEvent) => void;
let resolveExecutionPromise: (result: ShellExecutionResult) => void;
let tempRootDir: string;
@@ -94,6 +103,7 @@ describe('ShellTool', () => {
tempRootDir = fs.mkdtempSync(path.join(os.tmpdir(), 'shell-test-'));
fs.mkdirSync(path.join(tempRootDir, 'subdir'));
mockSandboxManager = new NoopSandboxManager();
mockConfig = {
get config() {
return this;
@@ -140,7 +150,15 @@ describe('ShellTool', () => {
getEnableShellOutputEfficiency: vi.fn().mockReturnValue(true),
getSandboxEnabled: vi.fn().mockReturnValue(false),
sanitizationConfig: {},
sandboxManager: new NoopSandboxManager(),
get sandboxManager() {
return mockSandboxManager;
},
sandboxPolicyManager: {
getCommandPermissions: vi.fn().mockReturnValue(undefined),
getModeConfig: vi.fn().mockReturnValue({ readonly: false }),
addPersistentApproval: vi.fn(),
addSessionApproval: vi.fn(),
},
} as unknown as Config;
const bus = createMockMessageBus();
@@ -168,6 +186,7 @@ describe('ShellTool', () => {
shellTool = new ShellTool(mockConfig, bus);
mockPlatform.mockReturnValue('linux');
mockHomedir.mockReturnValue('/home/user');
(vi.mocked(crypto.randomBytes) as Mock).mockReturnValue(
Buffer.from('abcdef', 'hex'),
);
@@ -646,7 +665,7 @@ describe('ShellTool', () => {
describe('shouldConfirmExecute', () => {
it('should request confirmation for a new command and allowlist it on "Always"', async () => {
const params = { command: 'npm install' };
const params = { command: 'ls -la' };
const invocation = shellTool.build(params);
// Accessing protected messageBus for testing purposes
@@ -920,6 +939,152 @@ describe('ShellTool', () => {
});
});
describe('sandbox heuristics', () => {
const mockAbortSignal = new AbortController().signal;
it('should suggest proactive permissions for npm commands', async () => {
const homeDir = path.join(tempRootDir, 'home');
fs.mkdirSync(homeDir);
fs.mkdirSync(path.join(homeDir, '.npm'));
fs.mkdirSync(path.join(homeDir, '.cache'));
mockHomedir.mockReturnValue(homeDir);
const sandboxManager = {
parseDenials: vi.fn().mockReturnValue({
network: true,
filePaths: [path.join(homeDir, '.npm/_logs/test.log')],
}),
prepareCommand: vi.fn(),
isKnownSafeCommand: vi.fn(),
isDangerousCommand: vi.fn(),
} as unknown as SandboxManager;
mockSandboxManager = sandboxManager;
const invocation = shellTool.build({ command: 'npm install' });
const promise = invocation.execute(mockAbortSignal);
resolveExecutionPromise({
exitCode: 1,
output: 'npm error code EPERM',
executionMethod: 'child_process',
signal: null,
error: null,
aborted: false,
pid: 12345,
rawOutput: Buffer.from('npm error code EPERM'),
});
const result = await promise;
expect(result.error?.type).toBe(ToolErrorType.SANDBOX_EXPANSION_REQUIRED);
const details = JSON.parse(result.error!.message);
expect(details.additionalPermissions.network).toBe(true);
expect(details.additionalPermissions.fileSystem.read).toContain(
path.join(homeDir, '.npm'),
);
expect(details.additionalPermissions.fileSystem.read).toContain(
path.join(homeDir, '.cache'),
);
expect(details.additionalPermissions.fileSystem.write).toContain(
path.join(homeDir, '.npm'),
);
});
it('should NOT consolidate paths into sensitive directories', async () => {
const rootDir = path.join(tempRootDir, 'fake_root');
const homeDir = path.join(rootDir, 'home');
const user1Dir = path.join(homeDir, 'user1');
const user2Dir = path.join(homeDir, 'user2');
const user3Dir = path.join(homeDir, 'user3');
fs.mkdirSync(homeDir, { recursive: true });
fs.mkdirSync(user1Dir);
fs.mkdirSync(user2Dir);
fs.mkdirSync(user3Dir);
mockHomedir.mockReturnValue(path.join(homeDir, 'user'));
vi.spyOn(mockConfig, 'isPathAllowed').mockImplementation((p) => {
if (p.includes('fake_root')) return false;
return true;
});
const sandboxManager = {
parseDenials: vi.fn().mockReturnValue({
network: false,
filePaths: [
path.join(user1Dir, 'file1'),
path.join(user2Dir, 'file2'),
path.join(user3Dir, 'file3'),
],
}),
prepareCommand: vi.fn(),
isKnownSafeCommand: vi.fn(),
isDangerousCommand: vi.fn(),
} as unknown as SandboxManager;
mockSandboxManager = sandboxManager;
const invocation = shellTool.build({ command: `ls ${homeDir}` });
const promise = invocation.execute(mockAbortSignal);
resolveExecutionPromise({
exitCode: 1,
output: 'Permission denied',
executionMethod: 'child_process',
signal: null,
error: null,
aborted: false,
pid: 12345,
rawOutput: Buffer.from('Permission denied'),
});
const result = await promise;
expect(result.error?.type).toBe(ToolErrorType.SANDBOX_EXPANSION_REQUIRED);
const details = JSON.parse(result.error!.message);
// Should NOT contain homeDir as it is a parent of homedir and thus sensitive
expect(details.additionalPermissions.fileSystem.read).not.toContain(
homeDir,
);
// Should contain individual paths instead
expect(details.additionalPermissions.fileSystem.read).toContain(user1Dir);
expect(details.additionalPermissions.fileSystem.read).toContain(user2Dir);
expect(details.additionalPermissions.fileSystem.read).toContain(user3Dir);
});
it('should proactively suggest expansion for npm install in confirmation', async () => {
const homeDir = path.join(tempRootDir, 'home');
fs.mkdirSync(homeDir);
mockHomedir.mockReturnValue(homeDir);
const invocation = shellTool.build({ command: 'npm install' });
const details = (await invocation.shouldConfirmExecute(
new AbortController().signal,
'ask_user',
)) as ToolSandboxExpansionConfirmationDetails;
expect(details.type).toBe('sandbox_expansion');
expect(details.title).toContain('Recommended');
expect(details.additionalPermissions.network).toBe(true);
});
it('should NOT proactively suggest expansion for npm test', async () => {
const homeDir = path.join(tempRootDir, 'home');
fs.mkdirSync(homeDir);
mockHomedir.mockReturnValue(homeDir);
const invocation = shellTool.build({ command: 'npm test' });
const details = (await invocation.shouldConfirmExecute(
new AbortController().signal,
'ask_user',
)) as ToolExecuteConfirmationDetails;
// Should be regular exec confirmation, not expansion
expect(details.type).toBe('exec');
});
});
describe('getSchema', () => {
it('should return the base schema when no modelId is provided', () => {
const schema = shellTool.getSchema();