summaryrefslogtreecommitdiff
path: root/packages/cli/src/services/prompt-processors/shellProcessor.ts
diff options
context:
space:
mode:
authorAbhi <[email protected]>2025-07-27 02:00:26 -0400
committerGitHub <[email protected]>2025-07-27 06:00:26 +0000
commit576cebc9282cfbe57d45321105d72cc61597ce9b (patch)
tree374dd97245761fe5c40ee87a9b4d5674a26344cf /packages/cli/src/services/prompt-processors/shellProcessor.ts
parent9e61b3510c0cd7f333f40f68e87d981aff19aab1 (diff)
feat: Add Shell Command Execution to Custom Commands (#4917)
Diffstat (limited to 'packages/cli/src/services/prompt-processors/shellProcessor.ts')
-rw-r--r--packages/cli/src/services/prompt-processors/shellProcessor.ts106
1 files changed, 106 insertions, 0 deletions
diff --git a/packages/cli/src/services/prompt-processors/shellProcessor.ts b/packages/cli/src/services/prompt-processors/shellProcessor.ts
new file mode 100644
index 00000000..bf811d66
--- /dev/null
+++ b/packages/cli/src/services/prompt-processors/shellProcessor.ts
@@ -0,0 +1,106 @@
+/**
+ * @license
+ * Copyright 2025 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import {
+ checkCommandPermissions,
+ ShellExecutionService,
+} from '@google/gemini-cli-core';
+
+import { CommandContext } from '../../ui/commands/types.js';
+import { IPromptProcessor } from './types.js';
+
+export class ConfirmationRequiredError extends Error {
+ constructor(
+ message: string,
+ public commandsToConfirm: string[],
+ ) {
+ super(message);
+ this.name = 'ConfirmationRequiredError';
+ }
+}
+
+/**
+ * Finds all instances of shell command injections (`!{...}`) in a prompt,
+ * executes them, and replaces the injection site with the command's output.
+ *
+ * This processor ensures that only allowlisted commands are executed. If a
+ * disallowed command is found, it halts execution and reports an error.
+ */
+export class ShellProcessor implements IPromptProcessor {
+ /**
+ * A regular expression to find all instances of `!{...}`. The inner
+ * capture group extracts the command itself.
+ */
+ private static readonly SHELL_INJECTION_REGEX = /!\{([^}]*)\}/g;
+
+ /**
+ * @param commandName The name of the custom command being executed, used
+ * for logging and error messages.
+ */
+ constructor(private readonly commandName: string) {}
+
+ async process(prompt: string, context: CommandContext): Promise<string> {
+ const { config, sessionShellAllowlist } = {
+ ...context.services,
+ ...context.session,
+ };
+ const commandsToExecute: Array<{ fullMatch: string; command: string }> = [];
+ const commandsToConfirm = new Set<string>();
+
+ const matches = [...prompt.matchAll(ShellProcessor.SHELL_INJECTION_REGEX)];
+ if (matches.length === 0) {
+ return prompt; // No shell commands, nothing to do.
+ }
+
+ // Discover all commands and check permissions.
+ for (const match of matches) {
+ const command = match[1].trim();
+ const { allAllowed, disallowedCommands, blockReason, isHardDenial } =
+ checkCommandPermissions(command, config!, sessionShellAllowlist);
+
+ if (!allAllowed) {
+ // If it's a hard denial, this is a non-recoverable security error.
+ if (isHardDenial) {
+ throw new Error(
+ `${this.commandName} cannot be run. ${blockReason || 'A shell command in this custom command is explicitly blocked in your config settings.'}`,
+ );
+ }
+
+ // Add each soft denial disallowed command to the set for confirmation.
+ disallowedCommands.forEach((uc) => commandsToConfirm.add(uc));
+ }
+ commandsToExecute.push({ fullMatch: match[0], command });
+ }
+
+ // If any commands require confirmation, throw a special error to halt the
+ // pipeline and trigger the UI flow.
+ if (commandsToConfirm.size > 0) {
+ throw new ConfirmationRequiredError(
+ 'Shell command confirmation required',
+ Array.from(commandsToConfirm),
+ );
+ }
+
+ // Execute all commands (only runs if no confirmation was needed).
+ let processedPrompt = prompt;
+ for (const { fullMatch, command } of commandsToExecute) {
+ const { result } = ShellExecutionService.execute(
+ command,
+ config!.getTargetDir(),
+ () => {}, // No streaming needed.
+ new AbortController().signal, // For now, we don't support cancellation from here.
+ );
+
+ const executionResult = await result;
+ processedPrompt = processedPrompt.replace(
+ fullMatch,
+ executionResult.output,
+ );
+ }
+
+ return processedPrompt;
+ }
+}