From 44d8db20c836089385bc0d3553f0ce867e5214f1 Mon Sep 17 00:00:00 2001 From: Christian Gunderman Date: Mon, 13 Apr 2026 19:23:50 -0700 Subject: [PATCH] feat(core): use shell for file operations under sandboxing When `security.toolSandboxing` is enabled, the CLI now excludes the lower-fidelity tools (`grep_search`, `replace`, `write_file`, `read_file`) from the main agent. Instead, it relies on `run_shell_command` (e.g. `sed`, `grep`, `cat`, `echo >`) to perform these actions. To maintain UX and telemetry parity, `run_shell_command` now infers common file operations. When detected: - The UI title is updated to a high-fidelity display (e.g. "Shell (Read File)", "Shell (Replace)"). - File editing/writing commands (like `sed -i` or `echo >`) generate a predicted diff view for the user during confirmation. - The execution emits the standard `FileOperationEvent` telemetry using the canonical tool names, ensuring metrics consistency. --- integration-tests/shell-parity.test.ts | 108 ++++++++++++ packages/cli/src/ui/hooks/toolMapping.ts | 2 +- packages/core/src/agents/local-executor.ts | 4 +- packages/core/src/core/geminiChat.ts | 8 +- packages/core/src/tools/shell.ts | 158 ++++++++++++++++- packages/core/src/tools/tool-registry.test.ts | 32 ++++ packages/core/src/tools/tool-registry.ts | 13 ++ packages/core/src/utils/shell-utils.test.ts | 40 +++++ packages/core/src/utils/shell-utils.ts | 165 ++++++++++++++++++ 9 files changed, 523 insertions(+), 7 deletions(-) create mode 100644 integration-tests/shell-parity.test.ts diff --git a/integration-tests/shell-parity.test.ts b/integration-tests/shell-parity.test.ts new file mode 100644 index 0000000000..7bc16d68df --- /dev/null +++ b/integration-tests/shell-parity.test.ts @@ -0,0 +1,108 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { + TestRig, +} from './test-helper.js'; + +describe('shell-parity', () => { + let rig: TestRig; + + beforeEach(() => { + rig = new TestRig(); + }); + + afterEach(async () => await rig.cleanup()); + + it('should use run_shell_command for replace when sandboxing is enabled', async () => { + await rig.setup('should use run_shell_command for replace when sandboxing is enabled', { + settings: { + security: { toolSandboxing: true }, + }, + }); + rig.createFile('test.ts', 'const foo = "bar";'); + + // We expect the model to use run_shell_command because edit/replace/write_file are filtered out. + const result = await rig.run({ + args: `Replace "bar" with "baz" in test.ts`, + }); + + // Verify forbidden tools were NOT used + const forbiddenTools = ['grep_search', 'replace', 'write_file', 'edit', 'read_file']; + const toolLogs = rig.readToolLogs(); + const usedForbidden = toolLogs.filter((t) => + forbiddenTools.includes(t.toolRequest.name), + ); + expect(usedForbidden).toHaveLength(0); + + // Verify run_shell_command was used + const shellCall = await rig.waitForToolCall('run_shell_command'); + expect(shellCall).toBeTruthy(); + + // Verify the command looks like a replace operation + const command = shellCall!.args.command as string; + // It should contain some form of replacement (sed, perl, or powershell -replace) + expect(command).toMatch(/sed|replace|Set-Content|perl/i); + + // Verify file content changed + const content = rig.readFile('test.ts'); + expect(content).toContain('baz'); + expect(content).not.toContain('"bar"'); + }); + + it('should use run_shell_command for search when sandboxing is enabled', async () => { + await rig.setup('should use run_shell_command for search when sandboxing is enabled', { + settings: { + security: { toolSandboxing: true }, + }, + }); + rig.createFile('search-me.txt', 'target-string'); + + await rig.run({ + args: `Search for "target-string" in search-me.txt`, + }); + + // Verify grep_search was NOT used + const toolLogs = rig.readToolLogs(); + const usedGrep = toolLogs.filter((t) => t.toolRequest.name === 'grep_search'); + expect(usedGrep).toHaveLength(0); + + // Verify run_shell_command was used + const shellCall = await rig.waitForToolCall('run_shell_command'); + expect(shellCall).toBeTruthy(); + + const command = shellCall!.args.command as string; + expect(command).toMatch(/grep|rg|ripgrep|Select-String|findstr/i); + }); + + it('should use run_shell_command for read when sandboxing is enabled', async () => { + await rig.setup('should use run_shell_command for read when sandboxing is enabled', { + settings: { + security: { toolSandboxing: true }, + }, + }); + rig.createFile('read-me.txt', 'hello world'); + + const result = await rig.run({ + args: `Read the file read-me.txt and tell me what it says`, + }); + + // Verify read_file was NOT used + const toolLogs = rig.readToolLogs(); + const usedRead = toolLogs.filter((t) => t.toolRequest.name === 'read_file'); + expect(usedRead).toHaveLength(0); + + // Verify run_shell_command was used + const shellCall = await rig.waitForToolCall('run_shell_command'); + expect(shellCall).toBeTruthy(); + + const command = shellCall!.args.command as string; + expect(command).toMatch(/cat|head|tail|less|more|Get-Content|type/i); + + expect(result).toContain('hello world'); + }); +}); diff --git a/packages/cli/src/ui/hooks/toolMapping.ts b/packages/cli/src/ui/hooks/toolMapping.ts index a23b5c3d96..901b89e44a 100644 --- a/packages/cli/src/ui/hooks/toolMapping.ts +++ b/packages/cli/src/ui/hooks/toolMapping.ts @@ -42,7 +42,7 @@ export function mapToDisplay( if (call.status === CoreToolCallStatus.Error) { description = JSON.stringify(call.request.args); } else { - description = call.invocation.getDescription(); + description = typeof call.invocation.getDisplayTitle === 'function' ? call.invocation.getDisplayTitle() : call.invocation.getDescription(); renderOutputAsMarkdown = call.tool.isOutputMarkdown; } diff --git a/packages/core/src/agents/local-executor.ts b/packages/core/src/agents/local-executor.ts index c9e4341f03..bfd8973cdf 100644 --- a/packages/core/src/agents/local-executor.ts +++ b/packages/core/src/agents/local-executor.ts @@ -1024,7 +1024,9 @@ export class LocalAgentExecutor { if (tool) { displayName = tool.displayName ?? toolName; const invocation = tool.build(args); - description = invocation.getDescription(); + // Prefer getDisplayTitle if it differs, otherwise fallback to getDescription. + // This ensures the timeline ("Shell (Replace)") matches the confirmation dialog. + description = typeof invocation.getDisplayTitle === 'function' ? invocation.getDisplayTitle() : invocation.getDescription(); } } catch { // Ignore errors during formatting for activity emission diff --git a/packages/core/src/core/geminiChat.ts b/packages/core/src/core/geminiChat.ts index 00ff64a398..eb95b61ea5 100644 --- a/packages/core/src/core/geminiChat.ts +++ b/packages/core/src/core/geminiChat.ts @@ -1030,6 +1030,11 @@ export class GeminiChat { ? resultDisplayRaw : undefined; + let description: string | undefined = undefined; + if ('invocation' in call && call.invocation) { + description = typeof call.invocation.getDisplayTitle === 'function' ? call.invocation.getDisplayTitle() : call.invocation.getDescription(); + } + return { id: call.request.callId, name: call.request.originalRequestName ?? call.request.name, @@ -1038,8 +1043,7 @@ export class GeminiChat { status: call.status, timestamp: new Date().toISOString(), resultDisplay, - description: - 'invocation' in call ? call.invocation?.getDescription() : undefined, + description, }; }); diff --git a/packages/core/src/tools/shell.ts b/packages/core/src/tools/shell.ts index 63a9b1dc83..96d6114066 100644 --- a/packages/core/src/tools/shell.ts +++ b/packages/core/src/tools/shell.ts @@ -12,6 +12,7 @@ import crypto from 'node:crypto'; import { debugLogger } from '../index.js'; import type { SandboxPermissions } from '../services/sandboxManager.js'; import { ToolErrorType } from './tool-error.js'; +import * as Diff from 'diff'; import { BaseDeclarativeTool, BaseToolInvocation, @@ -22,6 +23,7 @@ import { type BackgroundExecutionData, type ToolCallConfirmationDetails, type ToolExecuteConfirmationDetails, + type ToolEditConfirmationDetails, type PolicyUpdateOptions, type ToolLiveOutput, type ExecuteOptions, @@ -41,8 +43,19 @@ import { stripShellWrapper, parseCommandDetails, hasRedirection, + inferFileOperation, + FileOperationType, } from '../utils/shell-utils.js'; -import { SHELL_TOOL_NAME } from './tool-names.js'; +import { logFileOperation } from '../telemetry/loggers.js'; +import { FileOperationEvent } from '../telemetry/types.js'; +import { FileOperation } from '../telemetry/metrics.js'; +import { + SHELL_TOOL_NAME, + EDIT_TOOL_NAME, + WRITE_FILE_TOOL_NAME, + GREP_TOOL_NAME, + READ_FILE_TOOL_NAME, +} from './tool-names.js'; import { PARAM_ADDITIONAL_PERMISSIONS } from './definitions/base-declarations.js'; import type { MessageBus } from '../confirmation-bus/message-bus.js'; import { getShellDefinition } from './definitions/coreTools.js'; @@ -127,6 +140,19 @@ export class ShellToolInvocation extends BaseToolInvocation< } override getDisplayTitle(): string { + const inferred = inferFileOperation(this.params.command); + if (inferred) { + switch (inferred.type) { + case FileOperationType.SEARCH: + return `Shell (Search)`; + case FileOperationType.EDIT: + return `Shell (Replace)`; + case FileOperationType.WRITE: + return `Shell (Write File)`; + case FileOperationType.READ: + return `Shell (Read File)`; + } + } return this.params.command; } @@ -162,10 +188,96 @@ export class ShellToolInvocation extends BaseToolInvocation< return super.shouldConfirmExecute(abortSignal); } + private simulateSed( + content: string, + sedExpression: string, + ): string | undefined { + const match = sedExpression.match(/^s\/(.+)\/(.+)\/([g]*)?$/); + if (!match) return undefined; + const [_, oldStr, newStr, flags] = match; + try { + const regex = new RegExp(oldStr, flags?.includes('g') ? 'g' : ''); + return content.replace(regex, newStr); + } catch { + return undefined; + } + } + + private simulatePsReplace( + content: string, + oldString: string, + newString: string, + ): string { + try { + // PowerShell -replace is regex-based and case-insensitive by default. + const regex = new RegExp(oldString, 'gi'); + return content.replace(regex, newString); + } catch { + return content; + } + } + protected override async getConfirmationDetails( - _abortSignal: AbortSignal, + abortSignal: AbortSignal, ): Promise { const command = stripShellWrapper(this.params.command); + const inferred = inferFileOperation(this.params.command); + + if ( + inferred && + (inferred.type === FileOperationType.EDIT || + inferred.type === FileOperationType.WRITE) + ) { + const filePath = path.resolve( + this.context.config.getTargetDir(), + inferred.filePath, + ); + let originalContent: string | null = null; + try { + originalContent = await fsPromises.readFile(filePath, 'utf-8'); + } catch { + // file might not exist yet if it's a WRITE + } + + let newContent: string | undefined; + if ( + inferred.type === FileOperationType.EDIT && + originalContent !== null + ) { + if (inferred.metadata?.['sedExpression']) { + newContent = this.simulateSed( + originalContent, + inferred.metadata['sedExpression'] as string, + ); + } else if ( + inferred.metadata?.['oldString'] && + inferred.metadata?.['newString'] + ) { + newContent = this.simulatePsReplace( + originalContent, + inferred.metadata['oldString'] as string, + inferred.metadata['newString'] as string, + ); + } + } + + if (newContent !== undefined && originalContent !== null) { + const fileDiff = Diff.createPatch(filePath, originalContent, newContent); + const editDetails: ToolEditConfirmationDetails = { + type: 'edit', + title: this.getDisplayTitle(), + fileName: path.basename(filePath), + filePath: inferred.filePath, + fileDiff, + originalContent, + newContent, + onConfirm: async (_outcome: ToolConfirmationOutcome) => { + // Policy updates are now handled centrally by the scheduler + }, + }; + return editDetails; + } + } const parsed = parseCommandDetails(command); let rootCommandDisplay = ''; @@ -216,7 +328,7 @@ export class ShellToolInvocation extends BaseToolInvocation< const confirmationDetails: ToolExecuteConfirmationDetails = { type: 'exec', - title: 'Confirm Shell Command', + title: this.getDisplayTitle(), command: this.params.command, rootCommand: rootCommandDisplay, rootCommands, @@ -377,6 +489,46 @@ export class ShellToolInvocation extends BaseToolInvocation< const result = await resultPromise; + // Telemetry parity + const inferred = inferFileOperation(this.params.command); + if (inferred && result.exitCode === 0) { + let operation: FileOperation | undefined; + let canonicalToolName = SHELL_TOOL_NAME; + switch (inferred.type) { + case FileOperationType.SEARCH: + operation = FileOperation.READ; + canonicalToolName = GREP_TOOL_NAME; + break; + case FileOperationType.READ: + operation = FileOperation.READ; + canonicalToolName = READ_FILE_TOOL_NAME; + break; + case FileOperationType.EDIT: + operation = FileOperation.UPDATE; + canonicalToolName = EDIT_TOOL_NAME; + break; + case FileOperationType.WRITE: + operation = FileOperation.UPDATE; + canonicalToolName = WRITE_FILE_TOOL_NAME; + break; + } + + if (operation) { + const extension = path.extname(inferred.filePath); + logFileOperation( + this.context.config, + new FileOperationEvent( + canonicalToolName, + operation, + 0, // Lines changed not easily available + '', // mimetype + extension, + '', // programming language + ), + ); + } + } + const backgroundPIDs: number[] = []; if (os.platform() !== 'win32') { let tempFileExists = false; diff --git a/packages/core/src/tools/tool-registry.test.ts b/packages/core/src/tools/tool-registry.test.ts index 3d27171ad1..5387db9c61 100644 --- a/packages/core/src/tools/tool-registry.test.ts +++ b/packages/core/src/tools/tool-registry.test.ts @@ -22,6 +22,11 @@ import { ToolRegistry, DiscoveredTool } from './tool-registry.js'; import { DISCOVERED_TOOL_PREFIX, UPDATE_TOPIC_TOOL_NAME, + GREP_TOOL_NAME, + EDIT_TOOL_NAME, + WRITE_FILE_TOOL_NAME, + READ_FILE_TOOL_NAME, + SHELL_TOOL_NAME, } from './tool-names.js'; import { DiscoveredMCPTool } from './mcp-tool.js'; import { @@ -733,6 +738,33 @@ describe('ToolRegistry', () => { expect(declarations).toHaveLength(1); expect(declarations[0].name).toBe(`mcp_${serverName}_${toolName}`); }); + + it('should exclude lower fidelity tools when sandboxing is enabled for the main agent', () => { + vi.spyOn(config, 'getSandboxEnabled').mockReturnValue(true); + (toolRegistry as any).isMainRegistry = true; + + // Register the tools that should be excluded + const grepTool = new MockTool({ name: GREP_TOOL_NAME }); + const editTool = new MockTool({ name: EDIT_TOOL_NAME }); + const writeTool = new MockTool({ name: WRITE_FILE_TOOL_NAME }); + const readTool = new MockTool({ name: READ_FILE_TOOL_NAME }); + const shellTool = new MockTool({ name: SHELL_TOOL_NAME }); + + toolRegistry.registerTool(grepTool); + toolRegistry.registerTool(editTool); + toolRegistry.registerTool(writeTool); + toolRegistry.registerTool(readTool); + toolRegistry.registerTool(shellTool); + + const declarations = toolRegistry.getFunctionDeclarations(); + const names = declarations.map((d) => d.name); + + expect(names).not.toContain(GREP_TOOL_NAME); + expect(names).not.toContain(EDIT_TOOL_NAME); + expect(names).not.toContain(WRITE_FILE_TOOL_NAME); + expect(names).not.toContain(READ_FILE_TOOL_NAME); + expect(names).toContain(SHELL_TOOL_NAME); + }); }); describe('plan mode', () => { diff --git a/packages/core/src/tools/tool-registry.ts b/packages/core/src/tools/tool-registry.ts index a059c964d0..76433fc62a 100644 --- a/packages/core/src/tools/tool-registry.ts +++ b/packages/core/src/tools/tool-registry.ts @@ -30,6 +30,8 @@ import { getToolAliases, WRITE_FILE_TOOL_NAME, EDIT_TOOL_NAME, + GREP_TOOL_NAME, + READ_FILE_TOOL_NAME, UPDATE_TOPIC_TOOL_NAME, } from './tool-names.js'; @@ -635,6 +637,17 @@ export class ToolRegistry { return; } + if ( + this.isMainRegistry && + this.config.getSandboxEnabled() && + (toolName === GREP_TOOL_NAME || + toolName === EDIT_TOOL_NAME || + toolName === WRITE_FILE_TOOL_NAME || + toolName === READ_FILE_TOOL_NAME) + ) { + return; + } + seenNames.add(toolName); let schema = tool.getSchema(modelId); diff --git a/packages/core/src/utils/shell-utils.test.ts b/packages/core/src/utils/shell-utils.test.ts index 2370aa25c4..ff79f60c3a 100644 --- a/packages/core/src/utils/shell-utils.test.ts +++ b/packages/core/src/utils/shell-utils.test.ts @@ -23,6 +23,8 @@ import { stripShellWrapper, hasRedirection, resolveExecutable, + inferFileOperation, + FileOperationType, } from './shell-utils.js'; import path from 'node:path'; @@ -603,3 +605,41 @@ describe('resolveExecutable', () => { expect(await resolveExecutable('unknown')).toBeUndefined(); }); }); + + describe('inferFileOperation', () => { + it('should infer sed -i as an EDIT operation', () => { + mockPlatform.mockReturnValue('linux'); + const result = inferFileOperation("sed -i 's/foo/bar/g' test.ts"); + expect(result).toBeDefined(); + expect(result?.type).toBe(FileOperationType.EDIT); + expect(result?.filePath).toBe('test.ts'); + expect(result?.metadata?.['sedExpression']).toBe('s/foo/bar/g'); + }); + + it('should infer echo redirection as a WRITE operation', () => { + mockPlatform.mockReturnValue('linux'); + const result = inferFileOperation("echo 'hello' > hello.txt"); + expect(result).toBeDefined(); + expect(result?.type).toBe(FileOperationType.WRITE); + expect(result?.filePath).toBe('hello.txt'); + }); + + it('should infer grep as a SEARCH operation', () => { + mockPlatform.mockReturnValue('linux'); + const result = inferFileOperation("grep 'pattern' file.txt"); + expect(result).toBeDefined(); + expect(result?.type).toBe(FileOperationType.SEARCH); + expect(result?.filePath).toBe('file.txt'); + expect(result?.metadata?.['pattern']).toBe('pattern'); + }); + + it('should infer PowerShell -replace as an EDIT operation', () => { + mockPlatform.mockReturnValue('win32'); + const result = inferFileOperation("(Get-Content file.txt) -replace 'a', 'b' | Set-Content file.txt"); + expect(result).toBeDefined(); + expect(result?.type).toBe(FileOperationType.EDIT); + expect(result?.filePath).toBe('file.txt'); + expect(result?.metadata?.['oldString']).toBe('a'); + expect(result?.metadata?.['newString']).toBe('b'); + }); + }); diff --git a/packages/core/src/utils/shell-utils.ts b/packages/core/src/utils/shell-utils.ts index e2a240a0b0..56ef2bd9c2 100644 --- a/packages/core/src/utils/shell-utils.ts +++ b/packages/core/src/utils/shell-utils.ts @@ -623,6 +623,20 @@ export function getShellConfiguration(): ShellConfiguration { */ export const isWindows = () => os.platform() === 'win32'; +export enum FileOperationType { + SEARCH = 'SEARCH', + EDIT = 'EDIT', + WRITE = 'WRITE', + READ = 'READ', +} + +export interface InferredFileOperation { + type: FileOperationType; + filePath: string; + /** Inferred details (e.g., the sed expression or replacement string) */ + metadata?: Record; +} + /** * Escapes a string so that it can be safely used as a single argument * in a shell command, preventing command injection. @@ -751,6 +765,157 @@ export function getCommandRoots(command: string): string[] { .filter(Boolean); } +/** + * Attempts to infer if a shell command is performing a common file operation + * like search, edit, or write. + */ +export function inferFileOperation( + command: string, +): InferredFileOperation | undefined { + const stripped = stripShellWrapper(command); + const shellType = getShellConfiguration().shell; + + if (shellType === 'bash') { + // sed -i 's/foo/bar/g' file + // Handle both -i and --in-place, and optional space after -i + const sedMatch = stripped.match( + /^sed\s+(?:-i|--in-place)(?:\s*['"]?\.?['"]?)?\s+['"]?([^'"]+)['"]?\s+['"]?([^'"]+)['"]?\s*$/, + ); + if (sedMatch) { + return { + type: FileOperationType.EDIT, + filePath: sedMatch[2].trim(), + metadata: { sedExpression: sedMatch[1] }, + }; + } + + // echo "content" > file + // Simple redirection check + const redirectionMatch = stripped.match( + /(?:^|&&|\|\||;)\s*(?:echo|printf)\s+.*?\s*>\s*(\S+)\s*$/, + ); + if (redirectionMatch) { + return { + type: FileOperationType.WRITE, + filePath: redirectionMatch[1].trim(), + }; + } + + // cat > file <\s*(\S+)\s*<<\s*(\S+)/, + ); + if (heredocMatch) { + return { + type: FileOperationType.WRITE, + filePath: heredocMatch[1].trim(), + }; + } + + // cp src dest + const cpMatch = stripped.match( + /^(?:cp)\s+(?:-[^ ]+\s+)*['"]?([^'"]+)['"]?\s+['"]?([^'"]+)['"]?\s*$/, + ); + if (cpMatch) { + return { + type: FileOperationType.WRITE, + filePath: cpMatch[2].trim(), + }; + } + + // mv src dest + const mvMatch = stripped.match( + /^(?:mv)\s+(?:-[^ ]+\s+)*['"]?([^'"]+)['"]?\s+['"]?([^'"]+)['"]?\s*$/, + ); + if (mvMatch) { + return { + type: FileOperationType.WRITE, + filePath: mvMatch[2].trim(), + }; + } + + // ... | tee file + const teeMatch = stripped.match(/\|\s*tee\s+(?:-a\s+)?['"]?([^'"]+)['"]?\s*$/); + if (teeMatch) { + return { + type: FileOperationType.WRITE, + filePath: teeMatch[1].trim(), + }; + } + + // grep "pattern" file + const grepMatch = stripped.match( + /^(?:grep|rg|ripgrep)\s+(?:-[^ ]+\s+)*['"]?([^'"]+)['"]?\s+['"]?([^'"]+)['"]?\s*$/, + ); + if (grepMatch) { + return { + type: FileOperationType.SEARCH, + filePath: grepMatch[2].trim(), + metadata: { pattern: grepMatch[1] }, + }; + } + + // cat file, head file, tail file + const readMatch = stripped.match( + /^(?:cat|head|tail|less|more)\s+(?:-[^ ]+\s+)*['"]?([^'"]+)['"]?\s*$/, + ); + if (readMatch) { + return { + type: FileOperationType.READ, + filePath: readMatch[1].trim(), + }; + } + } else if (shellType === 'powershell') { + // (Get-Content file) -replace 'a', 'b' | Set-Content file + const psReplaceMatch = stripped.match( + /\(Get-Content\s+['"]?([^'"]+)['"]?\)\s+-replace\s+['"]?([^'"]+)['"]?,\s*['"]?([^'"]+)['"]?\s*\|\s*Set-Content\s+['"]?([^'"]+)['"]?/i, + ); + if (psReplaceMatch) { + return { + type: FileOperationType.EDIT, + filePath: psReplaceMatch[1].trim(), + metadata: { oldString: psReplaceMatch[2], newString: psReplaceMatch[3] }, + }; + } + + // Set-Content -Path file -Value "..." + const psSetContentMatch = stripped.match( + /Set-Content\s+-(?:Path|LiteralPath)\s+['"]?([^'"]+)['"]?/i, + ); + if (psSetContentMatch) { + return { + type: FileOperationType.WRITE, + filePath: psSetContentMatch[1].trim(), + }; + } + + // Select-String -Path file -Pattern "..." + const psSearchMatch = stripped.match( + /Select-String\s+-(?:Path|LiteralPath)\s+['"]?([^'"]+)['"]?\s+-Pattern\s+['"]?([^'"]+)['"]?/i, + ); + if (psSearchMatch) { + return { + type: FileOperationType.SEARCH, + filePath: psSearchMatch[1].trim(), + metadata: { pattern: psSearchMatch[2] }, + }; + } + + // Get-Content file, type file, cat file + const psReadMatch = stripped.match( + /^(?:Get-Content|type|cat)\s+(?:-(?:Path|LiteralPath|Tail|Head|TotalCount|Wait)\s+[^ ]+\s+)*['"]?([^'"]+)['"]?\s*$/i, + ); + if (psReadMatch) { + return { + type: FileOperationType.READ, + filePath: psReadMatch[1].trim(), + }; + } + } + + return undefined; +} + export function stripShellWrapper(command: string): string { const pattern = /^\s*(?:(?:(?:\S+\/)?(?:sh|bash|zsh))\s+-c|cmd\.exe\s+\/c|powershell(?:\.exe)?\s+(?:-NoProfile\s+)?-Command|pwsh(?:\.exe)?\s+(?:-NoProfile\s+)?-Command)\s+/i;