Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 14 additions & 11 deletions docs/cli/enterprise.md
Original file line number Diff line number Diff line change
Expand Up @@ -269,17 +269,20 @@ on the approved list.
> **Deprecated:** Use the [Policy Engine](../reference/policy-engine.md) for
> more robust control.

Alternatively, you can add specific tools that are considered dangerous in your
environment to a blocklist.

**Example:** Prevent the use of the shell tool for removing files.

```json
{
"tools": {
"exclude": ["ShellTool(rm -rf)"]
}
}
Alternatively, you can deny dangerous commands with the
[Policy Engine](../reference/policy-engine.md). Do not put `ShellTool(rm -rf)`
or `run_shell_command(rm -rf)` in `tools.exclude` as if it were a command filter
— that list matches whole tool names. Legacy parenthesized entries are converted
into deny rules, but a policy file is the supported way to block a command.

**Example:** Deny `rm -rf` via a policy rule:

```toml
[[rule]]
toolName = "run_shell_command"
commandPrefix = "rm -rf"
decision = "deny"
priority = 100
```

<!-- prettier-ignore -->
Expand Down
22 changes: 17 additions & 5 deletions docs/extensions/best-practices.md
Original file line number Diff line number Diff line change
Expand Up @@ -66,18 +66,30 @@ Only request the permissions your MCP server needs to function. Avoid giving the
model broad access (such as full shell access) if restricted tools are
sufficient.

If your extension uses powerful tools like `run_shell_command`, restrict them in
your `gemini-extension.json` file:
If your extension does not need a powerful tool like `run_shell_command` at all,
exclude it in your `gemini-extension.json` file. `excludeTools` matches whole
tool names:

```json
{
"name": "my-safe-extension",
"excludeTools": ["run_shell_command(rm -rf *)"]
"excludeTools": ["run_shell_command"]
}
```

This ensures the CLI blocks dangerous commands even if the model attempts to
execute them.
To block individual commands rather than the whole tool, ship a policy rule in
your extension's `policies/` directory instead:

```toml
[[rule]]
toolName = "run_shell_command"
commandPrefix = "rm -rf"
decision = "deny"
priority = 100
```

The CLI then blocks those commands even if the model attempts to run them. See
[Policy engine](../reference/policy-engine.md) for the full rule syntax.

### Validate inputs

Expand Down
13 changes: 7 additions & 6 deletions docs/extensions/reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -159,12 +159,13 @@ The manifest file defines the extension's behavior and configuration.
extension. This will be used to load the context from the extension directory.
If this property is not used but a `GEMINI.md` file is present in your
extension directory, then that file will be loaded.
- `excludeTools`: An array of tool names to exclude from the model. You can also
specify command-specific restrictions for tools that support it, like the
`run_shell_command` tool. For example,
`"excludeTools": ["run_shell_command(rm -rf)"]` will block the `rm -rf`
command. Note that this differs from the MCP server `excludeTools`
functionality, which can be listed in the MCP server config.
- `excludeTools`: An array of tool names to exclude from the model. Entries are
matched against whole tool names, so `"excludeTools": ["run_shell_command"]`

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: The documentation omits the still-supported legacy parenthesized form while describing all entries as whole-tool matches. Document that parenthesized entries are converted to command-deny rules, while recommending policies/ for new extensions.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At docs/extensions/reference.md, line 163:

<comment>The documentation omits the still-supported legacy parenthesized form while describing all entries as whole-tool matches. Document that parenthesized entries are converted to command-deny rules, while recommending `policies/` for new extensions.</comment>

<file context>
@@ -159,12 +159,13 @@ The manifest file defines the extension's behavior and configuration.
-  command. Note that this differs from the MCP server `excludeTools`
-  functionality, which can be listed in the MCP server config.
+- `excludeTools`: An array of tool names to exclude from the model. Entries are
+  matched against whole tool names, so `"excludeTools": ["run_shell_command"]`
+  removes that tool entirely. To restrict individual commands instead of the
+  whole tool, define a rule in your extension's `policies/` directory; see
</file context>

removes that tool entirely. To restrict individual commands instead of the
whole tool, define a rule in your extension's `policies/` directory; see
[Policy engine](../reference/policy-engine.md). Note that this differs from
the MCP server `excludeTools` functionality, which can be listed in the MCP
server config.
- `plan`: Planning features configuration.
- `directory`: The directory where planning artifacts are stored. This serves
as a fallback if the user hasn't specified a plan directory in their
Expand Down
6 changes: 5 additions & 1 deletion docs/reference/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -1801,7 +1801,11 @@ their corresponding top-level category object in your `settings.json` file.

- **`tools.exclude`** (array):

- **Description:** Tool names to exclude from discovery.
- **Description:** Tool names to exclude from discovery. Entries must be whole
tool names (for example `run_shell_command`). Parenthesized `toolName(args)`
values are not tool names; they are converted into command-level deny rules.
Prefer the [Policy Engine](policy-engine.md) for command restrictions.
Deprecated.
- **Default:** `undefined`
- **Requires restart:** Yes

Expand Down
56 changes: 34 additions & 22 deletions docs/tools/shell.md
Original file line number Diff line number Diff line change
Expand Up @@ -146,8 +146,9 @@ tools to detect if they are being run from within Gemini CLI.
> `replace`, etc.

You can restrict the commands that can be executed by the `run_shell_command`
tool by using the `tools.core` and `tools.exclude` settings in your
configuration file.
tool with `tools.core` (an allowlist) and the
[Policy Engine](../reference/policy-engine.md) (for blocking individual
commands).

- `tools.core`: To restrict `run_shell_command` to a specific set of commands,
add entries to the `core` list under the `tools` category in the format
Expand All @@ -156,10 +157,18 @@ configuration file.
commands. Including the generic `run_shell_command` acts as a wildcard,
allowing any command not explicitly blocked.
- `tools.exclude` [DEPRECATED]: To block specific commands, use the
[Policy Engine](../reference/policy-engine.md). Historically, this setting
allowed adding entries to the `exclude` list under the `tools` category in the
format `run_shell_command(<command>)`. For example,
`"tools": {"exclude": ["run_shell_command(rm)"]}` will block `rm` commands.
[Policy Engine](../reference/policy-engine.md). `tools.exclude` and extension
`excludeTools` match **whole tool names**. A parenthesized
`run_shell_command(<command>)` entry is not a tool name; the CLI converts it
into a command-level deny rule and warns. Prefer a policy file:

```toml
[[rule]]
toolName = "run_shell_command"
commandPrefix = "rm"
decision = "deny"
priority = 100
```

The validation logic is designed to be secure and flexible:

Expand All @@ -168,9 +177,10 @@ The validation logic is designed to be secure and flexible:
part of the chain is disallowed, the entire command is blocked.
2. **Prefix matching**: The tool uses prefix matching. For example, if you
allow `git`, you can run `git status` or `git log`.
3. **Blocklist precedence**: The `tools.exclude` list is always checked first.
If a command matches a blocked prefix, it will be denied, even if it also
matches an allowed prefix in `tools.core`.
3. **Deny-rule precedence**: A Policy Engine `deny` (or a legacy
`tools.exclude` entry that has been converted into one) is evaluated by
priority. A matching deny blocks the command even when `tools.core` would
otherwise allow it.

### Command restriction examples

Expand All @@ -192,25 +202,25 @@ To allow only `git` and `npm` commands, and block all others:

**Block specific command prefixes**

To block `rm` and allow all other commands:
To block `rm` and allow all other commands, ship a policy rule rather than
`tools.exclude`:

```json
{
"tools": {
"core": ["run_shell_command"],
"exclude": ["run_shell_command(rm)"]
}
}
```toml
[[rule]]
toolName = "run_shell_command"
commandPrefix = "rm"
decision = "deny"
priority = 100
```

- `rm -rf /`: Blocked
- `git status`: Allowed
- `npm install`: Allowed

**Blocklist takes precedence**
**Deny rules take precedence**

If a command prefix is in both `tools.core` and `tools.exclude`, it will be
blocked.
If a command prefix is both allowlisted in `tools.core` and denied by a policy
rule, it is blocked.

- **`tools.shell.enableInteractiveShell`**: (boolean) Uses `node-pty` for
real-time interaction.
Expand All @@ -224,8 +234,10 @@ You can limit which commands the agent is allowed to request using these
settings:

- **`tools.core`**: An allowlist of command prefixes (for example,
`["git", "npm test"]`).
- **`tools.exclude`**: A blocklist of command prefixes.
`["run_shell_command(git)", "run_shell_command(npm test)"]`).
- **Policy Engine**: A `deny` rule with `commandPrefix` or `commandRegex`. The
deprecated **`tools.exclude`** setting matches whole tool names; parenthesized
entries are converted into command-level deny rules.

## Use cases

Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
{
"name": "excludeTools",
"version": "1.0.0",
"excludeTools": ["run_shell_command(rm -rf)"]
"excludeTools": ["run_shell_command"]
}
12 changes: 11 additions & 1 deletion packages/core/src/config/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -184,6 +184,7 @@ import { validatePath } from '../utils/path-validator.js';
import { InjectionService } from './injectionService.js';
import { ExecutionLifecycleService } from '../services/executionLifecycleService.js';
import { WORKSPACE_POLICY_TIER } from '../policy/config.js';
import { isLegacyCommandScopedToolRef } from '../policy/legacy-tool-syntax.js';
import { loadPoliciesFromToml } from '../policy/toml-loader.js';

import { CheckerRunner } from '../safety/checker-runner.js';
Expand Down Expand Up @@ -2428,12 +2429,21 @@ export class Config implements McpContext, AgentLoopContext {
allToolNames?: Set<string>,
): Set<string> | undefined {
// Right now this is present for backward compatibility with settings.json exclude
const excludeToolsSet = new Set([...(this.excludeTools ?? [])]);
const excludeToolsSet = new Set<string>();
for (const tool of this.excludeTools ?? []) {
if (isLegacyCommandScopedToolRef(tool)) {
continue;
}
excludeToolsSet.add(tool);
}
for (const extension of this.getExtensionLoader().getExtensions()) {
if (!extension.isActive) {
continue;
}
for (const tool of extension.excludeTools || []) {
if (isLegacyCommandScopedToolRef(tool)) {
continue;
}
excludeToolsSet.add(tool);
}
}
Expand Down
20 changes: 20 additions & 0 deletions packages/core/src/policy/config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -227,6 +227,26 @@ describe('createPolicyEngineConfig', () => {
expect(rule?.priority).toBeCloseTo(4.4, 5); // Command line exclude
});

it('should deny specific shell commands in tools.exclude using toolName(args)', async () => {
const config = await createPolicyEngineConfig(
{ tools: { exclude: ['run_shell_command(rm)'] } },
ApprovalMode.DEFAULT,
MOCK_DEFAULT_DIR,
);
const rule = config.rules?.find(
(r) =>
r.toolName === 'run_shell_command' &&
r.decision === PolicyDecision.DENY &&
r.argsPattern,
);
expect(rule).toBeDefined();
expect(rule?.argsPattern?.test('{"command":"rm -rf /"}')).toBe(true);
expect(rule?.argsPattern?.test('{"command":"ls"}')).toBe(false);
expect(
config.rules?.some((r) => r.toolName === 'run_shell_command(rm)'),
).toBe(false);
});

it('should allow tools from allowed MCP servers', async () => {
const config = await createPolicyEngineConfig(
{ mcp: { allowed: ['my-server'] } },
Expand Down
19 changes: 11 additions & 8 deletions packages/core/src/policy/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ import { isNodeError } from '../utils/errors.js';
import { MCP_TOOL_PREFIX } from '../tools/mcp-tool.js';

import { isDirectorySecure } from '../utils/security.js';
import { mapExcludeToolsToDenyRules } from './legacy-tool-syntax.js';

const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
Expand Down Expand Up @@ -425,15 +426,17 @@ export async function createPolicyEngineConfig(

// Tools that are explicitly excluded in the settings.
// Priority: EXCLUDE_TOOLS_FLAG_PRIORITY (user tier - explicit temporary blocks)
// Parenthesized `toolName(args)` entries are command restrictions, not tool
// names — convert them into DENY rules with an argsPattern.
if (settings.tools?.exclude) {
for (const tool of settings.tools.exclude) {
rules.push({
toolName: tool,
decision: PolicyDecision.DENY,
priority: EXCLUDE_TOOLS_FLAG_PRIORITY,
source: 'Settings (Tools Excluded)',
});
}
rules.push(
...mapExcludeToolsToDenyRules(
settings.tools.exclude,
EXCLUDE_TOOLS_FLAG_PRIORITY,
'Settings (Tools Excluded)',
emitWarningOnce,
),
);
}

const nonPlanModes = [
Expand Down
81 changes: 81 additions & 0 deletions packages/core/src/policy/legacy-tool-syntax.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/

import { describe, expect, it, vi } from 'vitest';
import { PolicyDecision } from './types.js';
import {
isLegacyCommandScopedToolRef,
mapExcludeToolsToDenyRules,
normalizeLegacyCommandPrefix,
} from './legacy-tool-syntax.js';
import { SHELL_TOOL_NAME } from '../tools/tool-names.js';

describe('legacy-tool-syntax', () => {
describe('isLegacyCommandScopedToolRef', () => {
it('detects parenthesized command restrictions', () => {
expect(isLegacyCommandScopedToolRef('run_shell_command(rm -rf *)')).toBe(
true,
);
expect(isLegacyCommandScopedToolRef('run_shell_command')).toBe(false);
});
});

describe('normalizeLegacyCommandPrefix', () => {
it('strips a trailing glob star', () => {
expect(normalizeLegacyCommandPrefix('rm -rf *')).toBe('rm -rf');
expect(normalizeLegacyCommandPrefix('rm')).toBe('rm');
expect(normalizeLegacyCommandPrefix('*')).toBeUndefined();
});
});

describe('mapExcludeToolsToDenyRules', () => {
it('denies a whole tool for a bare name', () => {
const rules = mapExcludeToolsToDenyRules(
['run_shell_command'],
4.4,
'Settings (Tools Excluded)',
);
expect(rules).toEqual([
{
toolName: SHELL_TOOL_NAME,
decision: PolicyDecision.DENY,
priority: 4.4,
source: 'Settings (Tools Excluded)',
},
]);
});

it('converts parenthesized shell excludes into command-prefix deny rules', () => {
const warn = vi.fn();
const rules = mapExcludeToolsToDenyRules(
['run_shell_command(rm -rf *)'],
4.4,
'Settings (Tools Excluded)',
warn,
);

expect(warn).toHaveBeenCalledOnce();
expect(rules).toHaveLength(1);
expect(rules[0]?.toolName).toBe(SHELL_TOOL_NAME);
expect(rules[0]?.decision).toBe(PolicyDecision.DENY);
expect(rules[0]?.argsPattern).toBeInstanceOf(RegExp);

const argsPattern = rules[0]?.argsPattern;
expect(argsPattern?.test('{"command":"rm -rf /tmp/victim"}')).toBe(true);
expect(argsPattern?.test('{"command":"ls -la"}')).toBe(false);
});

it('normalizes ShellTool aliases', () => {
const rules = mapExcludeToolsToDenyRules(
['ShellTool(rm)'],
4.4,
'Settings (Tools Excluded)',
);
expect(rules[0]?.toolName).toBe(SHELL_TOOL_NAME);
expect(rules[0]?.argsPattern?.test('{"command":"rm -rf /"}')).toBe(true);
});
});
});
Loading