mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-03-18 01:51:20 -07:00
Shell approval rework (#11073)
This commit is contained in:
@@ -4,13 +4,22 @@
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { expect, describe, it, beforeEach, vi, afterEach } from 'vitest';
|
||||
import {
|
||||
expect,
|
||||
describe,
|
||||
it,
|
||||
beforeEach,
|
||||
beforeAll,
|
||||
vi,
|
||||
afterEach,
|
||||
} from 'vitest';
|
||||
import {
|
||||
checkCommandPermissions,
|
||||
escapeShellArg,
|
||||
getCommandRoots,
|
||||
getShellConfiguration,
|
||||
isCommandAllowed,
|
||||
initializeShellParsers,
|
||||
stripShellWrapper,
|
||||
} from './shell-utils.js';
|
||||
import type { Config } from '../config/config.js';
|
||||
@@ -32,6 +41,13 @@ vi.mock('shell-quote', () => ({
|
||||
}));
|
||||
|
||||
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');
|
||||
@@ -51,41 +67,41 @@ afterEach(() => {
|
||||
|
||||
describe('isCommandAllowed', () => {
|
||||
it('should allow a command if no restrictions are provided', () => {
|
||||
const result = isCommandAllowed('ls -l', config);
|
||||
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(ls)'];
|
||||
const result = isCommandAllowed('ls -l', config);
|
||||
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(ls -l)'];
|
||||
const result = isCommandAllowed('rm -rf /', config);
|
||||
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: "rm -rf /"`,
|
||||
`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 = () => ['ShellTool(rm -rf /)'];
|
||||
const result = isCommandAllowed('rm -rf /', config);
|
||||
config.getExcludeTools = () => ['ShellTool(badCommand --danger)'];
|
||||
const result = isCommandAllowed('badCommand --danger', config);
|
||||
expect(result.allowed).toBe(false);
|
||||
expect(result.reason).toBe(
|
||||
`Command 'rm -rf /' is blocked by configuration`,
|
||||
`Command 'badCommand --danger' is blocked by configuration`,
|
||||
);
|
||||
});
|
||||
|
||||
it('should prioritize the blocklist over the allowlist', () => {
|
||||
config.getCoreTools = () => ['ShellTool(rm -rf /)'];
|
||||
config.getExcludeTools = () => ['ShellTool(rm -rf /)'];
|
||||
const result = isCommandAllowed('rm -rf /', config);
|
||||
config.getCoreTools = () => ['ShellTool(badCommand --danger)'];
|
||||
config.getExcludeTools = () => ['ShellTool(badCommand --danger)'];
|
||||
const result = isCommandAllowed('badCommand --danger', config);
|
||||
expect(result.allowed).toBe(false);
|
||||
expect(result.reason).toBe(
|
||||
`Command 'rm -rf /' is blocked by configuration`,
|
||||
`Command 'badCommand --danger' is blocked by configuration`,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -106,58 +122,64 @@ describe('isCommandAllowed', () => {
|
||||
|
||||
it('should block a command on the blocklist even with a wildcard allow', () => {
|
||||
config.getCoreTools = () => ['ShellTool'];
|
||||
config.getExcludeTools = () => ['ShellTool(rm -rf /)'];
|
||||
const result = isCommandAllowed('rm -rf /', config);
|
||||
config.getExcludeTools = () => ['ShellTool(badCommand --danger)'];
|
||||
const result = isCommandAllowed('badCommand --danger', config);
|
||||
expect(result.allowed).toBe(false);
|
||||
expect(result.reason).toBe(
|
||||
`Command 'rm -rf /' is blocked by configuration`,
|
||||
`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(ls)',
|
||||
'run_shell_command(goodCommand)',
|
||||
];
|
||||
const result = isCommandAllowed('echo "hello" && ls -l', config);
|
||||
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 = () => ['run_shell_command(rm)'];
|
||||
const result = isCommandAllowed('echo "hello" && rm -rf /', config);
|
||||
config.getExcludeTools = () => ['run_shell_command(badCommand)'];
|
||||
const result = isCommandAllowed(
|
||||
'echo "hello" && badCommand --danger',
|
||||
config,
|
||||
);
|
||||
expect(result.allowed).toBe(false);
|
||||
expect(result.reason).toBe(
|
||||
`Command 'rm -rf /' is blocked by configuration`,
|
||||
`Command 'badCommand --danger' is blocked by configuration`,
|
||||
);
|
||||
});
|
||||
|
||||
describe('command substitution', () => {
|
||||
it('should block command substitution using `$(...)`', () => {
|
||||
const result = isCommandAllowed('echo $(rm -rf /)', config);
|
||||
expect(result.allowed).toBe(false);
|
||||
expect(result.reason).toContain('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 block command substitution using `<(...)`', () => {
|
||||
it('should allow command substitution using `<(...)`', () => {
|
||||
const result = isCommandAllowed('diff <(ls) <(ls -a)', config);
|
||||
expect(result.allowed).toBe(false);
|
||||
expect(result.reason).toContain('Command substitution');
|
||||
expect(result.allowed).toBe(true);
|
||||
expect(result.reason).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should block command substitution using `>(...)`', () => {
|
||||
it('should allow command substitution using `>(...)`', () => {
|
||||
const result = isCommandAllowed(
|
||||
'echo "Log message" > >(tee log.txt)',
|
||||
config,
|
||||
);
|
||||
expect(result.allowed).toBe(false);
|
||||
expect(result.reason).toContain('Command substitution');
|
||||
expect(result.allowed).toBe(true);
|
||||
expect(result.reason).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should block command substitution using backticks', () => {
|
||||
const result = isCommandAllowed('echo `rm -rf /`', config);
|
||||
expect(result.allowed).toBe(false);
|
||||
expect(result.reason).toContain('Command substitution');
|
||||
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', () => {
|
||||
@@ -165,33 +187,54 @@ describe('isCommandAllowed', () => {
|
||||
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('ls -l', config);
|
||||
const result = checkCommandPermissions('goodCommand --safe', config);
|
||||
expect(result).toEqual({
|
||||
allAllowed: true,
|
||||
disallowedCommands: [],
|
||||
});
|
||||
});
|
||||
|
||||
it('should return a detailed failure object for a blocked command', () => {
|
||||
config.getExcludeTools = () => ['ShellTool(rm)'];
|
||||
const result = checkCommandPermissions('rm -rf /', config);
|
||||
it('should block commands that cannot be parsed safely', () => {
|
||||
const result = checkCommandPermissions('ls &&', config);
|
||||
expect(result).toEqual({
|
||||
allAllowed: false,
|
||||
disallowedCommands: ['rm -rf /'],
|
||||
blockReason: `Command 'rm -rf /' is blocked by configuration`,
|
||||
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 = () => ['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(ls)'];
|
||||
const result = checkCommandPermissions('git status && ls', config);
|
||||
config.getCoreTools = () => ['ShellTool(goodCommand)'];
|
||||
const result = checkCommandPermissions(
|
||||
'git status && goodCommand',
|
||||
config,
|
||||
);
|
||||
expect(result).toEqual({
|
||||
allAllowed: false,
|
||||
disallowedCommands: ['git status'],
|
||||
@@ -204,24 +247,24 @@ describe('checkCommandPermissions', () => {
|
||||
describe('in "Default Deny" mode (with sessionAllowlist)', () => {
|
||||
it('should allow a command on the sessionAllowlist', () => {
|
||||
const result = checkCommandPermissions(
|
||||
'ls -l',
|
||||
'goodCommand --safe',
|
||||
config,
|
||||
new Set(['ls -l']),
|
||||
new Set(['goodCommand --safe']),
|
||||
);
|
||||
expect(result.allAllowed).toBe(true);
|
||||
});
|
||||
|
||||
it('should block a command not on the sessionAllowlist or global allowlist', () => {
|
||||
const result = checkCommandPermissions(
|
||||
'rm -rf /',
|
||||
'badCommand --danger',
|
||||
config,
|
||||
new Set(['ls -l']),
|
||||
new Set(['goodCommand --safe']),
|
||||
);
|
||||
expect(result.allAllowed).toBe(false);
|
||||
expect(result.blockReason).toContain(
|
||||
'not on the global or session allowlist',
|
||||
);
|
||||
expect(result.disallowedCommands).toEqual(['rm -rf /']);
|
||||
expect(result.disallowedCommands).toEqual(['badCommand --danger']);
|
||||
});
|
||||
|
||||
it('should allow a command on the global allowlist even if not on the session allowlist', () => {
|
||||
@@ -229,7 +272,7 @@ describe('checkCommandPermissions', () => {
|
||||
const result = checkCommandPermissions(
|
||||
'git status',
|
||||
config,
|
||||
new Set(['ls -l']),
|
||||
new Set(['goodCommand --safe']),
|
||||
);
|
||||
expect(result.allAllowed).toBe(true);
|
||||
});
|
||||
@@ -245,11 +288,11 @@ describe('checkCommandPermissions', () => {
|
||||
});
|
||||
|
||||
it('should block a command on the sessionAllowlist if it is also globally blocked', () => {
|
||||
config.getExcludeTools = () => ['run_shell_command(rm)'];
|
||||
config.getExcludeTools = () => ['run_shell_command(badCommand)'];
|
||||
const result = checkCommandPermissions(
|
||||
'rm -rf /',
|
||||
'badCommand --danger',
|
||||
config,
|
||||
new Set(['rm -rf /']),
|
||||
new Set(['badCommand --danger']),
|
||||
);
|
||||
expect(result.allAllowed).toBe(false);
|
||||
expect(result.blockReason).toContain('is blocked by configuration');
|
||||
@@ -258,12 +301,12 @@ describe('checkCommandPermissions', () => {
|
||||
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" && rm -rf /',
|
||||
'echo "hello" && badCommand --danger',
|
||||
config,
|
||||
new Set(['echo']),
|
||||
);
|
||||
expect(result.allAllowed).toBe(false);
|
||||
expect(result.disallowedCommands).toEqual(['rm -rf /']);
|
||||
expect(result.disallowedCommands).toEqual(['badCommand --danger']);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -290,6 +333,54 @@ describe('getCommandRoots', () => {
|
||||
const result = getCommandRoots('echo "hello" && git commit -m "feat"');
|
||||
expect(result).toEqual(['echo', 'git']);
|
||||
});
|
||||
|
||||
it('should include nested command substitutions', () => {
|
||||
const result = getCommandRoots('echo $(badCommand --danger)');
|
||||
expect(result).toEqual(['echo', 'badCommand']);
|
||||
});
|
||||
|
||||
it('should include process substitutions', () => {
|
||||
const result = getCommandRoots('diff <(ls) <(ls -a)');
|
||||
expect(result).toEqual(['diff', 'ls', 'ls']);
|
||||
});
|
||||
|
||||
it('should include backtick substitutions', () => {
|
||||
const result = getCommandRoots('echo `badCommand --danger`');
|
||||
expect(result).toEqual(['echo', 'badCommand']);
|
||||
});
|
||||
});
|
||||
|
||||
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 return command roots using PowerShell AST output', () => {
|
||||
const roots = getCommandRoots('Get-ChildItem | Select-Object Name');
|
||||
expect(roots.length).toBeGreaterThan(0);
|
||||
expect(roots).toContain('Get-ChildItem');
|
||||
});
|
||||
|
||||
it('should block commands when PowerShell parser reports errors', () => {
|
||||
const { allowed, reason } = isCommandAllowed('Get-ChildItem |', config);
|
||||
expect(allowed).toBe(false);
|
||||
expect(reason).toBe(
|
||||
'Command rejected because it could not be parsed safely',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('stripShellWrapper', () => {
|
||||
@@ -309,6 +400,21 @@ describe('stripShellWrapper', () => {
|
||||
expect(stripShellWrapper('cmd.exe /c "dir"')).toEqual('dir');
|
||||
});
|
||||
|
||||
it('should strip powershell.exe -Command with optional -NoProfile', () => {
|
||||
expect(
|
||||
stripShellWrapper('powershell.exe -NoProfile -Command "Get-ChildItem"'),
|
||||
).toEqual('Get-ChildItem');
|
||||
expect(
|
||||
stripShellWrapper('powershell.exe -Command "Get-ChildItem"'),
|
||||
).toEqual('Get-ChildItem');
|
||||
});
|
||||
|
||||
it('should strip pwsh -Command wrapper', () => {
|
||||
expect(
|
||||
stripShellWrapper('pwsh -NoProfile -Command "Get-ChildItem"'),
|
||||
).toEqual('Get-ChildItem');
|
||||
});
|
||||
|
||||
it('should not strip anything if no wrapper is present', () => {
|
||||
expect(stripShellWrapper('ls -l')).toEqual('ls -l');
|
||||
});
|
||||
@@ -400,21 +506,21 @@ describe('getShellConfiguration', () => {
|
||||
mockPlatform.mockReturnValue('win32');
|
||||
});
|
||||
|
||||
it('should return cmd.exe configuration by default', () => {
|
||||
it('should return PowerShell configuration by default', () => {
|
||||
delete process.env['ComSpec'];
|
||||
const config = getShellConfiguration();
|
||||
expect(config.executable).toBe('cmd.exe');
|
||||
expect(config.argsPrefix).toEqual(['/d', '/s', '/c']);
|
||||
expect(config.shell).toBe('cmd');
|
||||
expect(config.executable).toBe('powershell.exe');
|
||||
expect(config.argsPrefix).toEqual(['-NoProfile', '-Command']);
|
||||
expect(config.shell).toBe('powershell');
|
||||
});
|
||||
|
||||
it('should respect ComSpec for cmd.exe', () => {
|
||||
it('should ignore ComSpec when pointing to cmd.exe', () => {
|
||||
const cmdPath = 'C:\\WINDOWS\\system32\\cmd.exe';
|
||||
process.env['ComSpec'] = cmdPath;
|
||||
const config = getShellConfiguration();
|
||||
expect(config.executable).toBe(cmdPath);
|
||||
expect(config.argsPrefix).toEqual(['/d', '/s', '/c']);
|
||||
expect(config.shell).toBe('cmd');
|
||||
expect(config.executable).toBe('powershell.exe');
|
||||
expect(config.argsPrefix).toEqual(['-NoProfile', '-Command']);
|
||||
expect(config.shell).toBe('powershell');
|
||||
});
|
||||
|
||||
it('should return PowerShell configuration if ComSpec points to powershell.exe', () => {
|
||||
|
||||
Reference in New Issue
Block a user