Skip to content

Commit e8a065c

Browse files
Make --allowed-tools work in non-interactive mode (#9114)
Co-authored-by: google-labs-jules[bot] <161369871+google-labs-jules[bot]@users.noreply.github.com>
1 parent 8a16165 commit e8a065c

9 files changed

Lines changed: 388 additions & 7 deletions

File tree

integration-tests/run_shell_command.test.ts

Lines changed: 160 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,166 @@ describe('run_shell_command', () => {
6868
validateModelOutput(result, 'test-stdin', 'Shell command stdin test');
6969
});
7070

71+
it('should run allowed sub-command in non-interactive mode', async () => {
72+
const rig = new TestRig();
73+
await rig.setup('should run allowed sub-command in non-interactive mode');
74+
75+
const prompt = `use wc to tell me how many lines there are in /proc/meminfo`;
76+
77+
// Provide the prompt via stdin to simulate non-interactive mode
78+
const result = await rig.run({
79+
stdin: prompt,
80+
args: ['--allowed-tools=run_shell_command(wc)'],
81+
});
82+
83+
const foundToolCall = await rig.waitForToolCall('run_shell_command', 15000);
84+
85+
if (!foundToolCall) {
86+
printDebugInfo(rig, result, {
87+
'Found tool call': foundToolCall,
88+
});
89+
}
90+
91+
expect(
92+
foundToolCall,
93+
'Expected to find a run_shell_command tool call',
94+
).toBeTruthy();
95+
});
96+
97+
it('should succeed with no parens in non-interactive mode', async () => {
98+
const rig = new TestRig();
99+
await rig.setup('should succeed with no parens in non-interactive mode');
100+
101+
const prompt = `use wc to tell me how many lines there are in /proc/meminfo`;
102+
103+
const result = await rig.run({
104+
stdin: prompt,
105+
args: ['--allowed-tools=run_shell_command'],
106+
});
107+
108+
const foundToolCall = await rig.waitForToolCall('run_shell_command', 15000);
109+
110+
if (!foundToolCall) {
111+
printDebugInfo(rig, result, {
112+
'Found tool call': foundToolCall,
113+
});
114+
}
115+
116+
expect(
117+
foundToolCall,
118+
'Expected to find a run_shell_command tool call',
119+
).toBeTruthy();
120+
});
121+
122+
it('should succeed with --yolo mode', async () => {
123+
const rig = new TestRig();
124+
await rig.setup('should succeed with --yolo mode');
125+
126+
const prompt = `use wc to tell me how many lines there are in /proc/meminfo`;
127+
128+
const result = await rig.run(
129+
{
130+
prompt: prompt,
131+
},
132+
'--yolo',
133+
);
134+
135+
const foundToolCall = await rig.waitForToolCall('run_shell_command', 15000);
136+
137+
if (!foundToolCall) {
138+
printDebugInfo(rig, result, {
139+
'Found tool call': foundToolCall,
140+
});
141+
}
142+
143+
expect(
144+
foundToolCall,
145+
'Expected to find a run_shell_command tool call',
146+
).toBeTruthy();
147+
expect(result).toContain('lines in /proc/meminfo');
148+
});
149+
150+
it('should work with ShellTool alias', async () => {
151+
const rig = new TestRig();
152+
await rig.setup('should work with ShellTool alias');
153+
154+
const prompt = `use wc to tell me how many lines there are in /proc/meminfo`;
155+
156+
const result = await rig.run({
157+
stdin: prompt,
158+
args: ['--allowed-tools=ShellTool(wc)'],
159+
});
160+
161+
const foundToolCall = await rig.waitForToolCall('run_shell_command', 15000);
162+
163+
if (!foundToolCall) {
164+
printDebugInfo(rig, result, {
165+
'Found tool call': foundToolCall,
166+
});
167+
}
168+
169+
expect(
170+
foundToolCall,
171+
'Expected to find a run_shell_command tool call',
172+
).toBeTruthy();
173+
});
174+
175+
it('should combine multiple --allowed-tools flags', async () => {
176+
const rig = new TestRig();
177+
await rig.setup('should combine multiple --allowed-tools flags');
178+
179+
const prompt = `use wc and ls`;
180+
181+
const result = await rig.run({
182+
stdin: prompt,
183+
args: [
184+
'--allowed-tools=run_shell_command(wc)',
185+
'--allowed-tools=run_shell_command(ls)',
186+
],
187+
});
188+
189+
const foundToolCall = await rig.waitForToolCall('run_shell_command', 15000);
190+
191+
if (!foundToolCall) {
192+
printDebugInfo(rig, result, {
193+
'Found tool call': foundToolCall,
194+
});
195+
}
196+
197+
expect(
198+
foundToolCall,
199+
'Expected to find a run_shell_command tool call',
200+
).toBeTruthy();
201+
});
202+
203+
it('should allow all with "ShellTool" and other specifics', async () => {
204+
const rig = new TestRig();
205+
await rig.setup('should allow all with "ShellTool" and other specifics');
206+
207+
const prompt = `use date`;
208+
209+
const result = await rig.run({
210+
stdin: prompt,
211+
args: [
212+
'--allowed-tools=run_shell_command(wc)',
213+
'--allowed-tools=run_shell_command',
214+
],
215+
});
216+
217+
const foundToolCall = await rig.waitForToolCall('run_shell_command', 15000);
218+
219+
if (!foundToolCall) {
220+
printDebugInfo(rig, result, {
221+
'Found tool call': foundToolCall,
222+
});
223+
}
224+
225+
expect(
226+
foundToolCall,
227+
'Expected to find a run_shell_command tool call',
228+
).toBeTruthy();
229+
});
230+
71231
it('should propagate environment variables to the child process', async () => {
72232
const rig = new TestRig();
73233
await rig.setup('should propagate environment variables');

packages/cli/src/config/config.test.ts

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1936,6 +1936,51 @@ describe('loadCliConfig tool exclusions', () => {
19361936
expect(config.getExcludeTools()).not.toContain('replace');
19371937
expect(config.getExcludeTools()).not.toContain('write_file');
19381938
});
1939+
1940+
it('should not exclude shell tool in non-interactive mode when --allowed-tools="ShellTool" is set', async () => {
1941+
process.stdin.isTTY = false;
1942+
process.argv = [
1943+
'node',
1944+
'script.js',
1945+
'-p',
1946+
'test',
1947+
'--allowed-tools',
1948+
'ShellTool',
1949+
];
1950+
const argv = await parseArguments({} as Settings);
1951+
const config = await loadCliConfig({}, [], 'test-session', argv);
1952+
expect(config.getExcludeTools()).not.toContain(ShellTool.Name);
1953+
});
1954+
1955+
it('should not exclude shell tool in non-interactive mode when --allowed-tools="run_shell_command" is set', async () => {
1956+
process.stdin.isTTY = false;
1957+
process.argv = [
1958+
'node',
1959+
'script.js',
1960+
'-p',
1961+
'test',
1962+
'--allowed-tools',
1963+
'run_shell_command',
1964+
];
1965+
const argv = await parseArguments({} as Settings);
1966+
const config = await loadCliConfig({}, [], 'test-session', argv);
1967+
expect(config.getExcludeTools()).not.toContain(ShellTool.Name);
1968+
});
1969+
1970+
it('should not exclude shell tool in non-interactive mode when --allowed-tools="ShellTool(wc)" is set', async () => {
1971+
process.stdin.isTTY = false;
1972+
process.argv = [
1973+
'node',
1974+
'script.js',
1975+
'-p',
1976+
'test',
1977+
'--allowed-tools',
1978+
'ShellTool(wc)',
1979+
];
1980+
const argv = await parseArguments({} as Settings);
1981+
const config = await loadCliConfig({}, [], 'test-session', argv);
1982+
expect(config.getExcludeTools()).not.toContain(ShellTool.Name);
1983+
});
19391984
});
19401985

19411986
describe('loadCliConfig interactive', () => {

packages/cli/src/config/config.ts

Lines changed: 45 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ import {
3131
ShellTool,
3232
EditTool,
3333
WriteFileTool,
34+
SHELL_TOOL_NAMES,
3435
resolveTelemetrySettings,
3536
FatalConfigError,
3637
} from '@google/gemini-cli-core';
@@ -396,6 +397,36 @@ export async function loadHierarchicalGeminiMemory(
396397
);
397398
}
398399

400+
/**
401+
* Creates a filter function to determine if a tool should be excluded.
402+
*
403+
* In non-interactive mode, we want to disable tools that require user
404+
* interaction to prevent the CLI from hanging. This function creates a predicate
405+
* that returns `true` if a tool should be excluded.
406+
*
407+
* A tool is excluded if it's not in the `allowedToolsSet`. The shell tool
408+
* has a special case: it's not excluded if any of its subcommands
409+
* are in the `allowedTools` list.
410+
*
411+
* @param allowedTools A list of explicitly allowed tool names.
412+
* @param allowedToolsSet A set of explicitly allowed tool names for quick lookups.
413+
* @returns A function that takes a tool name and returns `true` if it should be excluded.
414+
*/
415+
function createToolExclusionFilter(
416+
allowedTools: string[],
417+
allowedToolsSet: Set<string>,
418+
) {
419+
return (tool: string): boolean => {
420+
if (tool === ShellTool.Name) {
421+
// If any of the allowed tools is ShellTool (even with subcommands), don't exclude it.
422+
return !allowedTools.some((allowed) =>
423+
SHELL_TOOL_NAMES.some((shellName) => allowed.startsWith(shellName)),
424+
);
425+
}
426+
return !allowedToolsSet.has(tool);
427+
};
428+
}
429+
399430
export function isDebugMode(argv: CliArgs): boolean {
400431
return (
401432
argv.debug ||
@@ -527,6 +558,9 @@ export async function loadCliConfig(
527558

528559
const policyEngineConfig = createPolicyEngineConfig(settings, approvalMode);
529560

561+
const allowedTools = argv.allowedTools || settings.tools?.allowed || [];
562+
const allowedToolsSet = new Set(allowedTools);
563+
530564
// Fix: If promptWords are provided, always use non-interactive mode
531565
const hasPromptWords = argv.promptWords && argv.promptWords.length > 0;
532566
const interactive =
@@ -535,14 +569,22 @@ export async function loadCliConfig(
535569
// In non-interactive mode, exclude tools that require a prompt.
536570
const extraExcludes: string[] = [];
537571
if (!interactive && !argv.experimentalAcp) {
572+
const defaultExcludes = [ShellTool.Name, EditTool.Name, WriteFileTool.Name];
573+
const autoEditExcludes = [ShellTool.Name];
574+
575+
const toolExclusionFilter = createToolExclusionFilter(
576+
allowedTools,
577+
allowedToolsSet,
578+
);
579+
538580
switch (approvalMode) {
539581
case ApprovalMode.DEFAULT:
540582
// In default non-interactive mode, all tools that require approval are excluded.
541-
extraExcludes.push(ShellTool.Name, EditTool.Name, WriteFileTool.Name);
583+
extraExcludes.push(...defaultExcludes.filter(toolExclusionFilter));
542584
break;
543585
case ApprovalMode.AUTO_EDIT:
544586
// In auto-edit non-interactive mode, only tools that still require a prompt are excluded.
545-
extraExcludes.push(ShellTool.Name);
587+
extraExcludes.push(...autoEditExcludes.filter(toolExclusionFilter));
546588
break;
547589
case ApprovalMode.YOLO:
548590
// No extra excludes for YOLO mode.
@@ -614,7 +656,7 @@ export async function loadCliConfig(
614656
question,
615657
fullContext: argv.allFiles || false,
616658
coreTools: settings.tools?.core || undefined,
617-
allowedTools: argv.allowedTools || settings.tools?.allowed || undefined,
659+
allowedTools: allowedTools.length > 0 ? allowedTools : undefined,
618660
policyEngineConfig,
619661
excludeTools,
620662
toolDiscoveryCommand: settings.tools?.discoveryCommand,

packages/cli/src/nonInteractiveCli.test.ts

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -873,4 +873,57 @@ describe('runNonInteractive', () => {
873873

874874
expect(processStdoutSpy).toHaveBeenCalledWith('Acknowledged');
875875
});
876+
877+
it('should allow a normally-excluded tool when --allowed-tools is set', async () => {
878+
// By default, ShellTool is excluded in non-interactive mode.
879+
// This test ensures that --allowed-tools overrides this exclusion.
880+
vi.mocked(mockConfig.getToolRegistry).mockReturnValue({
881+
getTool: vi.fn().mockReturnValue({
882+
name: 'ShellTool',
883+
description: 'A shell tool',
884+
run: vi.fn(),
885+
}),
886+
getFunctionDeclarations: vi.fn().mockReturnValue([{ name: 'ShellTool' }]),
887+
} as unknown as ToolRegistry);
888+
889+
const toolCallEvent: ServerGeminiStreamEvent = {
890+
type: GeminiEventType.ToolCallRequest,
891+
value: {
892+
callId: 'tool-shell-1',
893+
name: 'ShellTool',
894+
args: { command: 'ls' },
895+
isClientInitiated: false,
896+
prompt_id: 'prompt-id-allowed',
897+
},
898+
};
899+
const toolResponse: Part[] = [{ text: 'file.txt' }];
900+
mockCoreExecuteToolCall.mockResolvedValue({ responseParts: toolResponse });
901+
902+
const firstCallEvents: ServerGeminiStreamEvent[] = [toolCallEvent];
903+
const secondCallEvents: ServerGeminiStreamEvent[] = [
904+
{ type: GeminiEventType.Content, value: 'file.txt' },
905+
{
906+
type: GeminiEventType.Finished,
907+
value: { reason: undefined, usageMetadata: { totalTokenCount: 10 } },
908+
},
909+
];
910+
911+
mockGeminiClient.sendMessageStream
912+
.mockReturnValueOnce(createStreamFromEvents(firstCallEvents))
913+
.mockReturnValueOnce(createStreamFromEvents(secondCallEvents));
914+
915+
await runNonInteractive(
916+
mockConfig,
917+
mockSettings,
918+
'List the files',
919+
'prompt-id-allowed',
920+
);
921+
922+
expect(mockCoreExecuteToolCall).toHaveBeenCalledWith(
923+
mockConfig,
924+
expect.objectContaining({ name: 'ShellTool' }),
925+
expect.any(AbortSignal),
926+
);
927+
expect(processStdoutSpy).toHaveBeenCalledWith('file.txt');
928+
});
876929
});

packages/core/src/tools/shell.test.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,7 @@ describe('ShellTool', () => {
6161
.mockReturnValue(createMockWorkspaceContext('/test/dir')),
6262
getGeminiClient: vi.fn(),
6363
getShouldUseNodePtyShell: vi.fn().mockReturnValue(false),
64+
isInteractive: vi.fn().mockReturnValue(true),
6465
} as unknown as Config;
6566

6667
shellTool = new ShellTool(mockConfig);

0 commit comments

Comments
 (0)