fix(core): ensure sandbox approvals are correctly persisted and matched for proactive expansions (#24577)

This commit is contained in:
Gal Zahavi
2026-04-03 14:48:18 -07:00
committed by GitHub
parent 370c45de67
commit 893ae4d29a
10 changed files with 572 additions and 104 deletions

View File

@@ -154,7 +154,11 @@ describe('ShellTool', () => {
return mockSandboxManager;
},
sandboxPolicyManager: {
getCommandPermissions: vi.fn().mockReturnValue(undefined),
getCommandPermissions: vi.fn().mockReturnValue({
fileSystem: { read: [], write: [] },
network: false,
}),
getModeConfig: vi.fn().mockReturnValue({ readonly: false }),
addPersistentApproval: vi.fn(),
addSessionApproval: vi.fn(),
@@ -708,6 +712,39 @@ describe('ShellTool', () => {
it('should throw an error if validation fails', () => {
expect(() => shellTool.build({ command: '' })).toThrow();
});
it('should NOT return a sandbox expansion prompt for npm install when sandboxing is disabled', async () => {
const bus = (shellTool as unknown as { messageBus: MessageBus })
.messageBus;
const mockBus = getMockMessageBusInstance(
bus,
) as unknown as TestableMockMessageBus;
mockBus.defaultToolDecision = 'allow';
vi.mocked(mockConfig.getSandboxEnabled).mockReturnValue(false);
const params = { command: 'npm install' };
const invocation = shellTool.build(params);
const confirmation = await invocation.shouldConfirmExecute(
new AbortController().signal,
);
// Should be false because standard confirm mode is 'allow'
expect(confirmation).toBe(false);
});
it('should return a sandbox expansion prompt for npm install when sandboxing is enabled', async () => {
vi.mocked(mockConfig.getSandboxEnabled).mockReturnValue(true);
const params = { command: 'npm install' };
const invocation = shellTool.build(params);
const confirmation = await invocation.shouldConfirmExecute(
new AbortController().signal,
);
expect(confirmation).not.toBe(false);
expect(confirmation && confirmation.type).toBe('sandbox_expansion');
});
});
describe('getDescription', () => {
@@ -950,6 +987,10 @@ describe('ShellTool', () => {
describe('sandbox heuristics', () => {
const mockAbortSignal = new AbortController().signal;
beforeEach(() => {
vi.mocked(mockConfig.getSandboxEnabled).mockReturnValue(true);
});
it('should suggest proactive permissions for npm commands', async () => {
const homeDir = path.join(tempRootDir, 'home');
fs.mkdirSync(homeDir);

View File

@@ -10,7 +10,10 @@ import path from 'node:path';
import os from 'node:os';
import crypto from 'node:crypto';
import { debugLogger } from '../index.js';
import type { SandboxPermissions } from '../services/sandboxManager.js';
import {
type SandboxPermissions,
getPathIdentity,
} from '../services/sandboxManager.js';
import { ToolErrorType } from './tool-error.js';
import {
BaseDeclarativeTool,
@@ -42,6 +45,7 @@ import {
stripShellWrapper,
parseCommandDetails,
hasRedirection,
normalizeCommand,
} from '../utils/shell-utils.js';
import { SHELL_TOOL_NAME } from './tool-names.js';
import { PARAM_ADDITIONAL_PERMISSIONS } from './definitions/base-declarations.js';
@@ -49,7 +53,7 @@ import type { MessageBus } from '../confirmation-bus/message-bus.js';
import { getShellDefinition } from './definitions/coreTools.js';
import { resolveToolDeclaration } from './definitions/resolver.js';
import type { AgentLoopContext } from '../config/agent-loop-context.js';
import { isSubpath } from '../utils/paths.js';
import { isSubpath, resolveToRealPath } from '../utils/paths.js';
import {
getProactiveToolSuggestions,
isNetworkReliantCommand,
@@ -247,77 +251,103 @@ export class ShellToolInvocation extends BaseToolInvocation<
return this.getConfirmationDetails(abortSignal);
}
// Proactively suggest expansion for known network-heavy Node.js ecosystem tools
// (npm install, etc.) to avoid hangs when network is restricted by default.
// We do this even if the command is "allowed" by policy because the DEFAULT
// permissions are usually insufficient for these commands.
const command = stripShellWrapper(this.params.command);
const rootCommands = getCommandRoots(command);
const rootCommand = rootCommands[0];
if (this.context.config.getSandboxEnabled()) {
const command = stripShellWrapper(this.params.command);
const rootCommands = getCommandRoots(command);
const rawRootCommand = rootCommands[0];
if (rootCommand) {
const proactive = await getProactiveToolSuggestions(rootCommand);
if (proactive) {
const approved =
this.context.config.sandboxPolicyManager.getCommandPermissions(
rootCommand,
);
const missingNetwork = !!proactive.network && !approved?.network;
// Detect commands or sub-commands that definitely need network
const parsed = parseCommandDetails(command);
const subCommand = parsed?.details[0]?.args?.[0];
const needsNetwork = isNetworkReliantCommand(rootCommand, subCommand);
if (needsNetwork) {
// Add write permission to the current directory if we are in readonly mode
if (rawRootCommand) {
const rootCommand = normalizeCommand(rawRootCommand);
const proactive = await getProactiveToolSuggestions(rootCommand);
if (proactive) {
const mode = this.context.config.getApprovalMode();
const isReadonlyMode =
this.context.config.sandboxPolicyManager.getModeConfig(mode)
?.readonly ?? false;
const modeConfig =
this.context.config.sandboxPolicyManager.getModeConfig(mode);
const approved =
this.context.config.sandboxPolicyManager.getCommandPermissions(
rootCommand,
);
if (isReadonlyMode) {
const cwd =
this.params.dir_path || this.context.config.getTargetDir();
proactive.fileSystem = proactive.fileSystem || {
read: [],
write: [],
};
proactive.fileSystem.write = proactive.fileSystem.write || [];
if (!proactive.fileSystem.write.includes(cwd)) {
proactive.fileSystem.write.push(cwd);
proactive.fileSystem.read = proactive.fileSystem.read || [];
if (!proactive.fileSystem.read.includes(cwd)) {
proactive.fileSystem.read.push(cwd);
const hasNetwork = modeConfig.network || approved.network;
const missingNetwork = !!proactive.network && !hasNetwork;
// Detect commands or sub-commands that definitely need network
const parsed = parseCommandDetails(command);
const subCommand = parsed?.details[0]?.args?.[0];
const needsNetwork = isNetworkReliantCommand(rootCommand, subCommand);
if (needsNetwork) {
// Add write permission to the current directory if we are in readonly mode
const isReadonlyMode = modeConfig.readonly ?? false;
if (isReadonlyMode) {
const cwd =
this.params.dir_path || this.context.config.getTargetDir();
proactive.fileSystem = proactive.fileSystem || {
read: [],
write: [],
};
proactive.fileSystem.write = proactive.fileSystem.write || [];
if (!proactive.fileSystem.write.includes(cwd)) {
proactive.fileSystem.write.push(cwd);
proactive.fileSystem.read = proactive.fileSystem.read || [];
if (!proactive.fileSystem.read.includes(cwd)) {
proactive.fileSystem.read.push(cwd);
}
}
}
}
const missingRead = (proactive.fileSystem?.read || []).filter(
(p) => !approved?.fileSystem?.read?.includes(p),
);
const missingWrite = (proactive.fileSystem?.write || []).filter(
(p) => !approved?.fileSystem?.write?.includes(p),
);
const isApproved = (
requestedPath: string,
approvedPaths?: string[],
): boolean => {
if (!approvedPaths || approvedPaths.length === 0) return false;
const requestedRealIdentity = getPathIdentity(
resolveToRealPath(requestedPath),
);
const needsExpansion =
missingRead.length > 0 || missingWrite.length > 0 || missingNetwork;
// Identity check is fast, subpath check is slower
return approvedPaths.some((p) => {
const approvedRealIdentity = getPathIdentity(
resolveToRealPath(p),
);
return (
requestedRealIdentity === approvedRealIdentity ||
isSubpath(approvedRealIdentity, requestedRealIdentity)
);
});
};
if (needsExpansion) {
const details = await this.getConfirmationDetails(
abortSignal,
proactive,
const missingRead = (proactive.fileSystem?.read || []).filter(
(p) => !isApproved(p, approved.fileSystem?.read),
);
if (details && details.type === 'sandbox_expansion') {
const originalOnConfirm = details.onConfirm;
details.onConfirm = async (outcome: ToolConfirmationOutcome) => {
await originalOnConfirm(outcome);
if (outcome !== ToolConfirmationOutcome.Cancel) {
this.proactivePermissionsConfirmed = proactive;
}
};
const missingWrite = (proactive.fileSystem?.write || []).filter(
(p) => !isApproved(p, approved.fileSystem?.write),
);
const needsExpansion =
missingRead.length > 0 ||
missingWrite.length > 0 ||
missingNetwork;
if (needsExpansion) {
const details = await this.getConfirmationDetails(
abortSignal,
proactive,
);
if (details && details.type === 'sandbox_expansion') {
const originalOnConfirm = details.onConfirm;
details.onConfirm = async (
outcome: ToolConfirmationOutcome,
) => {
await originalOnConfirm(outcome);
if (outcome !== ToolConfirmationOutcome.Cancel) {
this.proactivePermissionsConfirmed = proactive;
}
};
}
return details;
}
return details;
}
}
}
@@ -742,20 +772,22 @@ export class ShellToolInvocation extends BaseToolInvocation<
);
// Proactive permission suggestions for Node ecosystem tools
const proactive =
await getProactiveToolSuggestions(rootCommandDisplay);
if (proactive) {
if (proactive.network) {
sandboxDenial.network = true;
}
if (proactive.fileSystem?.read) {
for (const p of proactive.fileSystem.read) {
readPaths.add(p);
if (this.context.config.getSandboxEnabled()) {
const proactive =
await getProactiveToolSuggestions(rootCommandDisplay);
if (proactive) {
if (proactive.network) {
sandboxDenial.network = true;
}
}
if (proactive.fileSystem?.write) {
for (const p of proactive.fileSystem.write) {
writePaths.add(p);
if (proactive.fileSystem?.read) {
for (const p of proactive.fileSystem.read) {
readPaths.add(p);
}
}
if (proactive.fileSystem?.write) {
for (const p of proactive.fileSystem.write) {
writePaths.add(p);
}
}
}
}

View File

@@ -0,0 +1,180 @@
/**
* @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);
}
});
});