summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorJerop Kipruto <[email protected]>2025-06-29 15:32:26 -0400
committerGitHub <[email protected]>2025-06-29 19:32:26 +0000
commitd8d78d73f9638d11ba8b6ba184b49d4dc7caa8f4 (patch)
treefd747168058eb730afc1766f5ad4712df335f6cf
parent19a0276142b61208e5d4b723e422e37bf005845a (diff)
feat: allow command-specific restrictions for ShellTool (#2605)
-rw-r--r--docs/cli/configuration.md9
-rw-r--r--docs/tools/shell.md102
-rw-r--r--packages/core/src/config/config.ts38
-rw-r--r--packages/core/src/tools/shell.test.ts171
-rw-r--r--packages/core/src/tools/shell.ts60
5 files changed, 362 insertions, 18 deletions
diff --git a/docs/cli/configuration.md b/docs/cli/configuration.md
index edf5d9fd..c866c01f 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 ff6e574e..cc65b013 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(<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.
diff --git a/packages/core/src/config/config.ts b/packages/core/src/config/config.ts
index 59c9c1bd..4ee2d23f 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<ToolRegistry> {
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 00000000..2cbd0ff4
--- /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 1ca90768..a2fa5ce4 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<string, unknown>,