summaryrefslogtreecommitdiff
path: root/packages/cli/src
diff options
context:
space:
mode:
Diffstat (limited to 'packages/cli/src')
-rw-r--r--packages/cli/src/services/FileCommandLoader.test.ts151
-rw-r--r--packages/cli/src/services/FileCommandLoader.ts22
-rw-r--r--packages/cli/src/services/prompt-processors/argumentProcessor.test.ts62
-rw-r--r--packages/cli/src/services/prompt-processors/argumentProcessor.ts17
-rw-r--r--packages/cli/src/services/prompt-processors/shellProcessor.test.ts471
-rw-r--r--packages/cli/src/services/prompt-processors/shellProcessor.ts240
-rw-r--r--packages/cli/src/services/prompt-processors/types.ts2
7 files changed, 768 insertions, 197 deletions
diff --git a/packages/cli/src/services/FileCommandLoader.test.ts b/packages/cli/src/services/FileCommandLoader.test.ts
index f4f8ac2c..42d93074 100644
--- a/packages/cli/src/services/FileCommandLoader.test.ts
+++ b/packages/cli/src/services/FileCommandLoader.test.ts
@@ -22,7 +22,8 @@ import {
ConfirmationRequiredError,
ShellProcessor,
} from './prompt-processors/shellProcessor.js';
-import { ShorthandArgumentProcessor } from './prompt-processors/argumentProcessor.js';
+import { DefaultArgumentProcessor } from './prompt-processors/argumentProcessor.js';
+import { CommandContext } from '../ui/commands/types.js';
const mockShellProcess = vi.hoisted(() => vi.fn());
vi.mock('./prompt-processors/shellProcessor.js', () => ({
@@ -46,9 +47,6 @@ vi.mock('./prompt-processors/argumentProcessor.js', async (importOriginal) => {
typeof import('./prompt-processors/argumentProcessor.js')
>();
return {
- ShorthandArgumentProcessor: vi
- .fn()
- .mockImplementation(() => new original.ShorthandArgumentProcessor()),
DefaultArgumentProcessor: vi
.fn()
.mockImplementation(() => new original.DefaultArgumentProcessor()),
@@ -71,7 +69,16 @@ describe('FileCommandLoader', () => {
beforeEach(() => {
vi.clearAllMocks();
- mockShellProcess.mockImplementation((prompt) => Promise.resolve(prompt));
+ mockShellProcess.mockImplementation(
+ (prompt: string, context: CommandContext) => {
+ const userArgsRaw = context?.invocation?.args || '';
+ const processedPrompt = prompt.replaceAll(
+ SHORTHAND_ARGS_PLACEHOLDER,
+ userArgsRaw,
+ );
+ return Promise.resolve(processedPrompt);
+ },
+ );
});
afterEach(() => {
@@ -198,7 +205,7 @@ describe('FileCommandLoader', () => {
const mockConfig = {
getProjectRoot: vi.fn(() => '/path/to/project'),
getExtensions: vi.fn(() => []),
- } as Config;
+ } as unknown as Config;
const loader = new FileCommandLoader(mockConfig);
const commands = await loader.loadCommands(signal);
expect(commands).toHaveLength(1);
@@ -239,7 +246,7 @@ describe('FileCommandLoader', () => {
const mockConfig = {
getProjectRoot: vi.fn(() => process.cwd()),
getExtensions: vi.fn(() => []),
- } as Config;
+ } as unknown as Config;
const loader = new FileCommandLoader(mockConfig);
const commands = await loader.loadCommands(signal);
@@ -379,6 +386,68 @@ describe('FileCommandLoader', () => {
expect(command.name).toBe('legacy_command');
});
+ describe('Processor Instantiation Logic', () => {
+ it('instantiates only DefaultArgumentProcessor if no {{args}} or !{} are present', async () => {
+ const userCommandsDir = getUserCommandsDir();
+ mock({
+ [userCommandsDir]: {
+ 'simple.toml': `prompt = "Just a regular prompt"`,
+ },
+ });
+
+ const loader = new FileCommandLoader(null as unknown as Config);
+ await loader.loadCommands(signal);
+
+ expect(ShellProcessor).not.toHaveBeenCalled();
+ expect(DefaultArgumentProcessor).toHaveBeenCalledTimes(1);
+ });
+
+ it('instantiates only ShellProcessor if {{args}} is present (but not !{})', async () => {
+ const userCommandsDir = getUserCommandsDir();
+ mock({
+ [userCommandsDir]: {
+ 'args.toml': `prompt = "Prompt with {{args}}"`,
+ },
+ });
+
+ const loader = new FileCommandLoader(null as unknown as Config);
+ await loader.loadCommands(signal);
+
+ expect(ShellProcessor).toHaveBeenCalledTimes(1);
+ expect(DefaultArgumentProcessor).not.toHaveBeenCalled();
+ });
+
+ it('instantiates ShellProcessor and DefaultArgumentProcessor if !{} is present (but not {{args}})', async () => {
+ const userCommandsDir = getUserCommandsDir();
+ mock({
+ [userCommandsDir]: {
+ 'shell.toml': `prompt = "Prompt with !{cmd}"`,
+ },
+ });
+
+ const loader = new FileCommandLoader(null as unknown as Config);
+ await loader.loadCommands(signal);
+
+ expect(ShellProcessor).toHaveBeenCalledTimes(1);
+ expect(DefaultArgumentProcessor).toHaveBeenCalledTimes(1);
+ });
+
+ it('instantiates only ShellProcessor if both {{args}} and !{} are present', async () => {
+ const userCommandsDir = getUserCommandsDir();
+ mock({
+ [userCommandsDir]: {
+ 'both.toml': `prompt = "Prompt with {{args}} and !{cmd}"`,
+ },
+ });
+
+ const loader = new FileCommandLoader(null as unknown as Config);
+ await loader.loadCommands(signal);
+
+ expect(ShellProcessor).toHaveBeenCalledTimes(1);
+ expect(DefaultArgumentProcessor).not.toHaveBeenCalled();
+ });
+ });
+
describe('Extension Command Loading', () => {
it('loads commands from active extensions', async () => {
const userCommandsDir = getUserCommandsDir();
@@ -416,7 +485,7 @@ describe('FileCommandLoader', () => {
path: extensionDir,
},
]),
- } as Config;
+ } as unknown as Config;
const loader = new FileCommandLoader(mockConfig);
const commands = await loader.loadCommands(signal);
@@ -465,7 +534,7 @@ describe('FileCommandLoader', () => {
path: extensionDir,
},
]),
- } as Config;
+ } as unknown as Config;
const loader = new FileCommandLoader(mockConfig);
const commands = await loader.loadCommands(signal);
@@ -572,7 +641,7 @@ describe('FileCommandLoader', () => {
path: extensionDir2,
},
]),
- } as Config;
+ } as unknown as Config;
const loader = new FileCommandLoader(mockConfig);
const commands = await loader.loadCommands(signal);
@@ -608,7 +677,7 @@ describe('FileCommandLoader', () => {
path: extensionDir,
},
]),
- } as Config;
+ } as unknown as Config;
const loader = new FileCommandLoader(mockConfig);
const commands = await loader.loadCommands(signal);
expect(commands).toHaveLength(0);
@@ -640,7 +709,7 @@ describe('FileCommandLoader', () => {
getExtensions: vi.fn(() => [
{ name: 'a', version: '1.0.0', isActive: true, path: extensionDir },
]),
- } as Config;
+ } as unknown as Config;
const loader = new FileCommandLoader(mockConfig);
const commands = await loader.loadCommands(signal);
@@ -671,7 +740,7 @@ describe('FileCommandLoader', () => {
});
});
- describe('Shorthand Argument Processor Integration', () => {
+ describe('Argument Handling Integration (via ShellProcessor)', () => {
it('correctly processes a command with {{args}}', async () => {
const userCommandsDir = getUserCommandsDir();
mock({
@@ -738,6 +807,19 @@ describe('FileCommandLoader', () => {
});
describe('Shell Processor Integration', () => {
+ it('instantiates ShellProcessor if {{args}} is present (even without shell trigger)', async () => {
+ const userCommandsDir = getUserCommandsDir();
+ mock({
+ [userCommandsDir]: {
+ 'args_only.toml': `prompt = "Hello {{args}}"`,
+ },
+ });
+
+ const loader = new FileCommandLoader(null as unknown as Config);
+ await loader.loadCommands(signal);
+
+ expect(ShellProcessor).toHaveBeenCalledWith('args_only');
+ });
it('instantiates ShellProcessor if the trigger is present', async () => {
const userCommandsDir = getUserCommandsDir();
mock({
@@ -752,7 +834,7 @@ describe('FileCommandLoader', () => {
expect(ShellProcessor).toHaveBeenCalledWith('shell');
});
- it('does not instantiate ShellProcessor if trigger is missing', async () => {
+ it('does not instantiate ShellProcessor if no triggers ({{args}} or !{}) are present', async () => {
const userCommandsDir = getUserCommandsDir();
mock({
[userCommandsDir]: {
@@ -852,32 +934,30 @@ describe('FileCommandLoader', () => {
),
).rejects.toThrow('Something else went wrong');
});
-
- it('assembles the processor pipeline in the correct order (Shell -> Argument)', async () => {
+ it('assembles the processor pipeline in the correct order (Shell -> Default)', async () => {
const userCommandsDir = getUserCommandsDir();
mock({
[userCommandsDir]: {
+ // This prompt uses !{} but NOT {{args}}, so both processors should be active.
'pipeline.toml': `
- prompt = "Shell says: ${SHELL_INJECTION_TRIGGER}echo foo} and user says: ${SHORTHAND_ARGS_PLACEHOLDER}"
- `,
+ prompt = "Shell says: ${SHELL_INJECTION_TRIGGER}echo foo}."
+ `,
},
});
- // Mock the process methods to track call order
- const argProcessMock = vi
+ const defaultProcessMock = vi
.fn()
- .mockImplementation((p) => `${p}-arg-processed`);
+ .mockImplementation((p) => Promise.resolve(`${p}-default-processed`));
- // Redefine the mock for this specific test
mockShellProcess.mockImplementation((p) =>
Promise.resolve(`${p}-shell-processed`),
);
- vi.mocked(ShorthandArgumentProcessor).mockImplementation(
+ vi.mocked(DefaultArgumentProcessor).mockImplementation(
() =>
({
- process: argProcessMock,
- }) as unknown as ShorthandArgumentProcessor,
+ process: defaultProcessMock,
+ }) as unknown as DefaultArgumentProcessor,
);
const loader = new FileCommandLoader(null as unknown as Config);
@@ -885,7 +965,7 @@ describe('FileCommandLoader', () => {
const command = commands.find((c) => c.name === 'pipeline');
expect(command).toBeDefined();
- await command!.action!(
+ const result = await command!.action!(
createMockCommandContext({
invocation: {
raw: '/pipeline bar',
@@ -896,20 +976,27 @@ describe('FileCommandLoader', () => {
'bar',
);
- // Verify that the shell processor was called before the argument processor
expect(mockShellProcess.mock.invocationCallOrder[0]).toBeLessThan(
- argProcessMock.mock.invocationCallOrder[0],
+ defaultProcessMock.mock.invocationCallOrder[0],
);
- // Also verify the flow of the prompt through the processors
+ // Verify the flow of the prompt through the processors
+ // 1. Shell processor runs first
expect(mockShellProcess).toHaveBeenCalledWith(
- expect.any(String),
+ expect.stringContaining(SHELL_INJECTION_TRIGGER),
expect.any(Object),
);
- expect(argProcessMock).toHaveBeenCalledWith(
- expect.stringContaining('-shell-processed'), // It receives the output of the shell processor
+ // 2. Default processor runs second
+ expect(defaultProcessMock).toHaveBeenCalledWith(
+ expect.stringContaining('-shell-processed'),
expect.any(Object),
);
+
+ if (result?.type === 'submit_prompt') {
+ expect(result.content).toContain('-shell-processed-default-processed');
+ } else {
+ assert.fail('Incorrect action type');
+ }
});
});
});
diff --git a/packages/cli/src/services/FileCommandLoader.ts b/packages/cli/src/services/FileCommandLoader.ts
index 5b8d8c42..2942a8b9 100644
--- a/packages/cli/src/services/FileCommandLoader.ts
+++ b/packages/cli/src/services/FileCommandLoader.ts
@@ -21,10 +21,7 @@ import {
SlashCommand,
SlashCommandActionReturn,
} from '../ui/commands/types.js';
-import {
- DefaultArgumentProcessor,
- ShorthandArgumentProcessor,
-} from './prompt-processors/argumentProcessor.js';
+import { DefaultArgumentProcessor } from './prompt-processors/argumentProcessor.js';
import {
IPromptProcessor,
SHORTHAND_ARGS_PLACEHOLDER,
@@ -224,16 +221,21 @@ export class FileCommandLoader implements ICommandLoader {
}
const processors: IPromptProcessor[] = [];
+ const usesArgs = validDef.prompt.includes(SHORTHAND_ARGS_PLACEHOLDER);
+ const usesShellInjection = validDef.prompt.includes(
+ SHELL_INJECTION_TRIGGER,
+ );
- // Add the Shell Processor if needed.
- if (validDef.prompt.includes(SHELL_INJECTION_TRIGGER)) {
+ // Interpolation (Shell Execution and Argument Injection)
+ // If the prompt uses either shell injection OR argument placeholders,
+ // we must use the ShellProcessor.
+ if (usesShellInjection || usesArgs) {
processors.push(new ShellProcessor(baseCommandName));
}
- // The presence of '{{args}}' is the switch that determines the behavior.
- if (validDef.prompt.includes(SHORTHAND_ARGS_PLACEHOLDER)) {
- processors.push(new ShorthandArgumentProcessor());
- } else {
+ // Default Argument Handling
+ // If NO explicit argument injection ({{args}}) was used, we append the raw invocation.
+ if (!usesArgs) {
processors.push(new DefaultArgumentProcessor());
}
diff --git a/packages/cli/src/services/prompt-processors/argumentProcessor.test.ts b/packages/cli/src/services/prompt-processors/argumentProcessor.test.ts
index 6af578a9..1a4c0c6b 100644
--- a/packages/cli/src/services/prompt-processors/argumentProcessor.test.ts
+++ b/packages/cli/src/services/prompt-processors/argumentProcessor.test.ts
@@ -4,69 +4,11 @@
* SPDX-License-Identifier: Apache-2.0
*/
-import {
- DefaultArgumentProcessor,
- ShorthandArgumentProcessor,
-} from './argumentProcessor.js';
+import { DefaultArgumentProcessor } from './argumentProcessor.js';
import { createMockCommandContext } from '../../test-utils/mockCommandContext.js';
+import { describe, it, expect } from 'vitest';
describe('Argument Processors', () => {
- describe('ShorthandArgumentProcessor', () => {
- const processor = new ShorthandArgumentProcessor();
-
- it('should replace a single {{args}} instance', async () => {
- const prompt = 'Refactor the following code: {{args}}';
- const context = createMockCommandContext({
- invocation: {
- raw: '/refactor make it faster',
- name: 'refactor',
- args: 'make it faster',
- },
- });
- const result = await processor.process(prompt, context);
- expect(result).toBe('Refactor the following code: make it faster');
- });
-
- it('should replace multiple {{args}} instances', async () => {
- const prompt = 'User said: {{args}}. I repeat: {{args}}!';
- const context = createMockCommandContext({
- invocation: {
- raw: '/repeat hello world',
- name: 'repeat',
- args: 'hello world',
- },
- });
- const result = await processor.process(prompt, context);
- expect(result).toBe('User said: hello world. I repeat: hello world!');
- });
-
- it('should handle an empty args string', async () => {
- const prompt = 'The user provided no input: {{args}}.';
- const context = createMockCommandContext({
- invocation: {
- raw: '/input',
- name: 'input',
- args: '',
- },
- });
- const result = await processor.process(prompt, context);
- expect(result).toBe('The user provided no input: .');
- });
-
- it('should not change the prompt if {{args}} is not present', async () => {
- const prompt = 'This is a static prompt.';
- const context = createMockCommandContext({
- invocation: {
- raw: '/static some arguments',
- name: 'static',
- args: 'some arguments',
- },
- });
- const result = await processor.process(prompt, context);
- expect(result).toBe('This is a static prompt.');
- });
- });
-
describe('DefaultArgumentProcessor', () => {
const processor = new DefaultArgumentProcessor();
diff --git a/packages/cli/src/services/prompt-processors/argumentProcessor.ts b/packages/cli/src/services/prompt-processors/argumentProcessor.ts
index a7efeea9..9d5fc369 100644
--- a/packages/cli/src/services/prompt-processors/argumentProcessor.ts
+++ b/packages/cli/src/services/prompt-processors/argumentProcessor.ts
@@ -4,25 +4,14 @@
* SPDX-License-Identifier: Apache-2.0
*/
-import { IPromptProcessor, SHORTHAND_ARGS_PLACEHOLDER } from './types.js';
+import { IPromptProcessor } from './types.js';
import { CommandContext } from '../../ui/commands/types.js';
/**
- * Replaces all instances of `{{args}}` in a prompt with the user-provided
- * argument string.
- */
-export class ShorthandArgumentProcessor implements IPromptProcessor {
- async process(prompt: string, context: CommandContext): Promise<string> {
- return prompt.replaceAll(
- SHORTHAND_ARGS_PLACEHOLDER,
- context.invocation!.args,
- );
- }
-}
-
-/**
* Appends the user's full command invocation to the prompt if arguments are
* provided, allowing the model to perform its own argument parsing.
+ *
+ * This processor is only used if the prompt does NOT contain {{args}}.
*/
export class DefaultArgumentProcessor implements IPromptProcessor {
async process(prompt: string, context: CommandContext): Promise<string> {
diff --git a/packages/cli/src/services/prompt-processors/shellProcessor.test.ts b/packages/cli/src/services/prompt-processors/shellProcessor.test.ts
index a2883923..fe4ae7a2 100644
--- a/packages/cli/src/services/prompt-processors/shellProcessor.test.ts
+++ b/packages/cli/src/services/prompt-processors/shellProcessor.test.ts
@@ -4,11 +4,32 @@
* SPDX-License-Identifier: Apache-2.0
*/
-import { vi, describe, it, expect, beforeEach } from 'vitest';
+import { describe, it, expect, beforeEach, vi } from 'vitest';
import { ConfirmationRequiredError, ShellProcessor } from './shellProcessor.js';
import { createMockCommandContext } from '../../test-utils/mockCommandContext.js';
import { CommandContext } from '../../ui/commands/types.js';
import { Config } from '@google/gemini-cli-core';
+import os from 'os';
+import { quote } from 'shell-quote';
+
+// Helper function to determine the expected escaped string based on the current OS,
+// mirroring the logic in the actual `escapeShellArg` implementation. This makes
+// our tests robust and platform-agnostic.
+function getExpectedEscapedArgForPlatform(arg: string): string {
+ if (os.platform() === 'win32') {
+ const comSpec = (process.env.ComSpec || 'cmd.exe').toLowerCase();
+ const isPowerShell =
+ comSpec.endsWith('powershell.exe') || comSpec.endsWith('pwsh.exe');
+
+ if (isPowerShell) {
+ return `'${arg.replace(/'/g, "''")}'`;
+ } else {
+ return `"${arg.replace(/"/g, '""')}"`;
+ }
+ } else {
+ return quote([arg]);
+ }
+}
const mockCheckCommandPermissions = vi.hoisted(() => vi.fn());
const mockShellExecute = vi.hoisted(() => vi.fn());
@@ -24,6 +45,15 @@ vi.mock('@google/gemini-cli-core', async (importOriginal) => {
};
});
+const SUCCESS_RESULT = {
+ stdout: 'default shell output',
+ stderr: '',
+ exitCode: 0,
+ error: null,
+ aborted: false,
+ signal: null,
+};
+
describe('ShellProcessor', () => {
let context: CommandContext;
let mockConfig: Partial<Config>;
@@ -36,6 +66,11 @@ describe('ShellProcessor', () => {
};
context = createMockCommandContext({
+ invocation: {
+ raw: '/cmd default args',
+ name: 'cmd',
+ args: 'default args',
+ },
services: {
config: mockConfig as Config,
},
@@ -45,16 +80,29 @@ describe('ShellProcessor', () => {
});
mockShellExecute.mockReturnValue({
- result: Promise.resolve({
- output: 'default shell output',
- }),
+ result: Promise.resolve(SUCCESS_RESULT),
});
+
mockCheckCommandPermissions.mockReturnValue({
allAllowed: true,
disallowedCommands: [],
});
});
+ it('should throw an error if config is missing', async () => {
+ const processor = new ShellProcessor('test-command');
+ const prompt = '!{ls}';
+ const contextWithoutConfig = createMockCommandContext({
+ services: {
+ config: null,
+ },
+ });
+
+ await expect(
+ processor.process(prompt, contextWithoutConfig),
+ ).rejects.toThrow(/Security configuration not loaded/);
+ });
+
it('should not change the prompt if no shell injections are present', async () => {
const processor = new ShellProcessor('test-command');
const prompt = 'This is a simple prompt with no injections.';
@@ -71,7 +119,7 @@ describe('ShellProcessor', () => {
disallowedCommands: [],
});
mockShellExecute.mockReturnValue({
- result: Promise.resolve({ output: 'On branch main' }),
+ result: Promise.resolve({ ...SUCCESS_RESULT, stdout: 'On branch main' }),
});
const result = await processor.process(prompt, context);
@@ -100,10 +148,13 @@ describe('ShellProcessor', () => {
mockShellExecute
.mockReturnValueOnce({
- result: Promise.resolve({ output: 'On branch main' }),
+ result: Promise.resolve({
+ ...SUCCESS_RESULT,
+ stdout: 'On branch main',
+ }),
})
.mockReturnValueOnce({
- result: Promise.resolve({ output: '/usr/home' }),
+ result: Promise.resolve({ ...SUCCESS_RESULT, stdout: '/usr/home' }),
});
const result = await processor.process(prompt, context);
@@ -226,8 +277,12 @@ describe('ShellProcessor', () => {
});
mockShellExecute
- .mockReturnValueOnce({ result: Promise.resolve({ output: 'output1' }) })
- .mockReturnValueOnce({ result: Promise.resolve({ output: 'output2' }) });
+ .mockReturnValueOnce({
+ result: Promise.resolve({ ...SUCCESS_RESULT, stdout: 'output1' }),
+ })
+ .mockReturnValueOnce({
+ result: Promise.resolve({ ...SUCCESS_RESULT, stdout: 'output2' }),
+ });
const result = await processor.process(prompt, context);
@@ -245,56 +300,406 @@ describe('ShellProcessor', () => {
expect(result).toBe('Run output1 and output2');
});
- it('should trim whitespace from the command inside the injection', async () => {
+ it('should trim whitespace from the command inside the injection before interpolation', async () => {
const processor = new ShellProcessor('test-command');
- const prompt = 'Files: !{ ls -l }';
+ const prompt = 'Files: !{ ls {{args}} -l }';
+
+ const rawArgs = context.invocation!.args;
+
+ const expectedEscapedArgs = getExpectedEscapedArgForPlatform(rawArgs);
+
+ const expectedCommand = `ls ${expectedEscapedArgs} -l`;
+
mockCheckCommandPermissions.mockReturnValue({
allAllowed: true,
disallowedCommands: [],
});
mockShellExecute.mockReturnValue({
- result: Promise.resolve({ output: 'total 0' }),
+ result: Promise.resolve({ ...SUCCESS_RESULT, stdout: 'total 0' }),
});
await processor.process(prompt, context);
expect(mockCheckCommandPermissions).toHaveBeenCalledWith(
- 'ls -l', // Verifies that the command was trimmed
+ expectedCommand,
expect.any(Object),
context.session.sessionShellAllowlist,
);
expect(mockShellExecute).toHaveBeenCalledWith(
- 'ls -l',
+ expectedCommand,
expect.any(String),
expect.any(Function),
expect.any(Object),
);
});
- it('should handle an empty command inside the injection gracefully', async () => {
+ it('should handle an empty command inside the injection gracefully (skips execution)', async () => {
const processor = new ShellProcessor('test-command');
const prompt = 'This is weird: !{}';
- mockCheckCommandPermissions.mockReturnValue({
- allAllowed: true,
- disallowedCommands: [],
+
+ const result = await processor.process(prompt, context);
+
+ expect(mockCheckCommandPermissions).not.toHaveBeenCalled();
+ expect(mockShellExecute).not.toHaveBeenCalled();
+
+ // It replaces !{} with an empty string.
+ expect(result).toBe('This is weird: ');
+ });
+
+ describe('Robust Parsing (Balanced Braces)', () => {
+ it('should correctly parse commands containing nested braces (e.g., awk)', async () => {
+ const processor = new ShellProcessor('test-command');
+ const command = "awk '{print $1}' file.txt";
+ const prompt = `Output: !{${command}}`;
+ mockShellExecute.mockReturnValue({
+ result: Promise.resolve({ ...SUCCESS_RESULT, stdout: 'result' }),
+ });
+
+ const result = await processor.process(prompt, context);
+
+ expect(mockCheckCommandPermissions).toHaveBeenCalledWith(
+ command,
+ expect.any(Object),
+ context.session.sessionShellAllowlist,
+ );
+ expect(mockShellExecute).toHaveBeenCalledWith(
+ command,
+ expect.any(String),
+ expect.any(Function),
+ expect.any(Object),
+ );
+ expect(result).toBe('Output: result');
});
- mockShellExecute.mockReturnValue({
- result: Promise.resolve({ output: 'empty output' }),
+
+ it('should handle deeply nested braces correctly', async () => {
+ const processor = new ShellProcessor('test-command');
+ const command = "echo '{{a},{b}}'";
+ const prompt = `!{${command}}`;
+ mockShellExecute.mockReturnValue({
+ result: Promise.resolve({ ...SUCCESS_RESULT, stdout: '{{a},{b}}' }),
+ });
+
+ const result = await processor.process(prompt, context);
+ expect(mockShellExecute).toHaveBeenCalledWith(
+ command,
+ expect.any(String),
+ expect.any(Function),
+ expect.any(Object),
+ );
+ expect(result).toBe('{{a},{b}}');
});
- const result = await processor.process(prompt, context);
+ it('should throw an error for unclosed shell injections', async () => {
+ const processor = new ShellProcessor('test-command');
+ const prompt = 'This prompt is broken: !{ls -l';
- expect(mockCheckCommandPermissions).toHaveBeenCalledWith(
- '',
- expect.any(Object),
- context.session.sessionShellAllowlist,
- );
- expect(mockShellExecute).toHaveBeenCalledWith(
- '',
- expect.any(String),
- expect.any(Function),
- expect.any(Object),
- );
- expect(result).toBe('This is weird: empty output');
+ await expect(processor.process(prompt, context)).rejects.toThrow(
+ /Unclosed shell injection/,
+ );
+ });
+
+ it('should throw an error for unclosed nested braces', async () => {
+ const processor = new ShellProcessor('test-command');
+ const prompt = 'Broken: !{echo {a}';
+
+ await expect(processor.process(prompt, context)).rejects.toThrow(
+ /Unclosed shell injection/,
+ );
+ });
+ });
+
+ describe('Error Reporting', () => {
+ it('should append stderr information if the command produces it', async () => {
+ const processor = new ShellProcessor('test-command');
+ const prompt = '!{cmd}';
+ mockShellExecute.mockReturnValue({
+ result: Promise.resolve({
+ ...SUCCESS_RESULT,
+ stdout: 'some output',
+ stderr: 'some error',
+ }),
+ });
+
+ const result = await processor.process(prompt, context);
+
+ expect(result).toBe('some output\n--- STDERR ---\nsome error');
+ });
+
+ it('should handle stderr-only output correctly', async () => {
+ const processor = new ShellProcessor('test-command');
+ const prompt = '!{cmd}';
+ mockShellExecute.mockReturnValue({
+ result: Promise.resolve({
+ ...SUCCESS_RESULT,
+ stdout: '',
+ stderr: 'error only',
+ }),
+ });
+
+ const result = await processor.process(prompt, context);
+
+ expect(result).toBe('--- STDERR ---\nerror only');
+ });
+
+ it('should append exit code and command name if the command fails', async () => {
+ const processor = new ShellProcessor('test-command');
+ const prompt = 'Run a failing command: !{exit 1}';
+ mockShellExecute.mockReturnValue({
+ result: Promise.resolve({
+ ...SUCCESS_RESULT,
+ stdout: 'some error output',
+ stderr: '',
+ exitCode: 1,
+ }),
+ });
+
+ const result = await processor.process(prompt, context);
+
+ expect(result).toBe(
+ "Run a failing command: some error output\n[Shell command 'exit 1' exited with code 1]",
+ );
+ });
+
+ it('should append signal info and command name if terminated by signal', async () => {
+ const processor = new ShellProcessor('test-command');
+ const prompt = '!{cmd}';
+ mockShellExecute.mockReturnValue({
+ result: Promise.resolve({
+ ...SUCCESS_RESULT,
+ stdout: 'output',
+ stderr: '',
+ exitCode: null,
+ signal: 'SIGTERM',
+ }),
+ });
+
+ const result = await processor.process(prompt, context);
+
+ expect(result).toBe(
+ "output\n[Shell command 'cmd' terminated by signal SIGTERM]",
+ );
+ });
+
+ it('should append stderr and exit code information correctly', async () => {
+ const processor = new ShellProcessor('test-command');
+ const prompt = '!{cmd}';
+ mockShellExecute.mockReturnValue({
+ result: Promise.resolve({
+ ...SUCCESS_RESULT,
+ stdout: 'out',
+ stderr: 'err',
+ exitCode: 127,
+ }),
+ });
+
+ const result = await processor.process(prompt, context);
+
+ expect(result).toBe(
+ "out\n--- STDERR ---\nerr\n[Shell command 'cmd' exited with code 127]",
+ );
+ });
+
+ it('should throw a detailed error if the shell fails to spawn', async () => {
+ const processor = new ShellProcessor('test-command');
+ const prompt = '!{bad-command}';
+ const spawnError = new Error('spawn EACCES');
+ mockShellExecute.mockReturnValue({
+ result: Promise.resolve({
+ ...SUCCESS_RESULT,
+ stdout: '',
+ stderr: '',
+ exitCode: null,
+ error: spawnError,
+ aborted: false,
+ }),
+ });
+
+ await expect(processor.process(prompt, context)).rejects.toThrow(
+ "Failed to start shell command in 'test-command': spawn EACCES. Command: bad-command",
+ );
+ });
+
+ it('should report abort status with command name if aborted', async () => {
+ const processor = new ShellProcessor('test-command');
+ const prompt = '!{long-running-command}';
+ const spawnError = new Error('Aborted');
+ mockShellExecute.mockReturnValue({
+ result: Promise.resolve({
+ ...SUCCESS_RESULT,
+ stdout: 'partial output',
+ stderr: '',
+ exitCode: null,
+ error: spawnError,
+ aborted: true, // Key difference
+ }),
+ });
+
+ const result = await processor.process(prompt, context);
+ expect(result).toBe(
+ "partial output\n[Shell command 'long-running-command' aborted]",
+ );
+ });
+ });
+
+ describe('Context-Aware Argument Interpolation ({{args}})', () => {
+ const rawArgs = 'user input';
+
+ beforeEach(() => {
+ // Update context for these tests to use specific arguments
+ context.invocation!.args = rawArgs;
+ });
+
+ it('should perform raw replacement if no shell injections are present (optimization path)', async () => {
+ const processor = new ShellProcessor('test-command');
+ const prompt = 'The user said: {{args}}';
+
+ const result = await processor.process(prompt, context);
+
+ expect(result).toBe(`The user said: ${rawArgs}`);
+ expect(mockShellExecute).not.toHaveBeenCalled();
+ });
+
+ it('should perform raw replacement outside !{} blocks', async () => {
+ const processor = new ShellProcessor('test-command');
+ const prompt = 'Outside: {{args}}. Inside: !{echo "hello"}';
+ mockShellExecute.mockReturnValue({
+ result: Promise.resolve({ ...SUCCESS_RESULT, stdout: 'hello' }),
+ });
+
+ const result = await processor.process(prompt, context);
+
+ expect(result).toBe(`Outside: ${rawArgs}. Inside: hello`);
+ });
+
+ it('should perform escaped replacement inside !{} blocks', async () => {
+ const processor = new ShellProcessor('test-command');
+ const prompt = 'Command: !{grep {{args}} file.txt}';
+ mockShellExecute.mockReturnValue({
+ result: Promise.resolve({ ...SUCCESS_RESULT, stdout: 'match found' }),
+ });
+
+ const result = await processor.process(prompt, context);
+
+ const expectedEscapedArgs = getExpectedEscapedArgForPlatform(rawArgs);
+ const expectedCommand = `grep ${expectedEscapedArgs} file.txt`;
+
+ expect(mockShellExecute).toHaveBeenCalledWith(
+ expectedCommand,
+ expect.any(String),
+ expect.any(Function),
+ expect.any(Object),
+ );
+
+ expect(result).toBe('Command: match found');
+ });
+
+ it('should handle both raw (outside) and escaped (inside) injection simultaneously', async () => {
+ const processor = new ShellProcessor('test-command');
+ const prompt = 'User "({{args}})" requested search: !{search {{args}}}';
+ mockShellExecute.mockReturnValue({
+ result: Promise.resolve({ ...SUCCESS_RESULT, stdout: 'results' }),
+ });
+
+ const result = await processor.process(prompt, context);
+
+ const expectedEscapedArgs = getExpectedEscapedArgForPlatform(rawArgs);
+ const expectedCommand = `search ${expectedEscapedArgs}`;
+ expect(mockShellExecute).toHaveBeenCalledWith(
+ expectedCommand,
+ expect.any(String),
+ expect.any(Function),
+ expect.any(Object),
+ );
+
+ expect(result).toBe(`User "(${rawArgs})" requested search: results`);
+ });
+
+ it('should perform security checks on the final, resolved (escaped) command', async () => {
+ const processor = new ShellProcessor('test-command');
+ const prompt = '!{rm {{args}}}';
+
+ const expectedEscapedArgs = getExpectedEscapedArgForPlatform(rawArgs);
+ const expectedResolvedCommand = `rm ${expectedEscapedArgs}`;
+ mockCheckCommandPermissions.mockReturnValue({
+ allAllowed: false,
+ disallowedCommands: [expectedResolvedCommand],
+ isHardDenial: false,
+ });
+
+ await expect(processor.process(prompt, context)).rejects.toThrow(
+ ConfirmationRequiredError,
+ );
+
+ expect(mockCheckCommandPermissions).toHaveBeenCalledWith(
+ expectedResolvedCommand,
+ expect.any(Object),
+ context.session.sessionShellAllowlist,
+ );
+ });
+
+ it('should report the resolved command if a hard denial occurs', async () => {
+ const processor = new ShellProcessor('test-command');
+ const prompt = '!{rm {{args}}}';
+ const expectedEscapedArgs = getExpectedEscapedArgForPlatform(rawArgs);
+ const expectedResolvedCommand = `rm ${expectedEscapedArgs}`;
+ mockCheckCommandPermissions.mockReturnValue({
+ allAllowed: false,
+ disallowedCommands: [expectedResolvedCommand],
+ isHardDenial: true,
+ blockReason: 'It is forbidden.',
+ });
+
+ await expect(processor.process(prompt, context)).rejects.toThrow(
+ `Blocked command: "${expectedResolvedCommand}". Reason: It is forbidden.`,
+ );
+ });
+ });
+ describe('Real-World Escaping Scenarios', () => {
+ it('should correctly handle multiline arguments', async () => {
+ const processor = new ShellProcessor('test-command');
+ const multilineArgs = 'first line\nsecond line';
+ context.invocation!.args = multilineArgs;
+ const prompt = 'Commit message: !{git commit -m {{args}}}';
+
+ const expectedEscapedArgs =
+ getExpectedEscapedArgForPlatform(multilineArgs);
+ const expectedCommand = `git commit -m ${expectedEscapedArgs}`;
+
+ await processor.process(prompt, context);
+
+ expect(mockShellExecute).toHaveBeenCalledWith(
+ expectedCommand,
+ expect.any(String),
+ expect.any(Function),
+ expect.any(Object),
+ );
+ });
+
+ it.each([
+ { name: 'spaces', input: 'file with spaces.txt' },
+ { name: 'double quotes', input: 'a "quoted" string' },
+ { name: 'single quotes', input: "it's a string" },
+ { name: 'command substitution (backticks)', input: '`reboot`' },
+ { name: 'command substitution (dollar)', input: '$(reboot)' },
+ { name: 'variable expansion', input: '$HOME' },
+ { name: 'command chaining (semicolon)', input: 'a; reboot' },
+ { name: 'command chaining (ampersand)', input: 'a && reboot' },
+ ])('should safely escape args containing $name', async ({ input }) => {
+ const processor = new ShellProcessor('test-command');
+ context.invocation!.args = input;
+ const prompt = '!{echo {{args}}}';
+
+ const expectedEscapedArgs = getExpectedEscapedArgForPlatform(input);
+ const expectedCommand = `echo ${expectedEscapedArgs}`;
+
+ await processor.process(prompt, context);
+
+ expect(mockShellExecute).toHaveBeenCalledWith(
+ expectedCommand,
+ expect.any(String),
+ expect.any(Function),
+ expect.any(Object),
+ );
+ });
});
});
diff --git a/packages/cli/src/services/prompt-processors/shellProcessor.ts b/packages/cli/src/services/prompt-processors/shellProcessor.ts
index bf811d66..9531d57e 100644
--- a/packages/cli/src/services/prompt-processors/shellProcessor.ts
+++ b/packages/cli/src/services/prompt-processors/shellProcessor.ts
@@ -6,11 +6,17 @@
import {
checkCommandPermissions,
+ escapeShellArg,
+ getShellConfiguration,
ShellExecutionService,
} from '@google/gemini-cli-core';
import { CommandContext } from '../../ui/commands/types.js';
-import { IPromptProcessor } from './types.js';
+import {
+ IPromptProcessor,
+ SHELL_INJECTION_TRIGGER,
+ SHORTHAND_ARGS_PLACEHOLDER,
+} from './types.js';
export class ConfirmationRequiredError extends Error {
constructor(
@@ -23,60 +29,89 @@ export class ConfirmationRequiredError extends Error {
}
/**
- * Finds all instances of shell command injections (`!{...}`) in a prompt,
- * executes them, and replaces the injection site with the command's output.
+ * Represents a single detected shell injection site in the prompt.
+ */
+interface ShellInjection {
+ /** The shell command extracted from within !{...}, trimmed. */
+ command: string;
+ /** The starting index of the injection (inclusive, points to '!'). */
+ startIndex: number;
+ /** The ending index of the injection (exclusive, points after '}'). */
+ endIndex: number;
+ /** The command after {{args}} has been escaped and substituted. */
+ resolvedCommand?: string;
+}
+
+/**
+ * Handles prompt interpolation, including shell command execution (`!{...}`)
+ * and context-aware argument injection (`{{args}}`).
*
- * This processor ensures that only allowlisted commands are executed. If a
- * disallowed command is found, it halts execution and reports an error.
+ * This processor ensures that:
+ * 1. `{{args}}` outside `!{...}` are replaced with raw input.
+ * 2. `{{args}}` inside `!{...}` are replaced with shell-escaped input.
+ * 3. Shell commands are executed securely after argument substitution.
+ * 4. Parsing correctly handles nested braces.
*/
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 userArgsRaw = context.invocation?.args || '';
- const matches = [...prompt.matchAll(ShellProcessor.SHELL_INJECTION_REGEX)];
- if (matches.length === 0) {
- return prompt; // No shell commands, nothing to do.
+ if (!prompt.includes(SHELL_INJECTION_TRIGGER)) {
+ return prompt.replaceAll(SHORTHAND_ARGS_PLACEHOLDER, userArgsRaw);
}
- // Discover all commands and check permissions.
- for (const match of matches) {
- const command = match[1].trim();
+ const config = context.services.config;
+ if (!config) {
+ throw new Error(
+ `Security configuration not loaded. Cannot verify shell command permissions for '${this.commandName}'. Aborting.`,
+ );
+ }
+ const { sessionShellAllowlist } = context.session;
+
+ const injections = this.extractInjections(prompt);
+ // If extractInjections found no closed blocks (and didn't throw), treat as raw.
+ if (injections.length === 0) {
+ return prompt.replaceAll(SHORTHAND_ARGS_PLACEHOLDER, userArgsRaw);
+ }
+
+ const { shell } = getShellConfiguration();
+ const userArgsEscaped = escapeShellArg(userArgsRaw, shell);
+
+ const resolvedInjections = injections.map((injection) => {
+ if (injection.command === '') {
+ return injection;
+ }
+ // Replace {{args}} inside the command string with the escaped version.
+ const resolvedCommand = injection.command.replaceAll(
+ SHORTHAND_ARGS_PLACEHOLDER,
+ userArgsEscaped,
+ );
+ return { ...injection, resolvedCommand };
+ });
+
+ const commandsToConfirm = new Set<string>();
+ for (const injection of resolvedInjections) {
+ const command = injection.resolvedCommand;
+
+ if (!command) continue;
+
+ // Security check on the final, escaped command string.
const { allAllowed, disallowedCommands, blockReason, isHardDenial } =
- checkCommandPermissions(command, config!, sessionShellAllowlist);
+ 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.'}`,
+ `${this.commandName} cannot be run. Blocked command: "${command}". Reason: ${blockReason || 'Blocked by configuration.'}`,
);
}
-
- // 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.
+ // Handle confirmation requirements.
if (commandsToConfirm.size > 0) {
throw new ConfirmationRequiredError(
'Shell command confirmation required',
@@ -84,23 +119,132 @@ export class ShellProcessor implements IPromptProcessor {
);
}
- // 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.
- );
+ let processedPrompt = '';
+ let lastIndex = 0;
- const executionResult = await result;
- processedPrompt = processedPrompt.replace(
- fullMatch,
- executionResult.output,
+ for (const injection of resolvedInjections) {
+ // Append the text segment BEFORE the injection, substituting {{args}} with RAW input.
+ const segment = prompt.substring(lastIndex, injection.startIndex);
+ processedPrompt += segment.replaceAll(
+ SHORTHAND_ARGS_PLACEHOLDER,
+ userArgsRaw,
);
+
+ // Execute the resolved command (which already has ESCAPED input).
+ if (injection.resolvedCommand) {
+ const { result } = ShellExecutionService.execute(
+ injection.resolvedCommand,
+ config.getTargetDir(),
+ () => {},
+ new AbortController().signal,
+ );
+
+ const executionResult = await result;
+
+ // Handle Spawn Errors
+ if (executionResult.error && !executionResult.aborted) {
+ throw new Error(
+ `Failed to start shell command in '${this.commandName}': ${executionResult.error.message}. Command: ${injection.resolvedCommand}`,
+ );
+ }
+
+ // Append the output, making stderr explicit for the model.
+ if (executionResult.stdout) {
+ processedPrompt += executionResult.stdout;
+ }
+ if (executionResult.stderr) {
+ if (executionResult.stdout) {
+ processedPrompt += '\n';
+ }
+ processedPrompt += `--- STDERR ---\n${executionResult.stderr}`;
+ }
+
+ // Append a status message if the command did not succeed.
+ if (executionResult.aborted) {
+ processedPrompt += `\n[Shell command '${injection.resolvedCommand}' aborted]`;
+ } else if (
+ executionResult.exitCode !== 0 &&
+ executionResult.exitCode !== null
+ ) {
+ processedPrompt += `\n[Shell command '${injection.resolvedCommand}' exited with code ${executionResult.exitCode}]`;
+ } else if (executionResult.signal !== null) {
+ processedPrompt += `\n[Shell command '${injection.resolvedCommand}' terminated by signal ${executionResult.signal}]`;
+ }
+ }
+
+ lastIndex = injection.endIndex;
}
+ // Append the remaining text AFTER the last injection, substituting {{args}} with RAW input.
+ const finalSegment = prompt.substring(lastIndex);
+ processedPrompt += finalSegment.replaceAll(
+ SHORTHAND_ARGS_PLACEHOLDER,
+ userArgsRaw,
+ );
+
return processedPrompt;
}
+
+ /**
+ * Iteratively parses the prompt string to extract shell injections (!{...}),
+ * correctly handling nested braces within the command.
+ *
+ * @param prompt The prompt string to parse.
+ * @returns An array of extracted ShellInjection objects.
+ * @throws Error if an unclosed injection (`!{`) is found.
+ */
+ private extractInjections(prompt: string): ShellInjection[] {
+ const injections: ShellInjection[] = [];
+ let index = 0;
+
+ while (index < prompt.length) {
+ const startIndex = prompt.indexOf(SHELL_INJECTION_TRIGGER, index);
+
+ if (startIndex === -1) {
+ break;
+ }
+
+ let currentIndex = startIndex + SHELL_INJECTION_TRIGGER.length;
+ let braceCount = 1;
+ let foundEnd = false;
+
+ while (currentIndex < prompt.length) {
+ const char = prompt[currentIndex];
+
+ // We count literal braces. This parser does not interpret shell quoting/escaping.
+ if (char === '{') {
+ braceCount++;
+ } else if (char === '}') {
+ braceCount--;
+ if (braceCount === 0) {
+ const commandContent = prompt.substring(
+ startIndex + SHELL_INJECTION_TRIGGER.length,
+ currentIndex,
+ );
+ const endIndex = currentIndex + 1;
+
+ injections.push({
+ command: commandContent.trim(),
+ startIndex,
+ endIndex,
+ });
+
+ index = endIndex;
+ foundEnd = true;
+ break;
+ }
+ }
+ currentIndex++;
+ }
+
+ // Check if the inner loop finished without finding the closing brace.
+ if (!foundEnd) {
+ throw new Error(
+ `Invalid syntax in command '${this.commandName}': Unclosed shell injection starting at index ${startIndex} ('!{'). Ensure braces are balanced.`,
+ );
+ }
+ }
+
+ return injections;
+ }
}
diff --git a/packages/cli/src/services/prompt-processors/types.ts b/packages/cli/src/services/prompt-processors/types.ts
index 2653d2b7..956bb432 100644
--- a/packages/cli/src/services/prompt-processors/types.ts
+++ b/packages/cli/src/services/prompt-processors/types.ts
@@ -33,6 +33,8 @@ export interface IPromptProcessor {
/**
* The placeholder string for shorthand argument injection in custom commands.
+ * When used outside of !{...}, arguments are injected raw.
+ * When used inside !{...}, arguments are shell-escaped.
*/
export const SHORTHAND_ARGS_PLACEHOLDER = '{{args}}';