policy: extract legacy policy from core tool scheduler to policy engine (#15902)

This commit is contained in:
Abhi
2026-01-06 23:28:06 -05:00
committed by GitHub
parent 2d683bb6f8
commit 5fe5d1da46
16 changed files with 286 additions and 973 deletions
@@ -16,6 +16,7 @@ import {
DEFAULT_TRUNCATE_TOOL_OUTPUT_THRESHOLD,
GeminiClient,
HookSystem,
PolicyDecision,
} from '@google/gemini-cli-core';
import { createMockMessageBus } from '@google/gemini-cli-core/src/test-utils/mock-message-bus.js';
import type { Config, Storage } from '@google/gemini-cli-core';
@@ -77,6 +78,17 @@ export function createMockConfig(
mockConfig.getGeminiClient = vi
.fn()
.mockReturnValue(new GeminiClient(mockConfig));
mockConfig.getPolicyEngine = vi.fn().mockReturnValue({
check: async () => {
const mode = mockConfig.getApprovalMode();
if (mode === ApprovalMode.YOLO) {
return { decision: PolicyDecision.ALLOW };
}
return { decision: PolicyDecision.ASK_USER };
},
});
return mockConfig;
}
@@ -9,7 +9,11 @@ import { ConfirmationRequiredError, ShellProcessor } from './shellProcessor.js';
import { createMockCommandContext } from '../../test-utils/mockCommandContext.js';
import type { CommandContext } from '../../ui/commands/types.js';
import type { Config } from '@google/gemini-cli-core';
import { ApprovalMode, getShellConfiguration } from '@google/gemini-cli-core';
import {
ApprovalMode,
getShellConfiguration,
PolicyDecision,
} from '@google/gemini-cli-core';
import { quote } from 'shell-quote';
import { createPartFromText } from '@google/genai';
import type { PromptPipelineContent } from './types.js';
@@ -60,15 +64,23 @@ const SUCCESS_RESULT = {
describe('ShellProcessor', () => {
let context: CommandContext;
let mockConfig: Partial<Config>;
let mockPolicyEngineCheck: Mock;
beforeEach(() => {
vi.clearAllMocks();
mockPolicyEngineCheck = vi.fn().mockResolvedValue({
decision: PolicyDecision.ALLOW,
});
mockConfig = {
getTargetDir: vi.fn().mockReturnValue('/test/dir'),
getApprovalMode: vi.fn().mockReturnValue(ApprovalMode.DEFAULT),
getEnableInteractiveShell: vi.fn().mockReturnValue(false),
getShellExecutionConfig: vi.fn().mockReturnValue({}),
getPolicyEngine: vi.fn().mockReturnValue({
check: mockPolicyEngineCheck,
}),
};
context = createMockCommandContext({
@@ -124,9 +136,8 @@ describe('ShellProcessor', () => {
const prompt: PromptPipelineContent = createPromptPipelineContent(
'The current status is: !{git status}',
);
mockCheckCommandPermissions.mockReturnValue({
allAllowed: true,
disallowedCommands: [],
mockPolicyEngineCheck.mockResolvedValue({
decision: PolicyDecision.ALLOW,
});
mockShellExecute.mockReturnValue({
result: Promise.resolve({ ...SUCCESS_RESULT, output: 'On branch main' }),
@@ -134,10 +145,12 @@ describe('ShellProcessor', () => {
const result = await processor.process(prompt, context);
expect(mockCheckCommandPermissions).toHaveBeenCalledWith(
'git status',
expect.any(Object),
context.session.sessionShellAllowlist,
expect(mockPolicyEngineCheck).toHaveBeenCalledWith(
{
name: 'run_shell_command',
args: { command: 'git status' },
},
undefined,
);
expect(mockShellExecute).toHaveBeenCalledWith(
'git status',
@@ -155,9 +168,8 @@ describe('ShellProcessor', () => {
const prompt: PromptPipelineContent = createPromptPipelineContent(
'!{git status} in !{pwd}',
);
mockCheckCommandPermissions.mockReturnValue({
allAllowed: true,
disallowedCommands: [],
mockPolicyEngineCheck.mockResolvedValue({
decision: PolicyDecision.ALLOW,
});
mockShellExecute
@@ -173,7 +185,7 @@ describe('ShellProcessor', () => {
const result = await processor.process(prompt, context);
expect(mockCheckCommandPermissions).toHaveBeenCalledTimes(2);
expect(mockPolicyEngineCheck).toHaveBeenCalledTimes(2);
expect(mockShellExecute).toHaveBeenCalledTimes(2);
expect(result).toEqual([{ text: 'On branch main in /usr/home' }]);
});
@@ -183,9 +195,8 @@ describe('ShellProcessor', () => {
const prompt: PromptPipelineContent = createPromptPipelineContent(
'Do something dangerous: !{rm -rf /}',
);
mockCheckCommandPermissions.mockReturnValue({
allAllowed: false,
disallowedCommands: ['rm -rf /'],
mockPolicyEngineCheck.mockResolvedValue({
decision: PolicyDecision.ASK_USER,
});
await expect(processor.process(prompt, context)).rejects.toThrow(
@@ -198,11 +209,11 @@ describe('ShellProcessor', () => {
const prompt: PromptPipelineContent = createPromptPipelineContent(
'Do something dangerous: !{rm -rf /}',
);
mockCheckCommandPermissions.mockReturnValue({
allAllowed: false,
disallowedCommands: ['rm -rf /'],
// In YOLO mode, PolicyEngine returns ALLOW
mockPolicyEngineCheck.mockResolvedValue({
decision: PolicyDecision.ALLOW,
});
// Override the approval mode for this test
// Override the approval mode for this test (though PolicyEngine mock handles the decision)
(mockConfig.getApprovalMode as Mock).mockReturnValue(ApprovalMode.YOLO);
mockShellExecute.mockReturnValue({
result: Promise.resolve({ ...SUCCESS_RESULT, output: 'deleted' }),
@@ -227,17 +238,14 @@ describe('ShellProcessor', () => {
const prompt: PromptPipelineContent = createPromptPipelineContent(
'Do something forbidden: !{reboot}',
);
mockCheckCommandPermissions.mockReturnValue({
allAllowed: false,
disallowedCommands: ['reboot'],
isHardDenial: true, // This is the key difference
blockReason: 'System commands are blocked',
mockPolicyEngineCheck.mockResolvedValue({
decision: PolicyDecision.DENY,
});
// Set approval mode to YOLO
(mockConfig.getApprovalMode as Mock).mockReturnValue(ApprovalMode.YOLO);
await expect(processor.process(prompt, context)).rejects.toThrow(
/Blocked command: "reboot". Reason: System commands are blocked/,
/Blocked command: "reboot". Reason: Blocked by policy/,
);
// Ensure it never tried to execute
@@ -249,9 +257,8 @@ describe('ShellProcessor', () => {
const prompt: PromptPipelineContent = createPromptPipelineContent(
'Do something dangerous: !{rm -rf /}',
);
mockCheckCommandPermissions.mockReturnValue({
allAllowed: false,
disallowedCommands: ['rm -rf /'],
mockPolicyEngineCheck.mockResolvedValue({
decision: PolicyDecision.ASK_USER,
});
try {
@@ -273,14 +280,12 @@ describe('ShellProcessor', () => {
const prompt: PromptPipelineContent = createPromptPipelineContent(
'!{cmd1} and !{cmd2}',
);
mockCheckCommandPermissions.mockImplementation((cmd) => {
if (cmd === 'cmd1') {
return { allAllowed: false, disallowedCommands: ['cmd1'] };
mockPolicyEngineCheck.mockImplementation(async (toolCall) => {
const cmd = toolCall.args.command;
if (cmd === 'cmd1' || cmd === 'cmd2') {
return { decision: PolicyDecision.ASK_USER };
}
if (cmd === 'cmd2') {
return { allAllowed: false, disallowedCommands: ['cmd2'] };
}
return { allAllowed: true, disallowedCommands: [] };
return { decision: PolicyDecision.ALLOW };
});
try {
@@ -301,11 +306,12 @@ describe('ShellProcessor', () => {
'First: !{echo "hello"}, Second: !{rm -rf /}',
);
mockCheckCommandPermissions.mockImplementation((cmd) => {
mockPolicyEngineCheck.mockImplementation(async (toolCall) => {
const cmd = toolCall.args.command;
if (cmd.includes('rm')) {
return { allAllowed: false, disallowedCommands: [cmd] };
return { decision: PolicyDecision.ASK_USER };
}
return { allAllowed: true, disallowedCommands: [] };
return { decision: PolicyDecision.ALLOW };
});
await expect(processor.process(prompt, context)).rejects.toThrow(
@@ -322,10 +328,13 @@ describe('ShellProcessor', () => {
'Allowed: !{ls -l}, Disallowed: !{rm -rf /}',
);
mockCheckCommandPermissions.mockImplementation((cmd) => ({
allAllowed: !cmd.includes('rm'),
disallowedCommands: cmd.includes('rm') ? [cmd] : [],
}));
mockPolicyEngineCheck.mockImplementation(async (toolCall) => {
const cmd = toolCall.args.command;
if (cmd.includes('rm')) {
return { decision: PolicyDecision.ASK_USER };
}
return { decision: PolicyDecision.ALLOW };
});
try {
await processor.process(prompt, context);
@@ -344,13 +353,12 @@ describe('ShellProcessor', () => {
'Run !{cmd1} and !{cmd2}',
);
// Add commands to the session allowlist
// Add commands to the session allowlist (conceptually, in this test we just mock the engine allowing them)
context.session.sessionShellAllowlist = new Set(['cmd1', 'cmd2']);
// checkCommandPermissions should now pass for these
mockCheckCommandPermissions.mockReturnValue({
allAllowed: true,
disallowedCommands: [],
mockPolicyEngineCheck.mockResolvedValue({
decision: PolicyDecision.ALLOW,
});
mockShellExecute
@@ -363,20 +371,58 @@ describe('ShellProcessor', () => {
const result = await processor.process(prompt, context);
expect(mockCheckCommandPermissions).toHaveBeenCalledWith(
'cmd1',
expect.any(Object),
context.session.sessionShellAllowlist,
);
expect(mockCheckCommandPermissions).toHaveBeenCalledWith(
'cmd2',
expect.any(Object),
context.session.sessionShellAllowlist,
);
expect(mockPolicyEngineCheck).not.toHaveBeenCalled();
expect(mockShellExecute).toHaveBeenCalledTimes(2);
expect(result).toEqual([{ text: 'Run output1 and output2' }]);
});
it('should support the full confirmation flow (Ask -> Approve -> Retry)', async () => {
// 1. Initial State: Command NOT allowed
const processor = new ShellProcessor('test-command');
const prompt: PromptPipelineContent =
createPromptPipelineContent('!{echo "once"}');
// Policy Engine says ASK_USER
mockPolicyEngineCheck.mockResolvedValue({
decision: PolicyDecision.ASK_USER,
});
// 2. First Attempt: processing should fail with ConfirmationRequiredError
try {
await processor.process(prompt, context);
expect.fail('Should have thrown ConfirmationRequiredError');
} catch (e) {
expect(e).toBeInstanceOf(ConfirmationRequiredError);
expect(mockPolicyEngineCheck).toHaveBeenCalledTimes(1);
}
// 3. User Approves: Add to session allowlist (simulating UI action)
context.session.sessionShellAllowlist.add('echo "once"');
// 4. Retry: calling process() again with the same context
// Reset mocks to ensure we track new calls cleanly
mockPolicyEngineCheck.mockClear();
// Mock successful execution
mockShellExecute.mockReturnValue({
result: Promise.resolve({ ...SUCCESS_RESULT, output: 'once' }),
});
const result = await processor.process(prompt, context);
// 5. Verify Success AND Policy Engine Bypass
expect(mockPolicyEngineCheck).not.toHaveBeenCalled();
expect(mockShellExecute).toHaveBeenCalledWith(
'echo "once"',
expect.any(String),
expect.any(Function),
expect.any(Object),
false,
expect.any(Object),
);
expect(result).toEqual([{ text: 'once' }]);
});
it('should trim whitespace from the command inside the injection before interpolation', async () => {
const processor = new ShellProcessor('test-command');
const prompt: PromptPipelineContent = createPromptPipelineContent(
@@ -389,9 +435,8 @@ describe('ShellProcessor', () => {
const expectedCommand = `ls ${expectedEscapedArgs} -l`;
mockCheckCommandPermissions.mockReturnValue({
allAllowed: true,
disallowedCommands: [],
mockPolicyEngineCheck.mockResolvedValue({
decision: PolicyDecision.ALLOW,
});
mockShellExecute.mockReturnValue({
result: Promise.resolve({ ...SUCCESS_RESULT, output: 'total 0' }),
@@ -399,10 +444,9 @@ describe('ShellProcessor', () => {
await processor.process(prompt, context);
expect(mockCheckCommandPermissions).toHaveBeenCalledWith(
expectedCommand,
expect.any(Object),
context.session.sessionShellAllowlist,
expect(mockPolicyEngineCheck).toHaveBeenCalledWith(
{ name: 'run_shell_command', args: { command: expectedCommand } },
undefined,
);
expect(mockShellExecute).toHaveBeenCalledWith(
expectedCommand,
@@ -421,7 +465,7 @@ describe('ShellProcessor', () => {
const result = await processor.process(prompt, context);
expect(mockCheckCommandPermissions).not.toHaveBeenCalled();
expect(mockPolicyEngineCheck).not.toHaveBeenCalled();
expect(mockShellExecute).not.toHaveBeenCalled();
// It replaces !{} with an empty string.
@@ -615,20 +659,20 @@ describe('ShellProcessor', () => {
const expectedEscapedArgs = getExpectedEscapedArgForPlatform(rawArgs);
const expectedResolvedCommand = `rm ${expectedEscapedArgs}`;
mockCheckCommandPermissions.mockReturnValue({
allAllowed: false,
disallowedCommands: [expectedResolvedCommand],
isHardDenial: false,
mockPolicyEngineCheck.mockResolvedValue({
decision: PolicyDecision.ASK_USER,
});
await expect(processor.process(prompt, context)).rejects.toThrow(
ConfirmationRequiredError,
);
expect(mockCheckCommandPermissions).toHaveBeenCalledWith(
expectedResolvedCommand,
expect.any(Object),
context.session.sessionShellAllowlist,
expect(mockPolicyEngineCheck).toHaveBeenCalledWith(
{
name: 'run_shell_command',
args: { command: expectedResolvedCommand },
},
undefined,
);
});
@@ -638,15 +682,12 @@ describe('ShellProcessor', () => {
createPromptPipelineContent('!{rm {{args}}}');
const expectedEscapedArgs = getExpectedEscapedArgForPlatform(rawArgs);
const expectedResolvedCommand = `rm ${expectedEscapedArgs}`;
mockCheckCommandPermissions.mockReturnValue({
allAllowed: false,
disallowedCommands: [expectedResolvedCommand],
isHardDenial: true,
blockReason: 'It is forbidden.',
mockPolicyEngineCheck.mockResolvedValue({
decision: PolicyDecision.DENY,
});
await expect(processor.process(prompt, context)).rejects.toThrow(
`Blocked command: "${expectedResolvedCommand}". Reason: It is forbidden.`,
`Blocked command: "${expectedResolvedCommand}". Reason: Blocked by policy.`,
);
});
});
@@ -5,12 +5,11 @@
*/
import {
ApprovalMode,
checkCommandPermissions,
escapeShellArg,
getShellConfiguration,
ShellExecutionService,
flatMapTextParts,
PolicyDecision,
} from '@google/gemini-cli-core';
import type { CommandContext } from '../../ui/commands/types.js';
@@ -81,7 +80,6 @@ export class ShellProcessor implements IPromptProcessor {
`Security configuration not loaded. Cannot verify shell command permissions for '${this.commandName}'. Aborting.`,
);
}
const { sessionShellAllowlist } = context.session;
const injections = extractInjections(
prompt,
@@ -121,21 +119,25 @@ export class ShellProcessor implements IPromptProcessor {
if (!command) continue;
if (context.session.sessionShellAllowlist?.has(command)) {
continue;
}
// Security check on the final, escaped command string.
const { allAllowed, disallowedCommands, blockReason, isHardDenial } =
checkCommandPermissions(command, config, sessionShellAllowlist);
const { decision } = await config.getPolicyEngine().check(
{
name: 'run_shell_command',
args: { command },
},
undefined,
);
if (!allAllowed) {
if (isHardDenial) {
throw new Error(
`${this.commandName} cannot be run. Blocked command: "${command}". Reason: ${blockReason || 'Blocked by configuration.'}`,
);
}
// If not a hard denial, respect YOLO mode and auto-approve.
if (config.getApprovalMode() !== ApprovalMode.YOLO) {
disallowedCommands.forEach((uc) => commandsToConfirm.add(uc));
}
if (decision === PolicyDecision.DENY) {
throw new Error(
`${this.commandName} cannot be run. Blocked command: "${command}". Reason: Blocked by policy.`,
);
} else if (decision === PolicyDecision.ASK_USER) {
commandsToConfirm.add(command);
}
}
@@ -33,6 +33,7 @@ import {
ApprovalMode,
HookSystem,
PREVIEW_GEMINI_MODEL,
PolicyDecision,
} from '@google/gemini-cli-core';
import { MockTool } from '@google/gemini-cli-core/src/test-utils/mock-tool.js';
import { createMockMessageBus } from '@google/gemini-cli-core/src/test-utils/mock-message-bus.js';
@@ -80,13 +81,21 @@ const mockConfig = {
getGeminiClient: () => null, // No client needed for these tests
getShellExecutionConfig: () => ({ terminalWidth: 80, terminalHeight: 24 }),
getMessageBus: () => null,
getPolicyEngine: () => null,
isInteractive: () => false,
getExperiments: () => {},
getEnableHooks: () => false,
} as unknown as Config;
mockConfig.getMessageBus = vi.fn().mockReturnValue(createMockMessageBus());
mockConfig.getHookSystem = vi.fn().mockReturnValue(new HookSystem(mockConfig));
mockConfig.getPolicyEngine = vi.fn().mockReturnValue({
check: async () => {
const mode = mockConfig.getApprovalMode();
if (mode === ApprovalMode.YOLO) {
return { decision: PolicyDecision.ALLOW };
}
return { decision: PolicyDecision.ASK_USER };
},
});
function createMockConfigOverride(overrides: Partial<Config> = {}): Config {
return { ...mockConfig, ...overrides } as Config;
+1 -3
View File
@@ -84,7 +84,7 @@ import { FileExclusions } from '../utils/ignorePatterns.js';
import type { EventEmitter } from 'node:events';
import { MessageBus } from '../confirmation-bus/message-bus.js';
import { PolicyEngine } from '../policy/policy-engine.js';
import type { PolicyEngineConfig } from '../policy/types.js';
import { ApprovalMode, type PolicyEngineConfig } from '../policy/types.js';
import { HookSystem } from '../hooks/index.js';
import type { UserTierId } from '../code_assist/types.js';
import type { RetrieveUserQuotaResponse } from '../code_assist/types.js';
@@ -101,8 +101,6 @@ import { debugLogger } from '../utils/debugLogger.js';
import { SkillManager, type SkillDefinition } from '../skills/skillManager.js';
import { startupProfiler } from '../telemetry/startupProfiler.js';
import { ApprovalMode } from '../policy/types.js';
export interface AccessibilitySettings {
disableLoadingPhrases?: boolean;
screenReader?: boolean;
@@ -19,7 +19,6 @@ import type {
ToolResult,
Config,
ToolRegistry,
AnyToolInvocation,
MessageBus,
} from '../index.js';
import {
@@ -31,6 +30,7 @@ import {
Kind,
ApprovalMode,
HookSystem,
PolicyDecision,
} from '../index.js';
import { createMockMessageBus } from '../test-utils/mock-message-bus.js';
import {
@@ -39,8 +39,8 @@ import {
MOCK_TOOL_SHOULD_CONFIRM_EXECUTE,
} from '../test-utils/mock-tool.js';
import * as modifiableToolModule from '../tools/modifiable-tool.js';
import { isShellInvocationAllowlisted } from '../utils/shell-permissions.js';
import { DEFAULT_GEMINI_MODEL } from '../config/models.js';
import type { PolicyEngine } from '../policy/policy-engine.js';
vi.mock('fs/promises', () => ({
writeFile: vi.fn(),
@@ -274,11 +274,35 @@ function createMockConfig(overrides: Partial<Config> = {}): Config {
getGeminiClient: () => null,
getMessageBus: () => createMockMessageBus(),
getEnableHooks: () => false,
getPolicyEngine: () => null,
getExperiments: () => {},
} as unknown as Config;
return { ...baseConfig, ...overrides } as Config;
const finalConfig = { ...baseConfig, ...overrides } as Config;
// Patch the policy engine to use the final config if not overridden
if (!overrides.getPolicyEngine) {
finalConfig.getPolicyEngine = () =>
({
check: async (toolCall: { name: string; args: object }) => {
// Mock simple policy logic for tests
const mode = finalConfig.getApprovalMode();
if (mode === ApprovalMode.YOLO) {
return { decision: PolicyDecision.ALLOW };
}
const allowed = finalConfig.getAllowedTools();
if (
allowed &&
(allowed.includes(toolCall.name) ||
allowed.some((p) => toolCall.name.startsWith(p)))
) {
return { decision: PolicyDecision.ALLOW };
}
return { decision: PolicyDecision.ASK_USER };
},
}) as unknown as PolicyEngine;
}
return finalConfig;
}
describe('CoreToolScheduler', () => {
@@ -570,7 +594,7 @@ describe('CoreToolScheduler', () => {
const mockConfig = createMockConfig({
getToolRegistry: () => mockToolRegistry,
isInteractive: () => false,
isInteractive: () => true,
});
const scheduler = new CoreToolScheduler({
@@ -1192,15 +1216,6 @@ describe('CoreToolScheduler request queueing', () => {
});
it('should require approval for a chained shell command even when prefix is allowlisted', async () => {
expect(
isShellInvocationAllowlisted(
{
params: { command: 'git status && rm -rf /tmp/should-not-run' },
} as unknown as AnyToolInvocation,
['run_shell_command(git)'],
),
).toBe(false);
const executeFn = vi.fn().mockResolvedValue({
llmContent: 'Shell command executed',
returnDisplay: 'Shell command executed',
@@ -1249,6 +1264,10 @@ describe('CoreToolScheduler request queueing', () => {
}),
getToolRegistry: () => toolRegistry,
getHookSystem: () => undefined,
getPolicyEngine: () =>
({
check: async () => ({ decision: PolicyDecision.ASK_USER }),
}) as unknown as PolicyEngine,
});
const scheduler = new CoreToolScheduler({
+36 -27
View File
@@ -14,7 +14,7 @@ import {
} from '../tools/tools.js';
import type { EditorType } from '../utils/editor.js';
import type { Config } from '../config/config.js';
import { ApprovalMode } from '../policy/types.js';
import { PolicyDecision } from '../policy/types.js';
import { logToolCall } from '../telemetry/loggers.js';
import { ToolErrorType } from '../tools/tool-error.js';
import { ToolCallEvent } from '../telemetry/types.js';
@@ -25,12 +25,7 @@ import {
modifyWithEditor,
} from '../tools/modifiable-tool.js';
import * as Diff from 'diff';
import { SHELL_TOOL_NAMES } from '../utils/shell-utils.js';
import {
doesToolInvocationMatch,
getToolSuggestion,
} from '../utils/tool-utils.js';
import { isShellInvocationAllowlisted } from '../utils/shell-permissions.js';
import { getToolSuggestion } from '../utils/tool-utils.js';
import type { ToolConfirmationRequest } from '../confirmation-bus/types.js';
import { MessageBusType } from '../confirmation-bus/types.js';
import type { MessageBus } from '../confirmation-bus/message-bus.js';
@@ -592,17 +587,46 @@ export class CoreToolScheduler {
return;
}
const confirmationDetails =
await invocation.shouldConfirmExecute(signal);
// Policy Check using PolicyEngine
// We must reconstruct the FunctionCall format expected by PolicyEngine
const toolCallForPolicy = {
name: toolCall.request.name,
args: toolCall.request.args,
};
const { decision } = await this.config
.getPolicyEngine()
.check(toolCallForPolicy, undefined); // Server name undefined for local tools
if (!confirmationDetails) {
if (decision === PolicyDecision.DENY) {
const errorMessage = `Tool execution denied by policy.`;
this.setStatusInternal(
reqInfo.callId,
'error',
signal,
createErrorResponse(
reqInfo,
new Error(errorMessage),
ToolErrorType.POLICY_VIOLATION,
),
);
await this.checkAndNotifyCompletion(signal);
return;
}
if (decision === PolicyDecision.ALLOW) {
this.setToolCallOutcome(
reqInfo.callId,
ToolConfirmationOutcome.ProceedAlways,
);
this.setStatusInternal(reqInfo.callId, 'scheduled', signal);
} else {
if (this.isAutoApproved(toolCall)) {
// PolicyDecision.ASK_USER
// We need confirmation details to show to the user
const confirmationDetails =
await invocation.shouldConfirmExecute(signal);
if (!confirmationDetails) {
this.setToolCallOutcome(
reqInfo.callId,
ToolConfirmationOutcome.ProceedAlways,
@@ -616,6 +640,7 @@ export class CoreToolScheduler {
}" requires user confirmation, which is not supported in non-interactive mode.`,
);
}
// Fire Notification hook before showing confirmation to user
const messageBus = this.config.getMessageBus();
const hooksEnabled = this.config.getEnableHooks();
@@ -1014,20 +1039,4 @@ export class CoreToolScheduler {
};
});
}
private isAutoApproved(toolCall: ValidatingToolCall): boolean {
if (this.config.getApprovalMode() === ApprovalMode.YOLO) {
return true;
}
const allowedTools = this.config.getAllowedTools() || [];
const { tool, invocation } = toolCall;
const toolName = typeof tool === 'string' ? tool : tool.name;
if (SHELL_TOOL_NAMES.includes(toolName)) {
return isShellInvocationAllowlisted(invocation, allowedTools);
}
return doesToolInvocationMatch(tool, invocation, allowedTools);
}
}
@@ -20,6 +20,7 @@ import {
ApprovalMode,
HookSystem,
PREVIEW_GEMINI_MODEL,
PolicyDecision,
} from '../index.js';
import type { Part } from '@google/genai';
import { MockTool } from '../test-utils/mock-tool.js';
@@ -65,7 +66,9 @@ describe('executeToolCall', () => {
getActiveModel: () => PREVIEW_GEMINI_MODEL,
getGeminiClient: () => null, // No client needed for these tests
getMessageBus: () => null,
getPolicyEngine: () => null,
getPolicyEngine: () => ({
check: async () => ({ decision: PolicyDecision.ALLOW }),
}),
isInteractive: () => false,
getExperiments: () => {},
getEnableHooks: () => false,
+2 -1
View File
@@ -67,7 +67,8 @@ export * from './utils/googleQuotaErrors.js';
export * from './utils/fileUtils.js';
export * from './utils/retry.js';
export * from './utils/shell-utils.js';
export * from './utils/shell-permissions.js';
export { PolicyDecision, ApprovalMode } from './policy/types.js';
export * from './utils/tool-utils.js';
export * from './utils/terminalSerializer.js';
export * from './utils/systemEncoding.js';
export * from './utils/textUtils.js';
+1 -1
View File
@@ -127,7 +127,7 @@ describe('createPolicyUpdater', () => {
expect(addedRule).toBeDefined();
expect(addedRule?.priority).toBe(2.95);
expect(addedRule?.argsPattern).toEqual(
new RegExp(`"command":"git\\ status(?:[\\s"]|$)`),
new RegExp(`"command":"git\\ status(?:[\\s"]|\\\\")`),
);
// Verify file written
@@ -72,14 +72,14 @@ describe('createPolicyUpdater', () => {
1,
expect.objectContaining({
toolName: 'run_shell_command',
argsPattern: new RegExp('"command":"echo(?:[\\s"]|$)'),
argsPattern: new RegExp('"command":"echo(?:[\\s"]|\\\\")'),
}),
);
expect(policyEngine.addRule).toHaveBeenNthCalledWith(
2,
expect.objectContaining({
toolName: 'run_shell_command',
argsPattern: new RegExp('"command":"ls(?:[\\s"]|$)'),
argsPattern: new RegExp('"command":"ls(?:[\\s"]|\\\\")'),
}),
);
});
@@ -98,7 +98,7 @@ describe('createPolicyUpdater', () => {
expect(policyEngine.addRule).toHaveBeenCalledWith(
expect.objectContaining({
toolName: 'run_shell_command',
argsPattern: new RegExp('"command":"git(?:[\\s"]|$)'),
argsPattern: new RegExp('"command":"git(?:[\\s"]|\\\\")'),
}),
);
});
+39 -6
View File
@@ -31,14 +31,14 @@ describe('policy/utils', () => {
it('should build pattern from a single commandPrefix', () => {
const result = buildArgsPatterns(undefined, 'ls', undefined);
expect(result).toEqual(['"command":"ls(?:[\\s"]|$)']);
expect(result).toEqual(['"command":"ls(?:[\\s"]|\\\\")']);
});
it('should build patterns from an array of commandPrefixes', () => {
const result = buildArgsPatterns(undefined, ['ls', 'cd'], undefined);
expect(result).toEqual([
'"command":"ls(?:[\\s"]|$)',
'"command":"cd(?:[\\s"]|$)',
'"command":"ls(?:[\\s"]|\\\\")',
'"command":"cd(?:[\\s"]|\\\\")',
]);
});
@@ -49,7 +49,7 @@ describe('policy/utils', () => {
it('should prioritize commandPrefix over commandRegex and argsPattern', () => {
const result = buildArgsPatterns('raw', 'prefix', 'regex');
expect(result).toEqual(['"command":"prefix(?:[\\s"]|$)']);
expect(result).toEqual(['"command":"prefix(?:[\\s"]|\\\\")']);
});
it('should prioritize commandRegex over argsPattern if no commandPrefix', () => {
@@ -59,13 +59,15 @@ describe('policy/utils', () => {
it('should escape characters in commandPrefix', () => {
const result = buildArgsPatterns(undefined, 'git checkout -b', undefined);
expect(result).toEqual(['"command":"git\\ checkout\\ \\-b(?:[\\s"]|$)']);
expect(result).toEqual([
'"command":"git\\ checkout\\ \\-b(?:[\\s"]|\\\\")',
]);
});
it('should correctly escape quotes in commandPrefix', () => {
const result = buildArgsPatterns(undefined, 'git "fix"', undefined);
expect(result).toEqual([
'"command":"git\\ \\\\\\"fix\\\\\\"(?:[\\s"]|$)',
'"command":"git\\ \\\\\\"fix\\\\\\"(?:[\\s"]|\\\\")',
]);
});
@@ -73,5 +75,36 @@ describe('policy/utils', () => {
const result = buildArgsPatterns(undefined, undefined, undefined);
expect(result).toEqual([undefined]);
});
it('should match prefixes followed by JSON escaped quotes', () => {
// Testing the security fix logic: allowing "echo \"foo\""
const prefix = 'echo ';
const patterns = buildArgsPatterns(undefined, prefix, undefined);
const regex = new RegExp(patterns[0]!);
// Mimic JSON stringified args
// echo "foo" -> {"command":"echo \"foo\""}
const validJsonArgs = '{"command":"echo \\"foo\\""}';
expect(regex.test(validJsonArgs)).toBe(true);
});
it('should NOT match prefixes followed by raw backslashes (security check)', () => {
// Testing that we blocked the hole: "echo\foo"
const prefix = 'echo ';
const patterns = buildArgsPatterns(undefined, prefix, undefined);
const regex = new RegExp(patterns[0]!);
// echo\foo -> {"command":"echo\\foo"}
// In regex matching: "echo " is followed by "\" which is NOT in [\s"] and is not \"
const attackJsonArgs = '{"command":"echo\\\\foo"}';
expect(regex.test(attackJsonArgs)).toBe(false);
// Also validation for "git " matching "git\status"
const gitPatterns = buildArgsPatterns(undefined, 'git ', undefined);
const gitRegex = new RegExp(gitPatterns[0]!);
// git\status -> {"command":"git\\status"}
const gitAttack = '{"command":"git\\\\status"}';
expect(gitRegex.test(gitAttack)).toBe(false);
});
});
});
+4 -1
View File
@@ -38,7 +38,10 @@ export function buildArgsPatterns(
// always followed by a space or a closing quote.
return prefixes.map((prefix) => {
const jsonPrefix = JSON.stringify(prefix).slice(1, -1);
return `"command":"${escapeRegex(jsonPrefix)}(?:[\\s"]|$)`;
// We allow [\s], ["], or the specific sequence [\"] (for escaped quotes
// in JSON). We do NOT allow generic [\\], which would match "git\status"
// -> "gitstatus".
return `"command":"${escapeRegex(jsonPrefix)}(?:[\\s"]|\\\\")`;
});
}
+4
View File
@@ -12,6 +12,10 @@
* - Fatal: System-level issues that prevent continued execution (e.g., disk full, critical I/O errors)
*/
export enum ToolErrorType {
POLICY_VIOLATION = 'policy_violation',
/**
* General tool execution failure (e.g. file system error, API error).
*/
// General Errors
INVALID_TOOL_PARAMS = 'invalid_tool_params',
UNKNOWN = 'unknown',
@@ -1,551 +0,0 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import {
expect,
describe,
it,
beforeEach,
beforeAll,
vi,
afterEach,
} from 'vitest';
import { initializeShellParsers } from './shell-utils.js';
import {
checkCommandPermissions,
isCommandAllowed,
isShellInvocationAllowlisted,
} from './shell-permissions.js';
import type { Config } from '../config/config.js';
import type { AnyToolInvocation } from '../index.js';
const mockPlatform = vi.hoisted(() => vi.fn());
const mockHomedir = vi.hoisted(() => vi.fn());
vi.mock('os', () => ({
default: {
platform: mockPlatform,
homedir: mockHomedir,
},
platform: mockPlatform,
homedir: mockHomedir,
}));
const mockSpawnSync = vi.hoisted(() => vi.fn());
vi.mock('node:child_process', async (importOriginal) => {
const actual = await importOriginal<typeof import('node:child_process')>();
return {
...actual,
spawnSync: mockSpawnSync,
};
});
const mockQuote = vi.hoisted(() => vi.fn());
vi.mock('shell-quote', () => ({
quote: mockQuote,
}));
let config: Config;
const isWindowsRuntime = process.platform === 'win32';
const describeWindowsOnly = isWindowsRuntime ? describe : describe.skip;
beforeAll(async () => {
mockPlatform.mockReturnValue('linux');
await initializeShellParsers();
});
beforeEach(() => {
mockPlatform.mockReturnValue('linux');
mockQuote.mockImplementation((args: string[]) =>
args.map((arg) => `'${arg}'`).join(' '),
);
config = {
getCoreTools: () => [],
getExcludeTools: () => new Set([]),
getAllowedTools: () => [],
getApprovalMode: () => 'strict',
isInteractive: () => false,
} as unknown as Config;
});
afterEach(() => {
vi.clearAllMocks();
});
describe('isCommandAllowed', () => {
it('should allow a command if no restrictions are provided', () => {
const result = isCommandAllowed('goodCommand --safe', config);
expect(result.allowed).toBe(true);
});
it('should allow a command if it is in the global allowlist', () => {
config.getCoreTools = () => ['ShellTool(goodCommand)'];
const result = isCommandAllowed('goodCommand --safe', config);
expect(result.allowed).toBe(true);
});
it('should block a command if it is not in a strict global allowlist', () => {
config.getCoreTools = () => ['ShellTool(goodCommand --safe)'];
const result = isCommandAllowed('badCommand --danger', config);
expect(result.allowed).toBe(false);
expect(result.reason).toBe(
`Command(s) not in the allowed commands list. Disallowed commands: "badCommand --danger"`,
);
});
it('should block a command if it is in the blocked list', () => {
config.getExcludeTools = () => new Set(['ShellTool(badCommand --danger)']);
const result = isCommandAllowed('badCommand --danger', config);
expect(result.allowed).toBe(false);
expect(result.reason).toBe(
`Command 'badCommand --danger' is blocked by configuration`,
);
});
it('should prioritize the blocklist over the allowlist', () => {
config.getCoreTools = () => ['ShellTool(badCommand --danger)'];
config.getExcludeTools = () => new Set(['ShellTool(badCommand --danger)']);
const result = isCommandAllowed('badCommand --danger', config);
expect(result.allowed).toBe(false);
expect(result.reason).toBe(
`Command 'badCommand --danger' is blocked by configuration`,
);
});
it('should allow any command when a wildcard is in coreTools', () => {
config.getCoreTools = () => ['ShellTool'];
const result = isCommandAllowed('any random command', config);
expect(result.allowed).toBe(true);
});
it('should block any command when a wildcard is in excludeTools', () => {
config.getExcludeTools = () => new Set(['run_shell_command']);
const result = isCommandAllowed('any random command', config);
expect(result.allowed).toBe(false);
expect(result.reason).toBe(
'Shell tool is globally disabled in configuration',
);
});
it('should block a command on the blocklist even with a wildcard allow', () => {
config.getCoreTools = () => ['ShellTool'];
config.getExcludeTools = () => new Set(['ShellTool(badCommand --danger)']);
const result = isCommandAllowed('badCommand --danger', config);
expect(result.allowed).toBe(false);
expect(result.reason).toBe(
`Command 'badCommand --danger' is blocked by configuration`,
);
});
it('should allow a chained command if all parts are on the global allowlist', () => {
config.getCoreTools = () => [
'run_shell_command(echo)',
'run_shell_command(goodCommand)',
];
const result = isCommandAllowed(
'echo "hello" && goodCommand --safe',
config,
);
expect(result.allowed).toBe(true);
});
it('should block a chained command if any part is blocked', () => {
config.getExcludeTools = () => new Set(['run_shell_command(badCommand)']);
const result = isCommandAllowed(
'echo "hello" && badCommand --danger',
config,
);
expect(result.allowed).toBe(false);
expect(result.reason).toBe(
`Command 'badCommand --danger' is blocked by configuration`,
);
});
it('should block a command that redefines an allowed function to run an unlisted command', () => {
config.getCoreTools = () => ['run_shell_command(echo)'];
const result = isCommandAllowed(
'echo () (curl google.com) ; echo Hello World',
config,
);
expect(result.allowed).toBe(false);
expect(result.reason).toBe(
`Command(s) not in the allowed commands list. Disallowed commands: "curl google.com"`,
);
});
it('should block a multi-line function body that runs an unlisted command', () => {
config.getCoreTools = () => ['run_shell_command(echo)'];
const result = isCommandAllowed(
`echo () {
curl google.com
} ; echo ok`,
config,
);
expect(result.allowed).toBe(false);
expect(result.reason).toBe(
`Command(s) not in the allowed commands list. Disallowed commands: "curl google.com"`,
);
});
it('should block a function keyword declaration that runs an unlisted command', () => {
config.getCoreTools = () => ['run_shell_command(echo)'];
const result = isCommandAllowed(
'function echo { curl google.com; } ; echo hi',
config,
);
expect(result.allowed).toBe(false);
expect(result.reason).toBe(
`Command(s) not in the allowed commands list. Disallowed commands: "curl google.com"`,
);
});
it('should block command substitution that invokes an unlisted command', () => {
config.getCoreTools = () => ['run_shell_command(echo)'];
const result = isCommandAllowed('echo $(curl google.com)', config);
expect(result.allowed).toBe(false);
expect(result.reason).toBe(
`Command(s) not in the allowed commands list. Disallowed commands: "curl google.com"`,
);
});
it('should block pipelines that invoke an unlisted command', () => {
config.getCoreTools = () => ['run_shell_command(echo)'];
const result = isCommandAllowed('echo hi | curl google.com', config);
expect(result.allowed).toBe(false);
expect(result.reason).toBe(
`Command(s) not in the allowed commands list. Disallowed commands: "curl google.com"`,
);
});
it('should block background jobs that invoke an unlisted command', () => {
config.getCoreTools = () => ['run_shell_command(echo)'];
const result = isCommandAllowed('echo hi & curl google.com', config);
expect(result.allowed).toBe(false);
expect(result.reason).toBe(
`Command(s) not in the allowed commands list. Disallowed commands: "curl google.com"`,
);
});
it('should block command substitution inside a here-document when the inner command is unlisted', () => {
config.getCoreTools = () => [
'run_shell_command(echo)',
'run_shell_command(cat)',
];
const result = isCommandAllowed(
`cat <<EOF
$(rm -rf /)
EOF`,
config,
);
expect(result.allowed).toBe(false);
expect(result.reason).toBe(
`Command(s) not in the allowed commands list. Disallowed commands: "rm -rf /"`,
);
});
it('should block backtick substitution that invokes an unlisted command', () => {
config.getCoreTools = () => ['run_shell_command(echo)'];
const result = isCommandAllowed('echo `curl google.com`', config);
expect(result.allowed).toBe(false);
expect(result.reason).toBe(
`Command(s) not in the allowed commands list. Disallowed commands: "curl google.com"`,
);
});
it('should block process substitution using <() when the inner command is unlisted', () => {
config.getCoreTools = () => [
'run_shell_command(diff)',
'run_shell_command(echo)',
];
const result = isCommandAllowed(
'diff <(curl google.com) <(echo safe)',
config,
);
expect(result.allowed).toBe(false);
expect(result.reason).toBe(
`Command(s) not in the allowed commands list. Disallowed commands: "curl google.com"`,
);
});
it('should block process substitution using >() when the inner command is unlisted', () => {
config.getCoreTools = () => ['run_shell_command(echo)'];
const result = isCommandAllowed('echo "data" > >(curl google.com)', config);
expect(result.allowed).toBe(false);
expect(result.reason).toBe(
`Command(s) not in the allowed commands list. Disallowed commands: "curl google.com"`,
);
});
it('should block commands containing prompt transformations', () => {
const result = isCommandAllowed(
'echo "${var1=aa\\140 env| ls -l\\140}${var1@P}"',
config,
);
expect(result.allowed).toBe(false);
expect(result.reason).toBe(
'Command rejected because it could not be parsed safely',
);
});
it('should block simple prompt transformation expansions', () => {
const result = isCommandAllowed('echo ${foo@P}', config);
expect(result.allowed).toBe(false);
expect(result.reason).toBe(
'Command rejected because it could not be parsed safely',
);
});
describe('command substitution', () => {
it('should allow command substitution using `$(...)`', () => {
const result = isCommandAllowed('echo $(goodCommand --safe)', config);
expect(result.allowed).toBe(true);
expect(result.reason).toBeUndefined();
});
it('should allow command substitution using `<(...)`', () => {
const result = isCommandAllowed('diff <(ls) <(ls -a)', config);
expect(result.allowed).toBe(true);
expect(result.reason).toBeUndefined();
});
it('should allow command substitution using `>(...)`', () => {
const result = isCommandAllowed(
'echo "Log message" > >(tee log.txt)',
config,
);
expect(result.allowed).toBe(true);
expect(result.reason).toBeUndefined();
});
it('should allow command substitution using backticks', () => {
const result = isCommandAllowed('echo `goodCommand --safe`', config);
expect(result.allowed).toBe(true);
expect(result.reason).toBeUndefined();
});
it('should allow substitution-like patterns inside single quotes', () => {
config.getCoreTools = () => ['ShellTool(echo)'];
const result = isCommandAllowed("echo '$(pwd)'", config);
expect(result.allowed).toBe(true);
});
it('should block a command when parsing fails', () => {
const result = isCommandAllowed('ls &&', config);
expect(result.allowed).toBe(false);
expect(result.reason).toBe(
'Command rejected because it could not be parsed safely',
);
});
});
});
describe('checkCommandPermissions', () => {
describe('in "Default Allow" mode (no sessionAllowlist)', () => {
it('should return a detailed success object for an allowed command', () => {
const result = checkCommandPermissions('goodCommand --safe', config);
expect(result).toEqual({
allAllowed: true,
disallowedCommands: [],
});
});
it('should block commands that cannot be parsed safely', () => {
const result = checkCommandPermissions('ls &&', config);
expect(result).toEqual({
allAllowed: false,
disallowedCommands: ['ls &&'],
blockReason: 'Command rejected because it could not be parsed safely',
isHardDenial: true,
});
});
it('should return a detailed failure object for a blocked command', () => {
config.getExcludeTools = () => new Set(['ShellTool(badCommand)']);
const result = checkCommandPermissions('badCommand --danger', config);
expect(result).toEqual({
allAllowed: false,
disallowedCommands: ['badCommand --danger'],
blockReason: `Command 'badCommand --danger' is blocked by configuration`,
isHardDenial: true,
});
});
it('should return a detailed failure object for a command not on a strict allowlist', () => {
config.getCoreTools = () => ['ShellTool(goodCommand)'];
const result = checkCommandPermissions(
'git status && goodCommand',
config,
);
expect(result).toEqual({
allAllowed: false,
disallowedCommands: ['git status'],
blockReason: `Command(s) not in the allowed commands list. Disallowed commands: "git status"`,
isHardDenial: false,
});
});
});
describe('in "Default Deny" mode (with sessionAllowlist)', () => {
it('should allow a command on the sessionAllowlist', () => {
const result = checkCommandPermissions(
'goodCommand --safe',
config,
new Set(['goodCommand --safe']),
);
expect(result.allAllowed).toBe(true);
});
it('should block a command not on the sessionAllowlist or global allowlist', () => {
const result = checkCommandPermissions(
'badCommand --danger',
config,
new Set(['goodCommand --safe']),
);
expect(result.allAllowed).toBe(false);
expect(result.blockReason).toContain(
'not on the global or session allowlist',
);
expect(result.disallowedCommands).toEqual(['badCommand --danger']);
});
it('should allow a command on the global allowlist even if not on the session allowlist', () => {
config.getCoreTools = () => ['ShellTool(git status)'];
const result = checkCommandPermissions(
'git status',
config,
new Set(['goodCommand --safe']),
);
expect(result.allAllowed).toBe(true);
});
it('should allow a chained command if parts are on different allowlists', () => {
config.getCoreTools = () => ['ShellTool(git status)'];
const result = checkCommandPermissions(
'git status && git commit',
config,
new Set(['git commit']),
);
expect(result.allAllowed).toBe(true);
});
it('should block a command on the sessionAllowlist if it is also globally blocked', () => {
config.getExcludeTools = () => new Set(['run_shell_command(badCommand)']);
const result = checkCommandPermissions(
'badCommand --danger',
config,
new Set(['badCommand --danger']),
);
expect(result.allAllowed).toBe(false);
expect(result.blockReason).toContain('is blocked by configuration');
});
it('should block a chained command if one part is not on any allowlist', () => {
config.getCoreTools = () => ['run_shell_command(echo)'];
const result = checkCommandPermissions(
'echo "hello" && badCommand --danger',
config,
new Set(['echo']),
);
expect(result.allAllowed).toBe(false);
expect(result.disallowedCommands).toEqual(['badCommand --danger']);
});
});
});
describeWindowsOnly('PowerShell integration', () => {
const originalComSpec = process.env['ComSpec'];
beforeEach(() => {
mockPlatform.mockReturnValue('win32');
const systemRoot = process.env['SystemRoot'] || 'C:\\Windows';
process.env['ComSpec'] =
`${systemRoot}\\System32\\WindowsPowerShell\\v1.0\\powershell.exe`;
});
afterEach(() => {
if (originalComSpec === undefined) {
delete process.env['ComSpec'];
} else {
process.env['ComSpec'] = originalComSpec;
}
});
it('should block commands when PowerShell parser reports errors', () => {
// Mock spawnSync to avoid the overhead of spawning a real PowerShell process,
// which can lead to timeouts in CI environments even on Windows.
mockSpawnSync.mockReturnValue({
status: 0,
stdout: JSON.stringify({ success: false }),
});
const { allowed, reason } = isCommandAllowed('Get-ChildItem |', config);
expect(allowed).toBe(false);
expect(reason).toBe(
'Command rejected because it could not be parsed safely',
);
});
it('should allow valid commands through PowerShell parser', () => {
// Mock spawnSync to avoid the overhead of spawning a real PowerShell process,
// which can lead to timeouts in CI environments even on Windows.
mockSpawnSync.mockReturnValue({
status: 0,
stdout: JSON.stringify({
success: true,
commands: [{ name: 'Get-ChildItem', text: 'Get-ChildItem' }],
}),
});
const { allowed } = isCommandAllowed('Get-ChildItem', config);
expect(allowed).toBe(true);
});
});
describe('isShellInvocationAllowlisted', () => {
function createInvocation(command: string): AnyToolInvocation {
return { params: { command } } as unknown as AnyToolInvocation;
}
it('should return false when any chained command segment is not allowlisted', () => {
const invocation = createInvocation(
'git status && rm -rf /tmp/should-not-run',
);
expect(
isShellInvocationAllowlisted(invocation, ['run_shell_command(git)']),
).toBe(false);
});
it('should return true when every segment is explicitly allowlisted', () => {
const invocation = createInvocation(
'git status && rm -rf /tmp/should-run && git diff',
);
expect(
isShellInvocationAllowlisted(invocation, [
'run_shell_command(git)',
'run_shell_command(rm -rf)',
]),
).toBe(true);
});
it('should return true when the allowlist contains a wildcard shell entry', () => {
const invocation = createInvocation('git status && rm -rf /tmp/should-run');
expect(
isShellInvocationAllowlisted(invocation, ['run_shell_command']),
).toBe(true);
});
it('should treat piped commands as separate segments that must be allowlisted', () => {
const invocation = createInvocation('git status | tail -n 1');
expect(
isShellInvocationAllowlisted(invocation, ['run_shell_command(git)']),
).toBe(false);
expect(
isShellInvocationAllowlisted(invocation, [
'run_shell_command(git)',
'run_shell_command(tail)',
]),
).toBe(true);
});
});
@@ -1,270 +0,0 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import type { AnyToolInvocation } from '../index.js';
import type { Config } from '../config/config.js';
import { doesToolInvocationMatch } from './tool-utils.js';
import {
parseCommandDetails,
SHELL_TOOL_NAMES,
type ParsedCommandDetail,
} from './shell-utils.js';
/**
* Checks a shell command against security policies and allowlists.
*
* This function operates in one of two modes depending on the presence of
* the `sessionAllowlist` parameter:
*
* 1. **"Default Deny" Mode (sessionAllowlist is provided):** This is the
* strictest mode, used for user-defined scripts like custom commands.
* A command is only permitted if it is found on the global `coreTools`
* allowlist OR the provided `sessionAllowlist`. It must not be on the
* global `excludeTools` blocklist.
*
* 2. **"Default Allow" Mode (sessionAllowlist is NOT provided):** This mode
* is used for direct tool invocations (e.g., by the model). If a strict
* global `coreTools` allowlist exists, commands must be on it. Otherwise,
* any command is permitted as long as it is not on the `excludeTools`
* blocklist.
*
* @param command The shell command string to validate.
* @param config The application configuration.
* @param sessionAllowlist A session-level list of approved commands. Its
* presence activates "Default Deny" mode.
* @returns An object detailing which commands are not allowed.
*/
export function checkCommandPermissions(
command: string,
config: Config,
sessionAllowlist?: Set<string>,
): {
allAllowed: boolean;
disallowedCommands: string[];
blockReason?: string;
isHardDenial?: boolean;
} {
const parseResult = parseCommandDetails(command);
if (!parseResult || parseResult.hasError) {
return {
allAllowed: false,
disallowedCommands: [command],
blockReason: 'Command rejected because it could not be parsed safely',
isHardDenial: true,
};
}
const normalize = (cmd: string): string => cmd.trim().replace(/\s+/g, ' ');
const commandsToValidate = parseResult.details
.map((detail: ParsedCommandDetail) => normalize(detail.text))
.filter(Boolean);
const invocation: AnyToolInvocation & { params: { command: string } } = {
params: { command: '' },
} as AnyToolInvocation & { params: { command: string } };
// 1. Blocklist Check (Highest Priority)
const excludeTools = config.getExcludeTools() || new Set([]);
const isWildcardBlocked = SHELL_TOOL_NAMES.some((name) =>
excludeTools.has(name),
);
if (isWildcardBlocked) {
return {
allAllowed: false,
disallowedCommands: commandsToValidate,
blockReason: 'Shell tool is globally disabled in configuration',
isHardDenial: true,
};
}
for (const cmd of commandsToValidate) {
invocation.params['command'] = cmd;
if (
doesToolInvocationMatch('run_shell_command', invocation, [
...excludeTools,
])
) {
return {
allAllowed: false,
disallowedCommands: [cmd],
blockReason: `Command '${cmd}' is blocked by configuration`,
isHardDenial: true,
};
}
}
const coreTools = config.getCoreTools() || [];
const isWildcardAllowed = SHELL_TOOL_NAMES.some((name) =>
coreTools.includes(name),
);
// If there's a global wildcard, all commands are allowed at this point
// because they have already passed the blocklist check.
if (isWildcardAllowed) {
return { allAllowed: true, disallowedCommands: [] };
}
const disallowedCommands: string[] = [];
if (sessionAllowlist) {
// "DEFAULT DENY" MODE: A session allowlist is provided.
// All commands must be in either the session or global allowlist.
const normalizedSessionAllowlist = new Set(
[...sessionAllowlist].flatMap((cmd) =>
SHELL_TOOL_NAMES.map((name) => `${name}(${cmd})`),
),
);
for (const cmd of commandsToValidate) {
invocation.params['command'] = cmd;
const isSessionAllowed = doesToolInvocationMatch(
'run_shell_command',
invocation,
[...normalizedSessionAllowlist],
);
if (isSessionAllowed) continue;
const isGloballyAllowed = doesToolInvocationMatch(
'run_shell_command',
invocation,
coreTools,
);
if (isGloballyAllowed) continue;
disallowedCommands.push(cmd);
}
if (disallowedCommands.length > 0) {
return {
allAllowed: false,
disallowedCommands,
blockReason: `Command(s) not on the global or session allowlist. Disallowed commands: ${disallowedCommands
.map((c) => JSON.stringify(c))
.join(', ')}`,
isHardDenial: false, // This is a soft denial; confirmation is possible.
};
}
} else {
// "DEFAULT ALLOW" MODE: No session allowlist.
const hasSpecificAllowedCommands =
coreTools.filter((tool) =>
SHELL_TOOL_NAMES.some((name) => tool.startsWith(`${name}(`)),
).length > 0;
if (hasSpecificAllowedCommands) {
for (const cmd of commandsToValidate) {
invocation.params['command'] = cmd;
const isGloballyAllowed = doesToolInvocationMatch(
'run_shell_command',
invocation,
coreTools,
);
if (!isGloballyAllowed) {
disallowedCommands.push(cmd);
}
}
if (disallowedCommands.length > 0) {
return {
allAllowed: false,
disallowedCommands,
blockReason: `Command(s) not in the allowed commands list. Disallowed commands: ${disallowedCommands
.map((c) => JSON.stringify(c))
.join(', ')}`,
isHardDenial: false,
};
}
}
// If no specific global allowlist exists, and it passed the blocklist,
// the command is allowed by default.
}
// If all checks for the current mode pass, the command is allowed.
return { allAllowed: true, disallowedCommands: [] };
}
export function isCommandAllowed(
command: string,
config: Config,
): { allowed: boolean; reason?: string } {
// By not providing a sessionAllowlist, we invoke "default allow" behavior.
const { allAllowed, blockReason } = checkCommandPermissions(command, config);
if (allAllowed) {
return { allowed: true };
}
return { allowed: false, reason: blockReason };
}
/**
* Determines whether a shell invocation should be auto-approved based on an allowlist.
*
* This reuses the same parsing logic as command-permission enforcement so that
* chained commands must be individually covered by the allowlist.
*
* @param invocation The shell tool invocation being evaluated.
* @param allowedPatterns The configured allowlist patterns (e.g. `run_shell_command(git)`).
* @returns True if every parsed command segment is allowed by the patterns; false otherwise.
*/
export function isShellInvocationAllowlisted(
invocation: AnyToolInvocation,
allowedPatterns: string[],
): boolean {
if (!allowedPatterns.length) {
return false;
}
const hasShellWildcard = allowedPatterns.some((pattern) =>
SHELL_TOOL_NAMES.includes(pattern),
);
const hasShellSpecificPattern = allowedPatterns.some((pattern) =>
SHELL_TOOL_NAMES.some((name) => pattern.startsWith(`${name}(`)),
);
if (!hasShellWildcard && !hasShellSpecificPattern) {
return false;
}
if (hasShellWildcard) {
return true;
}
if (
!('params' in invocation) ||
typeof invocation.params !== 'object' ||
invocation.params === null ||
!('command' in invocation.params)
) {
return false;
}
const commandValue = (invocation.params as { command?: unknown }).command;
if (typeof commandValue !== 'string' || !commandValue.trim()) {
return false;
}
const command = commandValue.trim();
const parseResult = parseCommandDetails(command);
if (!parseResult || parseResult.hasError) {
return false;
}
const normalize = (cmd: string): string => cmd.trim().replace(/\s+/g, ' ');
const commandsToValidate = parseResult.details
.map((detail: ParsedCommandDetail) => normalize(detail.text))
.filter(Boolean);
if (commandsToValidate.length === 0) {
return false;
}
return commandsToValidate.every((commandSegment: string) =>
doesToolInvocationMatch(
SHELL_TOOL_NAMES[0],
{ params: { command: commandSegment } } as AnyToolInvocation,
allowedPatterns,
),
);
}