Re-submission: Make --allowed-tools work in non-interactive mode (#10289)

Co-authored-by: google-labs-jules[bot] <161369871+google-labs-jules[bot]@users.noreply.github.com>
Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>
Co-authored-by: matt korwel <matt.korwel@gmail.com>
This commit is contained in:
mistergarrison
2025-10-06 12:15:21 -07:00
committed by GitHub
parent b6d3c56b35
commit d9fdff339a
9 changed files with 442 additions and 7 deletions

View File

@@ -61,6 +61,7 @@ describe('ShellTool', () => {
.mockReturnValue(createMockWorkspaceContext('/test/dir')),
getGeminiClient: vi.fn(),
getShouldUseNodePtyShell: vi.fn().mockReturnValue(false),
isInteractive: vi.fn().mockReturnValue(true),
} as unknown as Config;
shellTool = new ShellTool(mockConfig);

View File

@@ -22,6 +22,7 @@ import {
ToolConfirmationOutcome,
Kind,
} from './tools.js';
import { ApprovalMode } from '../config/config.js';
import { getErrorMessage } from '../utils/errors.js';
import { summarizeToolOutput } from '../utils/summarizer.js';
import type {
@@ -34,11 +35,58 @@ import type { AnsiOutput } from '../utils/terminalSerializer.js';
import {
getCommandRoots,
isCommandAllowed,
SHELL_TOOL_NAMES,
stripShellWrapper,
} from '../utils/shell-utils.js';
export const OUTPUT_UPDATE_INTERVAL_MS = 1000;
/**
* Parses the `--allowed-tools` flag to determine which sub-commands of the
* ShellTool are allowed. The flag can be provided multiple times.
*
* @param allowedTools The list of allowed tools from the config.
* @returns A Set of allowed sub-commands, or null if all commands are allowed.
* - `null`: All sub-commands are allowed (e.g., --allowed-tools="ShellTool").
* - `Set<string>`: A set of specifically allowed sub-commands (e.g., --allowed-tools="ShellTool(wc)" --allowed-tools="ShellTool(ls)").
* - `Set<>` (empty): No sub-commands are allowed (e.g., --allowed-tools="ShellTool()").
*/
function parseAllowedSubcommands(
allowedTools: readonly string[],
): Set<string> | null {
const shellToolEntries = allowedTools.filter((tool) =>
SHELL_TOOL_NAMES.some((name) => tool.startsWith(name)),
);
if (shellToolEntries.length === 0) {
return new Set(); // ShellTool not mentioned, so no subcommands are allowed.
}
// If any entry is just "run_shell_command" or "ShellTool", all subcommands are allowed.
if (shellToolEntries.some((entry) => SHELL_TOOL_NAMES.includes(entry))) {
return null;
}
const allSubcommands = new Set<string>();
const toolNamePattern = SHELL_TOOL_NAMES.join('|');
const regex = new RegExp(`^(${toolNamePattern})\\((.*)\\)$`);
for (const entry of shellToolEntries) {
const match = entry.match(regex);
if (match) {
const subcommands = match[2];
if (subcommands) {
subcommands
.split(',')
.map((s) => s.trim())
.forEach((s) => s && allSubcommands.add(s));
}
}
}
return allSubcommands;
}
export interface ShellToolParams {
command: string;
description?: string;
@@ -76,6 +124,30 @@ export class ShellToolInvocation extends BaseToolInvocation<
): Promise<ToolCallConfirmationDetails | false> {
const command = stripShellWrapper(this.params.command);
const rootCommands = [...new Set(getCommandRoots(command))];
// In non-interactive mode, we need to prevent the tool from hanging while
// waiting for user input. If a tool is not fully allowed (e.g. via
// --allowed-tools="ShellTool(wc)"), we should throw an error instead of
// prompting for confirmation. This check is skipped in YOLO mode.
if (
!this.config.isInteractive() &&
this.config.getApprovalMode() !== ApprovalMode.YOLO
) {
const allowed = this.config.getAllowedTools() || [];
const allowedSubcommands = parseAllowedSubcommands(allowed);
if (allowedSubcommands !== null) {
// Not all commands are allowed, so we need to check.
const allCommandsAllowed = rootCommands.every((cmd) =>
allowedSubcommands.has(cmd),
);
if (!allCommandsAllowed) {
throw new Error(
`Command "${command}" is not in the list of allowed tools for non-interactive mode.`,
);
}
}
}
const commandsToConfirm = rootCommands.filter(
(command) => !this.allowlist.has(command),
);

View File

@@ -11,7 +11,7 @@ import { quote } from 'shell-quote';
import { doesToolInvocationMatch } from './tool-utils.js';
import { spawn, type SpawnOptionsWithoutStdio } from 'node:child_process';
const SHELL_TOOL_NAMES = ['run_shell_command', 'ShellTool'];
export const SHELL_TOOL_NAMES = ['run_shell_command', 'ShellTool'];
/**
* An identifier for the shell type.

View File

@@ -36,6 +36,15 @@ describe('doesToolInvocationMatch', () => {
expect(result).toBe(true);
});
it('should match a command with an alias', () => {
const invocation = {
params: { command: 'wc -l' },
} as AnyToolInvocation;
const patterns = ['ShellTool(wc)'];
const result = doesToolInvocationMatch('ShellTool', invocation, patterns);
expect(result).toBe(true);
});
it('should match a command that is a prefix', () => {
const invocation = {
params: { command: 'git status -v' },

View File

@@ -6,8 +6,7 @@
import type { AnyDeclarativeTool, AnyToolInvocation } from '../index.js';
import { isTool } from '../index.js';
const SHELL_TOOL_NAMES = ['run_shell_command', 'ShellTool'];
import { SHELL_TOOL_NAMES } from './shell-utils.js';
/**
* Checks if a tool invocation matches any of a list of patterns.
@@ -61,7 +60,7 @@ export function doesToolInvocationMatch(
if (
'command' in invocation.params &&
toolNames.includes('run_shell_command')
toolNames.some((name) => SHELL_TOOL_NAMES.includes(name))
) {
const argValue = String(
(invocation.params as { command: string }).command,