feat: allow command-specific restrictions for ShellTool (#2605)
This commit is contained in:
parent
19a0276142
commit
d8d78d73f9
|
@ -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.
|
||||||
|
|
|
@ -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.
|
||||||
|
|
|
@ -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));
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
|
@ -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);
|
||||||
|
});
|
||||||
|
});
|
|
@ -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>,
|
||||||
|
|
Loading…
Reference in New Issue