Skip to content

Commit 1e034f2

Browse files
committed
Reapply "Shell approval rework" (#11143)
This reverts commit bd5c158.
1 parent da1370e commit 1e034f2

File tree

15 files changed

+526
-164
lines changed

15 files changed

+526
-164
lines changed

docs/cli/commands.md

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -292,8 +292,9 @@ Gemini CLI.
292292

293293
- **`!<shell_command>`**
294294
- **Description:** Execute the given `<shell_command>` using `bash` on
295-
Linux/macOS or `cmd.exe` on Windows. Any output or errors from the command
296-
are displayed in the terminal.
295+
Linux/macOS or `powershell.exe -NoProfile -Command` on Windows (unless you
296+
override `ComSpec`). Any output or errors from the command are displayed in
297+
the terminal.
297298
- **Examples:**
298299
- `!ls -la` (executes `ls -la` and returns to Gemini CLI)
299300
- `!git status` (executes `git status` and returns to Gemini CLI)

docs/tools/shell.md

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,8 +10,9 @@ command, including interactive commands that require user input (e.g., `vim`,
1010
`git rebase -i`) if the `tools.shell.enableInteractiveShell` setting is set to
1111
`true`.
1212

13-
On Windows, commands are executed with `cmd.exe /c`. On other platforms, they
14-
are executed with `bash -c`.
13+
On Windows, commands are executed with `powershell.exe -NoProfile -Command`
14+
(unless you explicitly point `ComSpec` at another shell). On other platforms,
15+
they are executed with `bash -c`.
1516

1617
### Arguments
1718

integration-tests/flicker.test.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,8 @@ import { describe, it, expect } from 'vitest';
88
import { TestRig } from './test-helper.js';
99

1010
describe('Flicker Detector', () => {
11-
it('should not detect a flicker under the max height budget', async () => {
11+
// TODO: https://github.com/google-gemini/gemini-cli/issues/11170
12+
it.skip('should not detect a flicker under the max height budget', async () => {
1213
const rig = new TestRig();
1314
await rig.setup('flicker-detector-test');
1415

integration-tests/run_shell_command.test.ts

Lines changed: 174 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,46 @@ function getLineCountCommand(): { command: string; tool: string } {
2525
}
2626
}
2727

28+
function getInvalidCommand(): string {
29+
switch (shell) {
30+
case 'powershell':
31+
return `Get-ChildItem | | Select-Object`;
32+
case 'cmd':
33+
return `dir | | findstr foo`;
34+
case 'bash':
35+
default:
36+
return `echo "hello" > > file`;
37+
}
38+
}
39+
40+
function getAllowedListCommand(): string {
41+
switch (shell) {
42+
case 'powershell':
43+
return 'Get-ChildItem';
44+
case 'cmd':
45+
return 'dir';
46+
case 'bash':
47+
default:
48+
return 'ls';
49+
}
50+
}
51+
52+
function getDisallowedFileReadCommand(testFile: string): {
53+
command: string;
54+
tool: string;
55+
} {
56+
const quotedPath = `"${testFile}"`;
57+
switch (shell) {
58+
case 'powershell':
59+
return { command: `Get-Content ${quotedPath}`, tool: 'Get-Content' };
60+
case 'cmd':
61+
return { command: `type ${quotedPath}`, tool: 'type' };
62+
case 'bash':
63+
default:
64+
return { command: `cat ${quotedPath}`, tool: 'cat' };
65+
}
66+
}
67+
2868
describe('run_shell_command', () => {
2969
it('should be able to run a shell command', async () => {
3070
const rig = new TestRig();
@@ -106,8 +146,17 @@ describe('run_shell_command', () => {
106146
const foundToolCall = await rig.waitForToolCall('run_shell_command', 15000);
107147

108148
if (!foundToolCall) {
149+
const toolLogs = rig.readToolLogs().map(({ toolRequest }) => ({
150+
name: toolRequest.name,
151+
success: toolRequest.success,
152+
args: toolRequest.args,
153+
}));
109154
printDebugInfo(rig, result, {
110155
'Found tool call': foundToolCall,
156+
'Allowed tools flag': `run_shell_command(${tool})`,
157+
Prompt: prompt,
158+
'Tool logs': toolLogs,
159+
Result: result,
111160
});
112161
}
113162

@@ -214,8 +263,17 @@ describe('run_shell_command', () => {
214263
const foundToolCall = await rig.waitForToolCall('run_shell_command', 15000);
215264

216265
if (!foundToolCall) {
266+
const toolLogs = rig.readToolLogs().map(({ toolRequest }) => ({
267+
name: toolRequest.name,
268+
success: toolRequest.success,
269+
args: toolRequest.args,
270+
}));
217271
printDebugInfo(rig, result, {
218272
'Found tool call': foundToolCall,
273+
'Allowed tools flag': `ShellTool(${tool})`,
274+
Prompt: prompt,
275+
'Tool logs': toolLogs,
276+
Result: result,
219277
});
220278
}
221279

@@ -284,6 +342,73 @@ describe('run_shell_command', () => {
284342
}
285343
});
286344

345+
it('should reject commands not on the allowlist', async () => {
346+
const rig = new TestRig();
347+
await rig.setup('should reject commands not on the allowlist');
348+
349+
const testFile = rig.createFile('test.txt', 'Disallowed command check\n');
350+
const allowedCommand = getAllowedListCommand();
351+
const disallowed = getDisallowedFileReadCommand(testFile);
352+
const prompt =
353+
`I am testing the allowed tools configuration. ` +
354+
`Attempt to run "${disallowed.command}" to read the contents of ${testFile}. ` +
355+
`If the command fails because it is not permitted, respond with the single word FAIL. ` +
356+
`If it succeeds, respond with SUCCESS.`;
357+
358+
const result = await rig.run(
359+
{
360+
stdin: prompt,
361+
yolo: false,
362+
},
363+
`--allowed-tools=run_shell_command(${allowedCommand})`,
364+
);
365+
366+
if (!result.toLowerCase().includes('fail')) {
367+
printDebugInfo(rig, result, {
368+
Result: result,
369+
AllowedCommand: allowedCommand,
370+
DisallowedCommand: disallowed.command,
371+
});
372+
}
373+
expect(result).toContain('FAIL');
374+
375+
const foundToolCall = await rig.waitForToolCall(
376+
'run_shell_command',
377+
15000,
378+
(args) => args.toLowerCase().includes(disallowed.tool.toLowerCase()),
379+
);
380+
381+
if (!foundToolCall) {
382+
printDebugInfo(rig, result, {
383+
'Found tool call': foundToolCall,
384+
ToolLogs: rig.readToolLogs(),
385+
});
386+
}
387+
expect(foundToolCall).toBe(true);
388+
389+
const toolLogs = rig
390+
.readToolLogs()
391+
.filter((toolLog) => toolLog.toolRequest.name === 'run_shell_command');
392+
const failureLog = toolLogs.find((toolLog) =>
393+
toolLog.toolRequest.args
394+
.toLowerCase()
395+
.includes(disallowed.tool.toLowerCase()),
396+
);
397+
398+
if (!failureLog || failureLog.toolRequest.success) {
399+
printDebugInfo(rig, result, {
400+
ToolLogs: toolLogs,
401+
DisallowedTool: disallowed.tool,
402+
});
403+
}
404+
405+
expect(
406+
failureLog,
407+
'Expected failing run_shell_command invocation',
408+
).toBeTruthy();
409+
expect(failureLog!.toolRequest.success).toBe(false);
410+
});
411+
287412
it('should allow all with "ShellTool" and other specific tools', async () => {
288413
const rig = new TestRig();
289414
await rig.setup(
@@ -390,4 +515,53 @@ describe('run_shell_command', () => {
390515
validateModelOutput(result, fileName, 'Platform-specific listing test');
391516
expect(result).toContain(fileName);
392517
});
518+
519+
it('rejects invalid shell expressions', async () => {
520+
const rig = new TestRig();
521+
await rig.setup('rejects invalid shell expressions');
522+
const invalidCommand = getInvalidCommand();
523+
const result = await rig.run(
524+
`I am testing the error handling of the run_shell_command tool. Please attempt to run the following command, which I know has invalid syntax: \`${invalidCommand}\`. If the command fails as expected, please return the word FAIL, otherwise return the word SUCCESS.`,
525+
);
526+
expect(result).toContain('FAIL');
527+
528+
const escapedInvalidCommand = JSON.stringify(invalidCommand).slice(1, -1);
529+
const foundToolCall = await rig.waitForToolCall(
530+
'run_shell_command',
531+
15000,
532+
(args) =>
533+
args.toLowerCase().includes(escapedInvalidCommand.toLowerCase()),
534+
);
535+
536+
if (!foundToolCall) {
537+
printDebugInfo(rig, result, {
538+
'Found tool call': foundToolCall,
539+
EscapedCommand: escapedInvalidCommand,
540+
ToolLogs: rig.readToolLogs(),
541+
});
542+
}
543+
expect(foundToolCall).toBe(true);
544+
545+
const toolLogs = rig
546+
.readToolLogs()
547+
.filter((toolLog) => toolLog.toolRequest.name === 'run_shell_command');
548+
const failureLog = toolLogs.find((toolLog) =>
549+
toolLog.toolRequest.args
550+
.toLowerCase()
551+
.includes(escapedInvalidCommand.toLowerCase()),
552+
);
553+
554+
if (!failureLog || failureLog.toolRequest.success) {
555+
printDebugInfo(rig, result, {
556+
ToolLogs: toolLogs,
557+
EscapedCommand: escapedInvalidCommand,
558+
});
559+
}
560+
561+
expect(
562+
failureLog,
563+
'Expected failing run_shell_command invocation for invalid syntax',
564+
).toBeTruthy();
565+
expect(failureLog!.toolRequest.success).toBe(false);
566+
});
393567
});

package-lock.json

Lines changed: 55 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

packages/cli/src/services/prompt-processors/shellProcessor.test.ts

Lines changed: 8 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -9,27 +9,24 @@ import { ConfirmationRequiredError, ShellProcessor } from './shellProcessor.js';
99
import { createMockCommandContext } from '../../test-utils/mockCommandContext.js';
1010
import type { CommandContext } from '../../ui/commands/types.js';
1111
import type { Config } from '@google/gemini-cli-core';
12-
import { ApprovalMode } from '@google/gemini-cli-core';
13-
import os from 'node:os';
12+
import { ApprovalMode, getShellConfiguration } from '@google/gemini-cli-core';
1413
import { quote } from 'shell-quote';
1514
import { createPartFromText } from '@google/genai';
1615
import type { PromptPipelineContent } from './types.js';
1716

1817
// Helper function to determine the expected escaped string based on the current OS,
1918
// mirroring the logic in the actual `escapeShellArg` implementation.
2019
function getExpectedEscapedArgForPlatform(arg: string): string {
21-
if (os.platform() === 'win32') {
22-
const comSpec = (process.env['ComSpec'] || 'cmd.exe').toLowerCase();
23-
const isPowerShell =
24-
comSpec.endsWith('powershell.exe') || comSpec.endsWith('pwsh.exe');
20+
const { shell } = getShellConfiguration();
2521

26-
if (isPowerShell) {
22+
switch (shell) {
23+
case 'powershell':
2724
return `'${arg.replace(/'/g, "''")}'`;
28-
} else {
25+
case 'cmd':
2926
return `"${arg.replace(/"/g, '""')}"`;
30-
}
31-
} else {
32-
return quote([arg]);
27+
case 'bash':
28+
default:
29+
return quote([arg]);
3330
}
3431
}
3532

packages/core/package.json

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,10 +20,10 @@
2020
"dist"
2121
],
2222
"dependencies": {
23-
"@google/genai": "1.16.0",
23+
"@google-cloud/logging": "^11.2.1",
2424
"@google-cloud/opentelemetry-cloud-monitoring-exporter": "^0.21.0",
2525
"@google-cloud/opentelemetry-cloud-trace-exporter": "^3.0.0",
26-
"@google-cloud/logging": "^11.2.1",
26+
"@google/genai": "1.16.0",
2727
"@joshua.litt/get-ripgrep": "^0.0.2",
2828
"@modelcontextprotocol/sdk": "^1.11.0",
2929
"@opentelemetry/api": "^1.9.0",
@@ -61,7 +61,9 @@
6161
"shell-quote": "^1.8.3",
6262
"simple-git": "^3.28.0",
6363
"strip-ansi": "^7.1.0",
64+
"tree-sitter-bash": "^0.25.0",
6465
"undici": "^7.10.0",
66+
"web-tree-sitter": "^0.25.10",
6567
"ws": "^8.18.0"
6668
},
6769
"optionalDependencies": {

0 commit comments

Comments
 (0)