feat: allow command-specific restrictions for ShellTool (#2605)

This commit is contained in:
Jerop Kipruto 2025-06-29 15:32:26 -04:00 committed by GitHub
parent 19a0276142
commit d8d78d73f9
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 362 additions and 18 deletions

View File

@ -65,14 +65,17 @@ In addition to a project settings file, a project's `.gemini` directory can cont
``` ```
- **`coreTools`** (array of strings): - **`coreTools`** (array of strings):
- **Description:** Allows you to specify a list of core tool names that should be made available to the model. This can be used to restrict the set of built-in tools. See [Built-in Tools](../core/tools-api.md#built-in-tools) for a list of core tools. - **Description:** Allows you to specify a list of core tool names that should be made available to the model. This can be used to restrict the set of built-in tools. See [Built-in Tools](../core/tools-api.md#built-in-tools) for a list of core tools. You can also specify command-specific restrictions for tools that support it, like the `ShellTool`. For example, `"coreTools": ["ShellTool(ls -l)"]` will only allow the `ls -l` command to be executed.
- **Default:** All tools available for use by the Gemini model. - **Default:** All tools available for use by the Gemini model.
- **Example:** `"coreTools": ["ReadFileTool", "GlobTool", "SearchText"]`. - **Example:** `"coreTools": ["ReadFileTool", "GlobTool", "ShellTool(ls)"]`.
- **`excludeTools`** (array of strings): - **`excludeTools`** (array of strings):
- **Description:** Allows you to specify a list of core tool names that should be excluded from the model. A tool listed in both `excludeTools` and `coreTools` is excluded. - **Description:** Allows you to specify a list of core tool names that should be excluded from the model. A tool listed in both `excludeTools` and `coreTools` is excluded. You can also specify command-specific restrictions for tools that support it, like the `ShellTool`. For example, `"excludeTools": ["ShellTool(rm -rf)"]` will block the `rm -rf` command.
- **Default**: No tools excluded. - **Default**: No tools excluded.
- **Example:** `"excludeTools": ["run_shell_command", "findFiles"]`. - **Example:** `"excludeTools": ["run_shell_command", "findFiles"]`.
- **Security Note:** Command-specific restrictions in
`excludeTools` for `run_shell_command` are based on simple string matching and can be easily bypassed. This feature is **not a security mechanism** and should not be relied upon to safely execute untrusted code. It is recommended to use `coreTools` to explicitly select commands
that can be executed.
- **`autoAccept`** (boolean): - **`autoAccept`** (boolean):
- **Description:** Controls whether the CLI automatically accepts and executes tool calls that are considered safe (e.g., read-only operations) without explicit user confirmation. If set to `true`, the CLI will bypass the confirmation prompt for tools deemed safe. - **Description:** Controls whether the CLI automatically accepts and executes tool calls that are considered safe (e.g., read-only operations) without explicit user confirmation. If set to `true`, the CLI will bypass the confirmation prompt for tools deemed safe.

View File

@ -59,3 +59,105 @@ run_shell_command(command="npm run dev &", description="Start development server
- **Interactive commands:** Avoid commands that require interactive user input, as this can cause the tool to hang. Use non-interactive flags if available (e.g., `npm init -y`). - **Interactive commands:** Avoid commands that require interactive user input, as this can cause the tool to hang. Use non-interactive flags if available (e.g., `npm init -y`).
- **Error handling:** Check the `Stderr`, `Error`, and `Exit Code` fields to determine if a command executed successfully. - **Error handling:** Check the `Stderr`, `Error`, and `Exit Code` fields to determine if a command executed successfully.
- **Background processes:** When a command is run in the background with `&`, the tool will return immediately and the process will continue to run in the background. The `Background PIDs` field will contain the process ID of the background process. - **Background processes:** When a command is run in the background with `&`, the tool will return immediately and the process will continue to run in the background. The `Background PIDs` field will contain the process ID of the background process.
## Command Restrictions
You can restrict the commands that can be executed by the `run_shell_command` tool by using the `coreTools` and `excludeTools` settings in your configuration file.
- `coreTools`: If you want to restrict the `run_shell_command` tool to a specific set of commands, you can add entries to the `coreTools` list in the format `ShellTool(<command>)`. For example, `"coreTools": ["ShellTool(ls -l)"]` will only allow the `ls -l` command to be executed. If you include `ShellTool` as a general entry in the `coreTools` list, it will act as a wildcard and allow any command to be executed, even if you have other specific commands in the list.
- `excludeTools`: If you want to block specific commands, you can add entries to the `excludeTools` list in the format `ShellTool(<command>)`. For example, `"excludeTools": ["ShellTool(rm -rf /)"]` will block the `rm -rf /` command.
### Command Restriction Examples
Here are some examples of how to use the `coreTools` and `excludeTools` settings to control which commands can be executed.
**Allow only specific commands**
To allow only `ls -l` and `git status`, and block all other commands:
```json
{
"coreTools": ["ShellTool(ls -l)", "ShellTool(git status)"]
}
```
- `ls -l`: Allowed
- `git status`: Allowed
- `npm install`: Blocked
**Block specific commands**
To block `rm -rf /` and `npm install`, and allow all other commands:
```json
{
"excludeTools": ["ShellTool(rm -rf /)", "ShellTool(npm install)"]
}
```
- `rm -rf /`: Blocked
- `npm install`: Blocked
- `ls -l`: Allowed
**Allow all commands**
To allow any command to be executed, you can use the `ShellTool` wildcard in `coreTools`:
```json
{
"coreTools": ["ShellTool"]
}
```
- `ls -l`: Allowed
- `npm install`: Allowed
- `any other command`: Allowed
**Wildcard with specific allowed commands**
If you include the `ShellTool` wildcard along with specific commands, the wildcard takes precedence, and all commands are allowed.
```json
{
"coreTools": ["ShellTool", "ShellTool(ls -l)"]
}
```
- `ls -l`: Allowed
- `npm install`: Allowed
- `any other command`: Allowed
**Wildcard with a blocklist**
You can use the `ShellTool` wildcard to allow all commands, while still blocking specific commands using `excludeTools`.
```json
{
"coreTools": ["ShellTool"],
"excludeTools": ["ShellTool(rm -rf /)"]
}
```
- `rm -rf /`: Blocked
- `ls -l`: Allowed
- `npm install`: Allowed
**Block all shell commands**
To block all shell commands, you can add the `ShellTool` wildcard to `excludeTools`:
```json
{
"excludeTools": ["ShellTool"]
}
```
- `ls -l`: Blocked
- `npm install`: Blocked
- `any other command`: Blocked
## Security Note for `excludeTools`
Command-specific restrictions in
`excludeTools` for `run_shell_command` are based on simple string matching and can be easily bypassed. This feature is **not a security mechanism** and should not be relied upon to safely execute untrusted code. It is recommended to use `coreTools` to explicitly select commands
that can be executed.

View File

@ -456,25 +456,33 @@ export class Config {
export function createToolRegistry(config: Config): Promise<ToolRegistry> { export function createToolRegistry(config: Config): Promise<ToolRegistry> {
const registry = new ToolRegistry(config); const registry = new ToolRegistry(config);
const targetDir = config.getTargetDir(); const targetDir = config.getTargetDir();
const tools = config.getCoreTools()
? new Set(config.getCoreTools())
: undefined;
const excludeTools = config.getExcludeTools()
? new Set(config.getExcludeTools())
: undefined;
// helper to create & register core tools that are enabled // helper to create & register core tools that are enabled
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
const registerCoreTool = (ToolClass: any, ...args: unknown[]) => { const registerCoreTool = (ToolClass: any, ...args: unknown[]) => {
// check both the tool name (.Name) and the class name (.name) const className = ToolClass.name;
if ( const toolName = ToolClass.Name || className;
// coreTools contain tool name const coreTools = config.getCoreTools();
(!tools || tools.has(ToolClass.Name) || tools.has(ToolClass.name)) && const excludeTools = config.getExcludeTools();
// excludeTools don't contain tool name
(!excludeTools || let isEnabled = false;
(!excludeTools.has(ToolClass.Name) && if (coreTools === undefined) {
!excludeTools.has(ToolClass.name))) isEnabled = true;
) { } else {
isEnabled = coreTools.some(
(tool) =>
tool === className ||
tool === toolName ||
tool.startsWith(`${className}(`) ||
tool.startsWith(`${toolName}(`),
);
}
if (excludeTools?.includes(className) || excludeTools?.includes(toolName)) {
isEnabled = false;
}
if (isEnabled) {
registry.registerTool(new ToolClass(...args)); registry.registerTool(new ToolClass(...args));
} }
}; };

View File

@ -0,0 +1,171 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { expect, describe, it } from 'vitest';
import { ShellTool } from './shell.js';
import { Config } from '../config/config.js';
describe('ShellTool', () => {
it('should allow a command if no restrictions are provided', async () => {
const config = {
getCoreTools: () => undefined,
getExcludeTools: () => undefined,
} as Config;
const shellTool = new ShellTool(config);
const isAllowed = shellTool.isCommandAllowed('ls -l');
expect(isAllowed).toBe(true);
});
it('should allow a command if it is in the allowed list', async () => {
const config = {
getCoreTools: () => ['ShellTool(ls -l)'],
getExcludeTools: () => undefined,
} as unknown as Config;
const shellTool = new ShellTool(config);
const isAllowed = shellTool.isCommandAllowed('ls -l');
expect(isAllowed).toBe(true);
});
it('should block a command if it is not in the allowed list', async () => {
const config = {
getCoreTools: () => ['ShellTool(ls -l)'],
getExcludeTools: () => undefined,
} as unknown as Config;
const shellTool = new ShellTool(config);
const isAllowed = shellTool.isCommandAllowed('rm -rf /');
expect(isAllowed).toBe(false);
});
it('should block a command if it is in the blocked list', async () => {
const config = {
getCoreTools: () => undefined,
getExcludeTools: () => ['ShellTool(rm -rf /)'],
} as unknown as Config;
const shellTool = new ShellTool(config);
const isAllowed = shellTool.isCommandAllowed('rm -rf /');
expect(isAllowed).toBe(false);
});
it('should allow a command if it is not in the blocked list', async () => {
const config = {
getCoreTools: () => undefined,
getExcludeTools: () => ['ShellTool(rm -rf /)'],
} as unknown as Config;
const shellTool = new ShellTool(config);
const isAllowed = shellTool.isCommandAllowed('ls -l');
expect(isAllowed).toBe(true);
});
it('should block a command if it is in both the allowed and blocked lists', async () => {
const config = {
getCoreTools: () => ['ShellTool(rm -rf /)'],
getExcludeTools: () => ['ShellTool(rm -rf /)'],
} as unknown as Config;
const shellTool = new ShellTool(config);
const isAllowed = shellTool.isCommandAllowed('rm -rf /');
expect(isAllowed).toBe(false);
});
it('should allow any command when ShellTool is in coreTools without specific commands', async () => {
const config = {
getCoreTools: () => ['ShellTool'],
getExcludeTools: () => [],
} as unknown as Config;
const shellTool = new ShellTool(config);
const isAllowed = shellTool.isCommandAllowed('any command');
expect(isAllowed).toBe(true);
});
it('should block any command when ShellTool is in excludeTools without specific commands', async () => {
const config = {
getCoreTools: () => [],
getExcludeTools: () => ['ShellTool'],
} as unknown as Config;
const shellTool = new ShellTool(config);
const isAllowed = shellTool.isCommandAllowed('any command');
expect(isAllowed).toBe(false);
});
it('should allow a command if it is in the allowed list using the public-facing name', async () => {
const config = {
getCoreTools: () => ['run_shell_command(ls -l)'],
getExcludeTools: () => undefined,
} as unknown as Config;
const shellTool = new ShellTool(config);
const isAllowed = shellTool.isCommandAllowed('ls -l');
expect(isAllowed).toBe(true);
});
it('should block a command if it is in the blocked list using the public-facing name', async () => {
const config = {
getCoreTools: () => undefined,
getExcludeTools: () => ['run_shell_command(rm -rf /)'],
} as unknown as Config;
const shellTool = new ShellTool(config);
const isAllowed = shellTool.isCommandAllowed('rm -rf /');
expect(isAllowed).toBe(false);
});
it('should block any command when ShellTool is in excludeTools using the public-facing name', async () => {
const config = {
getCoreTools: () => [],
getExcludeTools: () => ['run_shell_command'],
} as unknown as Config;
const shellTool = new ShellTool(config);
const isAllowed = shellTool.isCommandAllowed('any command');
expect(isAllowed).toBe(false);
});
it('should block any command if coreTools contains an empty ShellTool command list using the public-facing name', async () => {
const config = {
getCoreTools: () => ['run_shell_command()'],
getExcludeTools: () => [],
} as unknown as Config;
const shellTool = new ShellTool(config);
const isAllowed = shellTool.isCommandAllowed('any command');
expect(isAllowed).toBe(false);
});
it('should block any command if coreTools contains an empty ShellTool command list', async () => {
const config = {
getCoreTools: () => ['ShellTool()'],
getExcludeTools: () => [],
} as unknown as Config;
const shellTool = new ShellTool(config);
const isAllowed = shellTool.isCommandAllowed('any command');
expect(isAllowed).toBe(false);
});
it('should block a command with extra whitespace if it is in the blocked list', async () => {
const config = {
getCoreTools: () => undefined,
getExcludeTools: () => ['ShellTool(rm -rf /)'],
} as unknown as Config;
const shellTool = new ShellTool(config);
const isAllowed = shellTool.isCommandAllowed(' rm -rf / ');
expect(isAllowed).toBe(false);
});
it('should allow any command when ShellTool is present with specific commands', async () => {
const config = {
getCoreTools: () => ['ShellTool', 'ShellTool(ls)'],
getExcludeTools: () => [],
} as unknown as Config;
const shellTool = new ShellTool(config);
const isAllowed = shellTool.isCommandAllowed('any command');
expect(isAllowed).toBe(true);
});
it('should block a command on the blocklist even with a wildcard allow', async () => {
const config = {
getCoreTools: () => ['ShellTool'],
getExcludeTools: () => ['ShellTool(rm -rf /)'],
} as unknown as Config;
const shellTool = new ShellTool(config);
const isAllowed = shellTool.isCommandAllowed('rm -rf /');
expect(isAllowed).toBe(false);
});
});

View File

@ -98,7 +98,67 @@ Process Group PGID: Process group started or \`(none)\``,
.pop(); // take last part and return command root (or undefined if previous line was empty) .pop(); // take last part and return command root (or undefined if previous line was empty)
} }
isCommandAllowed(command: string): boolean {
const normalize = (cmd: string) => cmd.trim().replace(/\s+/g, ' ');
const extractCommands = (tools: string[]): string[] =>
tools.flatMap((tool) => {
if (tool.startsWith(`${ShellTool.name}(`) && tool.endsWith(')')) {
return [normalize(tool.slice(ShellTool.name.length + 1, -1))];
} else if (
tool.startsWith(`${ShellTool.Name}(`) &&
tool.endsWith(')')
) {
return [normalize(tool.slice(ShellTool.Name.length + 1, -1))];
}
return [];
});
const coreTools = this.config.getCoreTools() || [];
const excludeTools = this.config.getExcludeTools() || [];
if (
excludeTools.includes(ShellTool.name) ||
excludeTools.includes(ShellTool.Name)
) {
return false;
}
const blockedCommands = extractCommands(excludeTools);
const normalizedCommand = normalize(command);
if (blockedCommands.includes(normalizedCommand)) {
return false;
}
const hasSpecificCommands = coreTools.some(
(tool) =>
(tool.startsWith(`${ShellTool.name}(`) && tool.endsWith(')')) ||
(tool.startsWith(`${ShellTool.Name}(`) && tool.endsWith(')')),
);
if (hasSpecificCommands) {
// If the generic `ShellTool` is also present, it acts as a wildcard,
// allowing all commands (that are not explicitly blocked).
if (
coreTools.includes(ShellTool.name) ||
coreTools.includes(ShellTool.Name)
) {
return true;
}
// Otherwise, we are in strict allow-list mode.
const allowedCommands = extractCommands(coreTools);
return allowedCommands.includes(normalizedCommand);
}
return true;
}
validateToolParams(params: ShellToolParams): string | null { validateToolParams(params: ShellToolParams): string | null {
if (!this.isCommandAllowed(params.command)) {
return `Command is not allowed: ${params.command}`;
}
if ( if (
!SchemaValidator.validate( !SchemaValidator.validate(
this.parameterSchema as Record<string, unknown>, this.parameterSchema as Record<string, unknown>,