diff --git a/docs/cli/configuration.md b/docs/cli/configuration.md index edf5d9fd1e..c866c01f94 100644 --- a/docs/cli/configuration.md +++ b/docs/cli/configuration.md @@ -65,14 +65,17 @@ In addition to a project settings file, a project's `.gemini` directory can cont ``` - **`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. - - **Example:** `"coreTools": ["ReadFileTool", "GlobTool", "SearchText"]`. + - **Example:** `"coreTools": ["ReadFileTool", "GlobTool", "ShellTool(ls)"]`. - **`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. - **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): - **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. diff --git a/docs/tools/shell.md b/docs/tools/shell.md index ff6e574e14..cc65b01320 100644 --- a/docs/tools/shell.md +++ b/docs/tools/shell.md @@ -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`). - **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. + +## 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()`. 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()`. 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. diff --git a/packages/core/src/config/config.ts b/packages/core/src/config/config.ts index 59c9c1bd1b..4ee2d23fef 100644 --- a/packages/core/src/config/config.ts +++ b/packages/core/src/config/config.ts @@ -456,25 +456,33 @@ export class Config { export function createToolRegistry(config: Config): Promise { const registry = new ToolRegistry(config); 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 // eslint-disable-next-line @typescript-eslint/no-explicit-any const registerCoreTool = (ToolClass: any, ...args: unknown[]) => { - // check both the tool name (.Name) and the class name (.name) - if ( - // coreTools contain tool name - (!tools || tools.has(ToolClass.Name) || tools.has(ToolClass.name)) && - // excludeTools don't contain tool name - (!excludeTools || - (!excludeTools.has(ToolClass.Name) && - !excludeTools.has(ToolClass.name))) - ) { + const className = ToolClass.name; + const toolName = ToolClass.Name || className; + const coreTools = config.getCoreTools(); + const excludeTools = config.getExcludeTools(); + + let isEnabled = false; + if (coreTools === undefined) { + 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)); } }; diff --git a/packages/core/src/tools/shell.test.ts b/packages/core/src/tools/shell.test.ts new file mode 100644 index 0000000000..2cbd0ff4c9 --- /dev/null +++ b/packages/core/src/tools/shell.test.ts @@ -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); + }); +}); diff --git a/packages/core/src/tools/shell.ts b/packages/core/src/tools/shell.ts index 1ca9076855..a2fa5ce431 100644 --- a/packages/core/src/tools/shell.ts +++ b/packages/core/src/tools/shell.ts @@ -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) } + 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 { + if (!this.isCommandAllowed(params.command)) { + return `Command is not allowed: ${params.command}`; + } if ( !SchemaValidator.validate( this.parameterSchema as Record,