feat(cli): Introduce arguments for shell execution in custom commands (#5966)
This commit is contained in:
parent
e7dbc607a5
commit
33b9bdb11e
|
@ -169,23 +169,52 @@ Your command definition files must be written in the TOML format and use the `.t
|
|||
|
||||
#### Handling Arguments
|
||||
|
||||
Custom commands support two powerful, low-friction methods for handling arguments. The CLI automatically chooses the correct method based on the content of your command's `prompt`.
|
||||
Custom commands support two powerful methods for handling arguments. The CLI automatically chooses the correct method based on the content of your command's `prompt`.
|
||||
|
||||
##### 1. Shorthand Injection with `{{args}}`
|
||||
##### 1. Context-Aware Injection with `{{args}}`
|
||||
|
||||
If your `prompt` contains the special placeholder `{{args}}`, the CLI will replace that exact placeholder with all the text the user typed after the command name. This is perfect for simple, deterministic commands where you need to inject user input into a specific place in a larger prompt template.
|
||||
If your `prompt` contains the special placeholder `{{args}}`, the CLI will replace that placeholder with the text the user typed after the command name.
|
||||
|
||||
The behavior of this injection depends on where it is used:
|
||||
|
||||
**A. Raw Injection (Outside Shell Commands)**
|
||||
|
||||
When used in the main body of the prompt, the arguments are injected exactly as the user typed them.
|
||||
|
||||
**Example (`git/fix.toml`):**
|
||||
|
||||
```toml
|
||||
# In: ~/.gemini/commands/git/fix.toml
|
||||
# Invoked via: /git:fix "Button is misaligned on mobile"
|
||||
# Invoked via: /git:fix "Button is misaligned"
|
||||
|
||||
description = "Generates a fix for a given GitHub issue."
|
||||
prompt = "Please analyze the staged git changes and provide a code fix for the issue described here: {{args}}."
|
||||
description = "Generates a fix for a given issue."
|
||||
prompt = "Please provide a code fix for the issue described here: {{args}}."
|
||||
```
|
||||
|
||||
The model will receive the final prompt: `Please analyze the staged git changes and provide a code fix for the issue described here: "Button is misaligned on mobile".`
|
||||
The model receives: `Please provide a code fix for the issue described here: "Button is misaligned".`
|
||||
|
||||
**B. Using Arguments in Shell Commands (Inside `!{...}` Blocks)**
|
||||
|
||||
When you use `{{args}}` inside a shell injection block (`!{...}`), the arguments are automatically **shell-escaped** before replacement. This allows you to safely pass arguments to shell commands, ensuring the resulting command is syntactically correct and secure while preventing command injection vulnerabilities.
|
||||
|
||||
**Example (`/grep-code.toml`):**
|
||||
|
||||
```toml
|
||||
prompt = """
|
||||
Please summarize the findings for the pattern `{{args}}`.
|
||||
|
||||
Search Results:
|
||||
!{grep -r {{args}} .}
|
||||
"""
|
||||
```
|
||||
|
||||
When you run `/grep-code It's complicated`:
|
||||
|
||||
1. The CLI sees `{{args}}` used both outside and inside `!{...}`.
|
||||
2. Outside: The first `{{args}}` is replaced raw with `It's complicated`.
|
||||
3. Inside: The second `{{args}}` is replaced with the escaped version (e.g., on Linux: `"It's complicated"`).
|
||||
4. The command executed is `grep -r "It's complicated" .`.
|
||||
5. The CLI prompts you to confirm this exact, secure command before execution.
|
||||
6. The final prompt is sent.
|
||||
|
||||
##### 2. Default Argument Handling
|
||||
|
||||
|
@ -236,14 +265,11 @@ When a custom command attempts to execute a shell command, Gemini CLI will now p
|
|||
|
||||
**How It Works:**
|
||||
|
||||
1. **Inject Commands:** Use the `!{...}` syntax in your `prompt` to specify where the command should be run and its output injected.
|
||||
2. **Confirm Execution:** When you run the command, a dialog will appear listing the shell commands the prompt wants to execute.
|
||||
3. **Grant Permission:** You can choose to:
|
||||
- **Allow once:** The command(s) will run this one time.
|
||||
- **Allow always for this session:** The command(s) will be added to a temporary allowlist for the current CLI session and will not require confirmation again.
|
||||
- **No:** Cancel the execution of the shell command(s).
|
||||
|
||||
The CLI still respects the global `excludeTools` and `coreTools` settings. A command will be blocked without a confirmation prompt if it is explicitly disallowed in your configuration.
|
||||
1. **Inject Commands:** Use the `!{...}` syntax.
|
||||
2. **Argument Substitution:** If `{{args}}` is present inside the block, it is automatically shell-escaped (see [Context-Aware Injection](#1-context-aware-injection-with-args) above).
|
||||
3. **Robust Parsing:** The parser correctly handles complex shell commands that include nested braces, such as JSON payloads.
|
||||
4. **Security Check and Confirmation:** The CLI performs a security check on the final, resolved command (after arguments are escaped and substituted). A dialog will appear showing the exact command(s) to be executed.
|
||||
5. **Execution and Error Reporting:** The command is executed. If the command fails, the output injected into the prompt will include the error messages (stderr) followed by a status line, e.g., `[Shell command exited with code 1]`. This helps the model understand the context of the failure.
|
||||
|
||||
**Example (`git/commit.toml`):**
|
||||
|
||||
|
|
|
@ -0,0 +1,122 @@
|
|||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeAll } from 'vitest';
|
||||
import { ShellExecutionService } from '../packages/core/src/services/shellExecutionService.js';
|
||||
import * as fs from 'fs/promises';
|
||||
import * as path from 'path';
|
||||
import { vi } from 'vitest';
|
||||
|
||||
describe('ShellExecutionService programmatic integration tests', () => {
|
||||
let testDir: string;
|
||||
|
||||
beforeAll(async () => {
|
||||
// Create a dedicated directory for this test suite to avoid conflicts.
|
||||
testDir = path.join(
|
||||
process.env.INTEGRATION_TEST_FILE_DIR!,
|
||||
'shell-service-tests',
|
||||
);
|
||||
await fs.mkdir(testDir, { recursive: true });
|
||||
});
|
||||
|
||||
it('should execute a simple cross-platform command (echo)', async () => {
|
||||
const command = 'echo "hello from the service"';
|
||||
const onOutputEvent = vi.fn();
|
||||
const abortController = new AbortController();
|
||||
|
||||
const handle = ShellExecutionService.execute(
|
||||
command,
|
||||
testDir,
|
||||
onOutputEvent,
|
||||
abortController.signal,
|
||||
);
|
||||
|
||||
const result = await handle.result;
|
||||
|
||||
expect(result.error).toBeNull();
|
||||
expect(result.exitCode).toBe(0);
|
||||
// Output can vary slightly between shells (e.g., quotes), so check for inclusion.
|
||||
expect(result.output).toContain('hello from the service');
|
||||
});
|
||||
|
||||
it.runIf(process.platform === 'win32')(
|
||||
'should execute "dir" on Windows',
|
||||
async () => {
|
||||
const testFile = 'test-file-windows.txt';
|
||||
await fs.writeFile(path.join(testDir, testFile), 'windows test');
|
||||
|
||||
const command = 'dir';
|
||||
const onOutputEvent = vi.fn();
|
||||
const abortController = new AbortController();
|
||||
|
||||
const handle = ShellExecutionService.execute(
|
||||
command,
|
||||
testDir,
|
||||
onOutputEvent,
|
||||
abortController.signal,
|
||||
);
|
||||
|
||||
const result = await handle.result;
|
||||
|
||||
expect(result.error).toBeNull();
|
||||
expect(result.exitCode).toBe(0);
|
||||
expect(result.output).toContain(testFile);
|
||||
},
|
||||
);
|
||||
|
||||
it.skipIf(process.platform === 'win32')(
|
||||
'should execute "ls -l" on Unix',
|
||||
async () => {
|
||||
const testFile = 'test-file-unix.txt';
|
||||
await fs.writeFile(path.join(testDir, testFile), 'unix test');
|
||||
|
||||
const command = 'ls -l';
|
||||
const onOutputEvent = vi.fn();
|
||||
const abortController = new AbortController();
|
||||
|
||||
const handle = ShellExecutionService.execute(
|
||||
command,
|
||||
testDir,
|
||||
onOutputEvent,
|
||||
abortController.signal,
|
||||
);
|
||||
|
||||
const result = await handle.result;
|
||||
|
||||
expect(result.error).toBeNull();
|
||||
expect(result.exitCode).toBe(0);
|
||||
expect(result.output).toContain(testFile);
|
||||
},
|
||||
);
|
||||
|
||||
it('should abort a running process', async () => {
|
||||
// A command that runs for a bit. 'sleep' on unix, 'timeout' on windows.
|
||||
const command = process.platform === 'win32' ? 'timeout /t 20' : 'sleep 20';
|
||||
const onOutputEvent = vi.fn();
|
||||
const abortController = new AbortController();
|
||||
|
||||
const handle = ShellExecutionService.execute(
|
||||
command,
|
||||
testDir,
|
||||
onOutputEvent,
|
||||
abortController.signal,
|
||||
);
|
||||
|
||||
// Abort shortly after starting
|
||||
setTimeout(() => abortController.abort(), 50);
|
||||
|
||||
const result = await handle.result;
|
||||
|
||||
// For debugging the flaky test.
|
||||
console.log('Abort test result:', result);
|
||||
|
||||
expect(result.aborted).toBe(true);
|
||||
// A clean exit is exitCode 0 and no signal. If the process was truly
|
||||
// aborted, it should not have exited cleanly.
|
||||
const exitedCleanly = result.exitCode === 0 && result.signal === null;
|
||||
expect(exitedCleanly, 'Process should not have exited cleanly').toBe(false);
|
||||
});
|
||||
});
|
|
@ -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');
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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());
|
||||
}
|
||||
|
||||
|
|
|
@ -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();
|
||||
|
||||
|
|
|
@ -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> {
|
||||
|
|
|
@ -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: [],
|
||||
});
|
||||
mockShellExecute.mockReturnValue({
|
||||
result: Promise.resolve({ output: 'empty output' }),
|
||||
});
|
||||
|
||||
const result = await processor.process(prompt, context);
|
||||
|
||||
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');
|
||||
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');
|
||||
});
|
||||
|
||||
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}}');
|
||||
});
|
||||
|
||||
it('should throw an error for unclosed shell injections', async () => {
|
||||
const processor = new ShellProcessor('test-command');
|
||||
const prompt = 'This prompt is broken: !{ls -l';
|
||||
|
||||
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),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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;
|
||||
|
||||
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,
|
||||
);
|
||||
|
||||
const executionResult = await result;
|
||||
processedPrompt = processedPrompt.replace(
|
||||
fullMatch,
|
||||
executionResult.output,
|
||||
);
|
||||
// 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;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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}}';
|
||||
|
||||
|
|
|
@ -10,6 +10,16 @@ vi.mock('child_process', () => ({
|
|||
spawn: mockSpawn,
|
||||
}));
|
||||
|
||||
const mockGetShellConfiguration = vi.hoisted(() => vi.fn());
|
||||
let mockIsWindows = false;
|
||||
|
||||
vi.mock('../utils/shell-utils.js', () => ({
|
||||
getShellConfiguration: mockGetShellConfiguration,
|
||||
get isWindows() {
|
||||
return mockIsWindows;
|
||||
},
|
||||
}));
|
||||
|
||||
import EventEmitter from 'events';
|
||||
import { Readable } from 'stream';
|
||||
import { type ChildProcess } from 'child_process';
|
||||
|
@ -43,18 +53,21 @@ describe('ShellExecutionService', () => {
|
|||
vi.clearAllMocks();
|
||||
|
||||
mockIsBinary.mockReturnValue(false);
|
||||
mockPlatform.mockReturnValue('linux');
|
||||
|
||||
mockGetShellConfiguration.mockReturnValue({
|
||||
executable: 'bash',
|
||||
argsPrefix: ['-c'],
|
||||
});
|
||||
mockIsWindows = false;
|
||||
|
||||
onOutputEventMock = vi.fn();
|
||||
|
||||
mockChildProcess = new EventEmitter() as EventEmitter &
|
||||
Partial<ChildProcess>;
|
||||
// FIX: Cast simple EventEmitters to the expected stream type.
|
||||
mockChildProcess.stdout = new EventEmitter() as Readable;
|
||||
mockChildProcess.stderr = new EventEmitter() as Readable;
|
||||
mockChildProcess.kill = vi.fn();
|
||||
|
||||
// FIX: Use Object.defineProperty to set the readonly 'pid' property.
|
||||
Object.defineProperty(mockChildProcess, 'pid', {
|
||||
value: 12345,
|
||||
configurable: true,
|
||||
|
|
|
@ -4,24 +4,47 @@
|
|||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { expect, describe, it, beforeEach } from 'vitest';
|
||||
import { expect, describe, it, beforeEach, vi, afterEach } from 'vitest';
|
||||
import {
|
||||
checkCommandPermissions,
|
||||
escapeShellArg,
|
||||
getCommandRoots,
|
||||
getShellConfiguration,
|
||||
isCommandAllowed,
|
||||
stripShellWrapper,
|
||||
} from './shell-utils.js';
|
||||
import { Config } from '../config/config.js';
|
||||
|
||||
const mockPlatform = vi.hoisted(() => vi.fn());
|
||||
vi.mock('os', () => ({
|
||||
default: {
|
||||
platform: mockPlatform,
|
||||
},
|
||||
platform: mockPlatform,
|
||||
}));
|
||||
|
||||
const mockQuote = vi.hoisted(() => vi.fn());
|
||||
vi.mock('shell-quote', () => ({
|
||||
quote: mockQuote,
|
||||
}));
|
||||
|
||||
let config: Config;
|
||||
|
||||
beforeEach(() => {
|
||||
mockPlatform.mockReturnValue('linux');
|
||||
mockQuote.mockImplementation((args: string[]) =>
|
||||
args.map((arg) => `'${arg}'`).join(' '),
|
||||
);
|
||||
config = {
|
||||
getCoreTools: () => [],
|
||||
getExcludeTools: () => [],
|
||||
} as unknown as Config;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('isCommandAllowed', () => {
|
||||
it('should allow a command if no restrictions are provided', () => {
|
||||
const result = isCommandAllowed('ls -l', config);
|
||||
|
@ -277,3 +300,135 @@ describe('stripShellWrapper', () => {
|
|||
expect(stripShellWrapper('ls -l')).toEqual('ls -l');
|
||||
});
|
||||
});
|
||||
|
||||
describe('escapeShellArg', () => {
|
||||
describe('POSIX (bash)', () => {
|
||||
it('should use shell-quote for escaping', () => {
|
||||
mockQuote.mockReturnValueOnce("'escaped value'");
|
||||
const result = escapeShellArg('raw value', 'bash');
|
||||
expect(mockQuote).toHaveBeenCalledWith(['raw value']);
|
||||
expect(result).toBe("'escaped value'");
|
||||
});
|
||||
|
||||
it('should handle empty strings', () => {
|
||||
const result = escapeShellArg('', 'bash');
|
||||
expect(result).toBe('');
|
||||
expect(mockQuote).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Windows', () => {
|
||||
describe('when shell is cmd.exe', () => {
|
||||
it('should wrap simple arguments in double quotes', () => {
|
||||
const result = escapeShellArg('search term', 'cmd');
|
||||
expect(result).toBe('"search term"');
|
||||
});
|
||||
|
||||
it('should escape internal double quotes by doubling them', () => {
|
||||
const result = escapeShellArg('He said "Hello"', 'cmd');
|
||||
expect(result).toBe('"He said ""Hello"""');
|
||||
});
|
||||
|
||||
it('should handle empty strings', () => {
|
||||
const result = escapeShellArg('', 'cmd');
|
||||
expect(result).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
describe('when shell is PowerShell', () => {
|
||||
it('should wrap simple arguments in single quotes', () => {
|
||||
const result = escapeShellArg('search term', 'powershell');
|
||||
expect(result).toBe("'search term'");
|
||||
});
|
||||
|
||||
it('should escape internal single quotes by doubling them', () => {
|
||||
const result = escapeShellArg("It's a test", 'powershell');
|
||||
expect(result).toBe("'It''s a test'");
|
||||
});
|
||||
|
||||
it('should handle double quotes without escaping them', () => {
|
||||
const result = escapeShellArg('He said "Hello"', 'powershell');
|
||||
expect(result).toBe('\'He said "Hello"\'');
|
||||
});
|
||||
|
||||
it('should handle empty strings', () => {
|
||||
const result = escapeShellArg('', 'powershell');
|
||||
expect(result).toBe('');
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('getShellConfiguration', () => {
|
||||
const originalEnv = { ...process.env };
|
||||
|
||||
afterEach(() => {
|
||||
process.env = originalEnv;
|
||||
});
|
||||
|
||||
it('should return bash configuration on Linux', () => {
|
||||
mockPlatform.mockReturnValue('linux');
|
||||
const config = getShellConfiguration();
|
||||
expect(config.executable).toBe('bash');
|
||||
expect(config.argsPrefix).toEqual(['-c']);
|
||||
expect(config.shell).toBe('bash');
|
||||
});
|
||||
|
||||
it('should return bash configuration on macOS (darwin)', () => {
|
||||
mockPlatform.mockReturnValue('darwin');
|
||||
const config = getShellConfiguration();
|
||||
expect(config.executable).toBe('bash');
|
||||
expect(config.argsPrefix).toEqual(['-c']);
|
||||
expect(config.shell).toBe('bash');
|
||||
});
|
||||
|
||||
describe('on Windows', () => {
|
||||
beforeEach(() => {
|
||||
mockPlatform.mockReturnValue('win32');
|
||||
});
|
||||
|
||||
it('should return cmd.exe configuration by default', () => {
|
||||
delete process.env.ComSpec;
|
||||
const config = getShellConfiguration();
|
||||
expect(config.executable).toBe('cmd.exe');
|
||||
expect(config.argsPrefix).toEqual(['/d', '/s', '/c']);
|
||||
expect(config.shell).toBe('cmd');
|
||||
});
|
||||
|
||||
it('should respect ComSpec for cmd.exe', () => {
|
||||
const cmdPath = 'C:\\WINDOWS\\system32\\cmd.exe';
|
||||
process.env.ComSpec = cmdPath;
|
||||
const config = getShellConfiguration();
|
||||
expect(config.executable).toBe(cmdPath);
|
||||
expect(config.argsPrefix).toEqual(['/d', '/s', '/c']);
|
||||
expect(config.shell).toBe('cmd');
|
||||
});
|
||||
|
||||
it('should return PowerShell configuration if ComSpec points to powershell.exe', () => {
|
||||
const psPath =
|
||||
'C:\\WINDOWS\\System32\\WindowsPowerShell\\v1.0\\powershell.exe';
|
||||
process.env.ComSpec = psPath;
|
||||
const config = getShellConfiguration();
|
||||
expect(config.executable).toBe(psPath);
|
||||
expect(config.argsPrefix).toEqual(['-NoProfile', '-Command']);
|
||||
expect(config.shell).toBe('powershell');
|
||||
});
|
||||
|
||||
it('should return PowerShell configuration if ComSpec points to pwsh.exe', () => {
|
||||
const pwshPath = 'C:\\Program Files\\PowerShell\\7\\pwsh.exe';
|
||||
process.env.ComSpec = pwshPath;
|
||||
const config = getShellConfiguration();
|
||||
expect(config.executable).toBe(pwshPath);
|
||||
expect(config.argsPrefix).toEqual(['-NoProfile', '-Command']);
|
||||
expect(config.shell).toBe('powershell');
|
||||
});
|
||||
|
||||
it('should be case-insensitive when checking ComSpec', () => {
|
||||
process.env.ComSpec = 'C:\\Path\\To\\POWERSHELL.EXE';
|
||||
const config = getShellConfiguration();
|
||||
expect(config.executable).toBe('C:\\Path\\To\\POWERSHELL.EXE');
|
||||
expect(config.argsPrefix).toEqual(['-NoProfile', '-Command']);
|
||||
expect(config.shell).toBe('powershell');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -5,6 +5,102 @@
|
|||
*/
|
||||
|
||||
import { Config } from '../config/config.js';
|
||||
import os from 'os';
|
||||
import { quote } from 'shell-quote';
|
||||
|
||||
/**
|
||||
* An identifier for the shell type.
|
||||
*/
|
||||
export type ShellType = 'cmd' | 'powershell' | 'bash';
|
||||
|
||||
/**
|
||||
* Defines the configuration required to execute a command string within a specific shell.
|
||||
*/
|
||||
export interface ShellConfiguration {
|
||||
/** The path or name of the shell executable (e.g., 'bash', 'cmd.exe'). */
|
||||
executable: string;
|
||||
/**
|
||||
* The arguments required by the shell to execute a subsequent string argument.
|
||||
*/
|
||||
argsPrefix: string[];
|
||||
/** An identifier for the shell type. */
|
||||
shell: ShellType;
|
||||
}
|
||||
|
||||
/**
|
||||
* Determines the appropriate shell configuration for the current platform.
|
||||
*
|
||||
* This ensures we can execute command strings predictably and securely across platforms
|
||||
* using the `spawn(executable, [...argsPrefix, commandString], { shell: false })` pattern.
|
||||
*
|
||||
* @returns The ShellConfiguration for the current environment.
|
||||
*/
|
||||
export function getShellConfiguration(): ShellConfiguration {
|
||||
if (isWindows()) {
|
||||
const comSpec = process.env.ComSpec || 'cmd.exe';
|
||||
const executable = comSpec.toLowerCase();
|
||||
|
||||
if (
|
||||
executable.endsWith('powershell.exe') ||
|
||||
executable.endsWith('pwsh.exe')
|
||||
) {
|
||||
// For PowerShell, the arguments are different.
|
||||
// -NoProfile: Speeds up startup.
|
||||
// -Command: Executes the following command.
|
||||
return {
|
||||
executable: comSpec,
|
||||
argsPrefix: ['-NoProfile', '-Command'],
|
||||
shell: 'powershell',
|
||||
};
|
||||
}
|
||||
|
||||
// Default to cmd.exe for anything else on Windows.
|
||||
// Flags for CMD:
|
||||
// /d: Skip execution of AutoRun commands.
|
||||
// /s: Modifies the treatment of the command string (important for quoting).
|
||||
// /c: Carries out the command specified by the string and then terminates.
|
||||
return {
|
||||
executable: comSpec,
|
||||
argsPrefix: ['/d', '/s', '/c'],
|
||||
shell: 'cmd',
|
||||
};
|
||||
}
|
||||
|
||||
// Unix-like systems (Linux, macOS)
|
||||
return { executable: 'bash', argsPrefix: ['-c'], shell: 'bash' };
|
||||
}
|
||||
|
||||
/**
|
||||
* Export the platform detection constant for use in process management (e.g., killing processes).
|
||||
*/
|
||||
export const isWindows = () => os.platform() === 'win32';
|
||||
|
||||
/**
|
||||
* Escapes a string so that it can be safely used as a single argument
|
||||
* in a shell command, preventing command injection.
|
||||
*
|
||||
* @param arg The argument string to escape.
|
||||
* @param shell The type of shell the argument is for.
|
||||
* @returns The shell-escaped string.
|
||||
*/
|
||||
export function escapeShellArg(arg: string, shell: ShellType): string {
|
||||
if (!arg) {
|
||||
return '';
|
||||
}
|
||||
|
||||
switch (shell) {
|
||||
case 'powershell':
|
||||
// For PowerShell, wrap in single quotes and escape internal single quotes by doubling them.
|
||||
return `'${arg.replace(/'/g, "''")}'`;
|
||||
case 'cmd':
|
||||
// Simple Windows escaping for cmd.exe: wrap in double quotes and escape inner double quotes.
|
||||
return `"${arg.replace(/"/g, '""')}"`;
|
||||
case 'bash':
|
||||
default:
|
||||
// POSIX shell escaping using shell-quote.
|
||||
return quote([arg]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Splits a shell command into a list of individual commands, respecting quotes.
|
||||
|
|
Loading…
Reference in New Issue