mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-07-23 08:10:57 -07:00
Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| e2fe41ed18 | |||
| 19430fdeca | |||
| e2b69dc267 | |||
| 6621bd88c8 | |||
| 44d8db20c8 |
@@ -0,0 +1,123 @@
|
||||
/**
|
||||
* @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.
|
||||
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');
|
||||
});
|
||||
});
|
||||
@@ -534,7 +534,6 @@ export const mockAppState: AppState = {
|
||||
};
|
||||
|
||||
const mockUIActions: UIActions = {
|
||||
toggleAlternateBuffer: vi.fn(),
|
||||
handleThemeSelect: vi.fn(),
|
||||
closeThemeDialog: vi.fn(),
|
||||
handleThemeHighlight: vi.fn(),
|
||||
|
||||
@@ -68,10 +68,8 @@ import {
|
||||
writeToStdout,
|
||||
disableMouseEvents,
|
||||
enterAlternateScreen,
|
||||
exitAlternateScreen,
|
||||
enableMouseEvents,
|
||||
disableLineWrapping,
|
||||
enableLineWrapping,
|
||||
shouldEnterAlternateScreen,
|
||||
startupProfiler,
|
||||
SessionStartSource,
|
||||
@@ -215,7 +213,7 @@ export const AppContainer = (props: AppContainerProps) => {
|
||||
});
|
||||
|
||||
useMemoryMonitor(historyManager);
|
||||
const [isAlternateBuffer, setIsAlternateBuffer] = useState(config.getUseAlternateBuffer());
|
||||
const isAlternateBuffer = config.getUseAlternateBuffer();
|
||||
const [corgiMode, setCorgiMode] = useState(false);
|
||||
const [forceRerenderKey, setForceRerenderKey] = useState(0);
|
||||
const [debugMessage, setDebugMessage] = useState<string>('');
|
||||
@@ -1552,23 +1550,6 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
type: TransientMessageType;
|
||||
}>(WARNING_PROMPT_DURATION_MS);
|
||||
|
||||
const [shownBufferToggleHint, setShownBufferToggleHint] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (isAlternateBuffer) return;
|
||||
|
||||
const isLongHistory = historyManager.history.length > 15;
|
||||
const isComplexPrompt = buffer.text.length > 200 || buffer.text.includes('\n');
|
||||
|
||||
if ((isLongHistory || isComplexPrompt) && !shownBufferToggleHint) {
|
||||
showTransientMessage({
|
||||
text: 'Tip: Press Alt+T to toggle full-screen mode for better scrolling/editing',
|
||||
type: TransientMessageType.Hint
|
||||
});
|
||||
setShownBufferToggleHint(true);
|
||||
}
|
||||
}, [historyManager.history.length, buffer.text, isAlternateBuffer, shownBufferToggleHint, showTransientMessage]);
|
||||
|
||||
const {
|
||||
isFolderTrustDialogOpen,
|
||||
discoveryResults: folderDiscoveryResults,
|
||||
@@ -1719,11 +1700,6 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
return true;
|
||||
}
|
||||
|
||||
if (keyMatchers[Command.TOGGLE_BUFFER_MODE](key)) {
|
||||
toggleAlternateBuffer();
|
||||
return true;
|
||||
}
|
||||
|
||||
if (keyMatchers[Command.QUIT](key)) {
|
||||
// If the user presses Ctrl+C, we want to cancel any ongoing requests.
|
||||
// This should happen regardless of the count.
|
||||
@@ -2228,11 +2204,8 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
};
|
||||
}, [config, refreshStatic]);
|
||||
|
||||
const showIsAlternateBufferHint = (historyManager.history.length > 15 || buffer.text.length > 200 || buffer.text.includes('\n')) && !isAlternateBuffer;
|
||||
|
||||
const uiState: UIState = useMemo(
|
||||
() => ({
|
||||
isAlternateBuffer,
|
||||
history: historyManager.history,
|
||||
historyManager,
|
||||
isThemeDialogOpen,
|
||||
@@ -2358,7 +2331,6 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
adminSettingsChanged,
|
||||
newAgents,
|
||||
showIsExpandableHint,
|
||||
showIsAlternateBufferHint,
|
||||
hintMode:
|
||||
config.isModelSteeringEnabled() && isToolExecuting(pendingHistoryItems),
|
||||
hintBuffer: '',
|
||||
@@ -2485,8 +2457,6 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
adminSettingsChanged,
|
||||
newAgents,
|
||||
showIsExpandableHint,
|
||||
showIsAlternateBufferHint,
|
||||
isAlternateBuffer,
|
||||
],
|
||||
);
|
||||
|
||||
@@ -2495,31 +2465,6 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
[setShowPrivacyNotice],
|
||||
);
|
||||
|
||||
const toggleAlternateBuffer = useCallback(() => {
|
||||
setIsAlternateBuffer(prev => {
|
||||
const next = !prev;
|
||||
if (next) {
|
||||
enterAlternateScreen();
|
||||
enableMouseEvents();
|
||||
disableLineWrapping();
|
||||
} else {
|
||||
exitAlternateScreen();
|
||||
disableMouseEvents();
|
||||
enableLineWrapping();
|
||||
writeToStdout('\x1b[2J\x1b[H');
|
||||
}
|
||||
process.stdout.emit('resize');
|
||||
|
||||
// Give a tick for resize to process, then trigger remount to force full redraw
|
||||
setImmediate(() => {
|
||||
refreshStatic();
|
||||
setForceRerenderKey((prev) => prev + 1);
|
||||
});
|
||||
|
||||
return next;
|
||||
});
|
||||
}, [setIsAlternateBuffer, refreshStatic, setForceRerenderKey]);
|
||||
|
||||
const uiActions: UIActions = useMemo(
|
||||
() => ({
|
||||
handleThemeSelect,
|
||||
@@ -2531,7 +2476,6 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
handleEditorSelect,
|
||||
exitEditorDialog,
|
||||
exitPrivacyNotice,
|
||||
toggleAlternateBuffer,
|
||||
closeSettingsDialog,
|
||||
closeModelDialog,
|
||||
openAgentConfigDialog,
|
||||
@@ -2670,7 +2614,6 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
config,
|
||||
historyManager,
|
||||
getPreferredEditor,
|
||||
toggleAlternateBuffer,
|
||||
],
|
||||
);
|
||||
|
||||
|
||||
@@ -142,38 +142,4 @@ describe('<StatusRow />', () => {
|
||||
await waitUntilReady();
|
||||
expect(lastFrame()).toContain('Tip: Test Tip');
|
||||
});
|
||||
|
||||
it('renders buffer toggle hint when showIsAlternateBufferHint is true', async () => {
|
||||
(useComposerStatus as Mock).mockReturnValue({
|
||||
isInteractiveShellWaiting: false,
|
||||
showLoadingIndicator: false,
|
||||
showTips: false,
|
||||
showWit: false,
|
||||
modeContentObj: null,
|
||||
showMinimalContext: false,
|
||||
});
|
||||
|
||||
const uiState: Partial<UIState> = {
|
||||
...defaultUiState,
|
||||
showIsAlternateBufferHint: true,
|
||||
};
|
||||
|
||||
const { lastFrame, waitUntilReady } = await renderWithProviders(
|
||||
<StatusRow
|
||||
showUiDetails={false}
|
||||
isNarrow={false}
|
||||
terminalWidth={100}
|
||||
hideContextSummary={false}
|
||||
hideUiDetailsForSuggestions={false}
|
||||
hasPendingActionRequired={false}
|
||||
/>,
|
||||
{
|
||||
width: 100,
|
||||
uiState,
|
||||
},
|
||||
);
|
||||
|
||||
await waitUntilReady();
|
||||
expect(lastFrame()).toContain('[Alt+T] Switch to Full Screen');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -206,12 +206,7 @@ export const StatusRow: React.FC<StatusRowProps> = ({
|
||||
return uiState.currentTip;
|
||||
}
|
||||
|
||||
// 2. Buffer Toggle Hint
|
||||
if (uiState.showIsAlternateBufferHint) {
|
||||
return '[Alt+T] Switch to Full Screen';
|
||||
}
|
||||
|
||||
// 3. Shortcut Hint (Fallback)
|
||||
// 2. Shortcut Hint (Fallback)
|
||||
if (
|
||||
settings.merged.ui.showShortcutsHint &&
|
||||
!hideUiDetailsForSuggestions &&
|
||||
|
||||
@@ -6,6 +6,12 @@ Narrow Container
|
||||
"
|
||||
`;
|
||||
|
||||
exports[`<SectionHeader /> > 'renders correctly in a narrow contain…' 2`] = `
|
||||
"─────────────────────────
|
||||
Narrow Container
|
||||
"
|
||||
`;
|
||||
|
||||
exports[`<SectionHeader /> > 'renders correctly when title is trunc…' 1`] = `
|
||||
"────────────────────
|
||||
Very Long Header Ti…
|
||||
|
||||
@@ -205,8 +205,6 @@ const MAC_ALT_KEY_CHARACTER_MAP: Record<string, string> = {
|
||||
'\u03A9': 'z', // "Ω" Option+z
|
||||
'\u00B8': 'Z', // "¸" Option+Shift+z
|
||||
'\u2202': 'd', // "∂" delete word forward
|
||||
'\u2020': 't', // "†" toggle full screen buffer
|
||||
'\u00E5': 'a', // "å" Option+a for alternate buffer
|
||||
};
|
||||
|
||||
function nonKeyboardEventFilter(
|
||||
|
||||
@@ -78,7 +78,6 @@ export interface UIActions {
|
||||
setShortcutsHelpVisible: (visible: boolean) => void;
|
||||
setCleanUiDetailsVisible: (visible: boolean) => void;
|
||||
toggleCleanUiDetailsVisible: () => void;
|
||||
toggleAlternateBuffer: () => void;
|
||||
revealCleanUiDetailsTemporarily: (durationMs?: number) => void;
|
||||
handleWarning: (message: string) => void;
|
||||
setEmbeddedShellFocused: (value: boolean) => void;
|
||||
|
||||
@@ -118,8 +118,6 @@ export interface UIState {
|
||||
isEditorDialogOpen: boolean;
|
||||
showPrivacyNotice: boolean;
|
||||
corgiMode: boolean;
|
||||
isAlternateBuffer: boolean;
|
||||
showIsAlternateBufferHint: boolean;
|
||||
debugMessage: string;
|
||||
quittingMessages: HistoryItem[] | null;
|
||||
isSettingsDialogOpen: boolean;
|
||||
|
||||
@@ -42,7 +42,9 @@ export function mapToDisplay(
|
||||
if (call.status === CoreToolCallStatus.Error) {
|
||||
description = JSON.stringify(call.request.args);
|
||||
} else {
|
||||
description = call.invocation.getDescription();
|
||||
description = call.invocation.getDisplayTitle
|
||||
? call.invocation.getDisplayTitle()
|
||||
: call.invocation.getDescription();
|
||||
renderOutputAsMarkdown = call.tool.isOutputMarkdown;
|
||||
}
|
||||
|
||||
|
||||
@@ -11,47 +11,49 @@ import {
|
||||
isAlternateBufferEnabled,
|
||||
} from './useAlternateBuffer.js';
|
||||
import type { Config } from '@google/gemini-cli-core';
|
||||
import { useUIState } from '../contexts/UIStateContext.js';
|
||||
|
||||
vi.mock('../contexts/UIStateContext.js');
|
||||
vi.mock('../contexts/ConfigContext.js', () => ({
|
||||
useConfig: vi.fn(),
|
||||
}));
|
||||
|
||||
const mockUseUIState = vi.mocked(useUIState);
|
||||
const mockUseConfig = vi.mocked(
|
||||
await import('../contexts/ConfigContext.js').then((m) => m.useConfig),
|
||||
);
|
||||
|
||||
describe('useAlternateBuffer', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('should return false when uiState.isAlternateBuffer is false', async () => {
|
||||
mockUseUIState.mockReturnValue({
|
||||
isAlternateBuffer: false,
|
||||
} as unknown as ReturnType<typeof mockUseUIState>);
|
||||
it('should return false when config.getUseAlternateBuffer returns false', async () => {
|
||||
mockUseConfig.mockReturnValue({
|
||||
getUseAlternateBuffer: () => false,
|
||||
} as unknown as ReturnType<typeof mockUseConfig>);
|
||||
|
||||
const { result } = await renderHook(() => useAlternateBuffer());
|
||||
expect(result.current).toBe(false);
|
||||
});
|
||||
|
||||
it('should return true when uiState.isAlternateBuffer is true', async () => {
|
||||
mockUseUIState.mockReturnValue({
|
||||
isAlternateBuffer: true,
|
||||
} as unknown as ReturnType<typeof mockUseUIState>);
|
||||
it('should return true when config.getUseAlternateBuffer returns true', async () => {
|
||||
mockUseConfig.mockReturnValue({
|
||||
getUseAlternateBuffer: () => true,
|
||||
} as unknown as ReturnType<typeof mockUseConfig>);
|
||||
|
||||
const { result } = await renderHook(() => useAlternateBuffer());
|
||||
expect(result.current).toBe(true);
|
||||
});
|
||||
|
||||
it('should react to state changes', async () => {
|
||||
mockUseUIState.mockReturnValue({
|
||||
isAlternateBuffer: false,
|
||||
} as unknown as ReturnType<typeof mockUseUIState>);
|
||||
it('should return the immutable config value, not react to settings changes', async () => {
|
||||
const mockConfig = {
|
||||
getUseAlternateBuffer: () => true,
|
||||
} as unknown as ReturnType<typeof mockUseConfig>;
|
||||
|
||||
mockUseConfig.mockReturnValue(mockConfig);
|
||||
|
||||
const { result, rerender } = await renderHook(() => useAlternateBuffer());
|
||||
|
||||
expect(result.current).toBe(false);
|
||||
|
||||
mockUseUIState.mockReturnValue({
|
||||
isAlternateBuffer: true,
|
||||
} as unknown as ReturnType<typeof mockUseUIState>);
|
||||
// Value should remain true even after rerender
|
||||
expect(result.current).toBe(true);
|
||||
|
||||
rerender();
|
||||
|
||||
|
||||
@@ -4,14 +4,14 @@
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { useUIState } from '../contexts/UIStateContext.js';
|
||||
import { useConfig } from '../contexts/ConfigContext.js';
|
||||
import type { Config } from '@google/gemini-cli-core';
|
||||
|
||||
export const isAlternateBufferEnabled = (config: Config): boolean =>
|
||||
config.getUseAlternateBuffer();
|
||||
|
||||
// This is read from UIState so that the UI can toggle dynamically
|
||||
// This is read from Config so that the UI reads the same value per application session
|
||||
export const useAlternateBuffer = (): boolean => {
|
||||
const uiState = useUIState();
|
||||
return uiState.isAlternateBuffer;
|
||||
const config = useConfig();
|
||||
return isAlternateBufferEnabled(config);
|
||||
};
|
||||
|
||||
@@ -95,7 +95,6 @@ export enum Command {
|
||||
RESTART_APP = 'app.restart',
|
||||
SUSPEND_APP = 'app.suspend',
|
||||
SHOW_SHELL_INPUT_UNFOCUS_WARNING = 'app.showShellUnfocusWarning',
|
||||
TOGGLE_BUFFER_MODE = 'app.toggleBufferMode',
|
||||
|
||||
// Background Shell Controls
|
||||
BACKGROUND_SHELL_ESCAPE = 'background.escape',
|
||||
@@ -393,7 +392,6 @@ export const defaultKeyBindingConfig: KeyBindingConfig = new Map([
|
||||
[Command.RESTART_APP, [new KeyBinding('r'), new KeyBinding('shift+r')]],
|
||||
[Command.SUSPEND_APP, [new KeyBinding('ctrl+z')]],
|
||||
[Command.SHOW_SHELL_INPUT_UNFOCUS_WARNING, [new KeyBinding('tab')]],
|
||||
[Command.TOGGLE_BUFFER_MODE, [new KeyBinding('alt+a')]],
|
||||
|
||||
// Background Shell Controls
|
||||
[Command.BACKGROUND_SHELL_ESCAPE, [new KeyBinding('escape')]],
|
||||
@@ -611,7 +609,6 @@ export const commandDescriptions: Readonly<Record<Command, string>> = {
|
||||
[Command.SHOW_IDE_CONTEXT_DETAIL]: 'Show IDE context details.',
|
||||
[Command.TOGGLE_MARKDOWN]: 'Toggle Markdown rendering.',
|
||||
[Command.TOGGLE_COPY_MODE]: 'Toggle copy mode when in alternate buffer mode.',
|
||||
[Command.TOGGLE_BUFFER_MODE]: 'Toggle between regular and full screen (alternate buffer) mode.',
|
||||
[Command.TOGGLE_YOLO]: 'Toggle YOLO (auto-approval) mode for tool calls.',
|
||||
[Command.CYCLE_APPROVAL_MODE]:
|
||||
'Cycle through approval modes: default (prompt), auto_edit (auto-approve edits), and plan (read-only). Plan mode is skipped when the agent is busy.',
|
||||
|
||||
@@ -1024,7 +1024,11 @@ export class LocalAgentExecutor<TOutput extends z.ZodTypeAny> {
|
||||
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 = invocation.getDisplayTitle
|
||||
? invocation.getDisplayTitle()
|
||||
: invocation.getDescription();
|
||||
}
|
||||
} catch {
|
||||
// Ignore errors during formatting for activity emission
|
||||
|
||||
@@ -30,9 +30,12 @@ import {
|
||||
isGemini2Model,
|
||||
supportsModernFeatures,
|
||||
} from '../config/models.js';
|
||||
import { hasCycleInSchema } from '../tools/tools.js';
|
||||
import { hasCycleInSchema, hasDisplayTitle } from '../tools/tools.js';
|
||||
import type { StructuredError } from './turn.js';
|
||||
import type { CompletedToolCall } from '../scheduler/types.js';
|
||||
import {
|
||||
type CompletedToolCall,
|
||||
isInvocationCall,
|
||||
} from '../scheduler/types.js';
|
||||
import {
|
||||
logContentRetry,
|
||||
logContentRetryFailure,
|
||||
@@ -1030,6 +1033,13 @@ export class GeminiChat {
|
||||
? resultDisplayRaw
|
||||
: undefined;
|
||||
|
||||
let description: string | undefined = undefined;
|
||||
if (isInvocationCall(call)) {
|
||||
description = hasDisplayTitle(call.invocation)
|
||||
? call.invocation.getDisplayTitle()
|
||||
: call.invocation.getDescription();
|
||||
}
|
||||
|
||||
return {
|
||||
id: call.request.callId,
|
||||
name: call.request.originalRequestName ?? call.request.name,
|
||||
@@ -1038,8 +1048,7 @@ export class GeminiChat {
|
||||
status: call.status,
|
||||
timestamp: new Date().toISOString(),
|
||||
resultDisplay,
|
||||
description:
|
||||
'invocation' in call ? call.invocation?.getDescription() : undefined,
|
||||
description,
|
||||
};
|
||||
});
|
||||
|
||||
|
||||
@@ -5,13 +5,14 @@
|
||||
*/
|
||||
|
||||
import type { Part } from '@google/genai';
|
||||
import type {
|
||||
AnyDeclarativeTool,
|
||||
AnyToolInvocation,
|
||||
ToolCallConfirmationDetails,
|
||||
ToolConfirmationOutcome,
|
||||
ToolResultDisplay,
|
||||
ToolLiveOutput,
|
||||
import {
|
||||
isAnyToolInvocation,
|
||||
type AnyDeclarativeTool,
|
||||
type AnyToolInvocation,
|
||||
type ToolCallConfirmationDetails,
|
||||
type ToolConfirmationOutcome,
|
||||
type ToolResultDisplay,
|
||||
type ToolLiveOutput,
|
||||
} from '../tools/tools.js';
|
||||
import type { ToolErrorType } from '../tools/tool-error.js';
|
||||
import type { SerializableConfirmationDetails } from '../confirmation-bus/types.js';
|
||||
@@ -195,6 +196,12 @@ export type CompletedToolCall =
|
||||
| CancelledToolCall
|
||||
| ErroredToolCall;
|
||||
|
||||
export function isInvocationCall(
|
||||
call: CompletedToolCall,
|
||||
): call is CompletedToolCall & { invocation: AnyToolInvocation } {
|
||||
return 'invocation' in call && isAnyToolInvocation(call.invocation);
|
||||
}
|
||||
|
||||
export type ConfirmHandler = (
|
||||
toolCall: WaitingToolCall,
|
||||
) => Promise<ToolConfirmationOutcome>;
|
||||
|
||||
@@ -139,6 +139,7 @@ describe('ShellTool', () => {
|
||||
getShellBackgroundCompletionBehavior: vi.fn().mockReturnValue('silent'),
|
||||
getEnableShellOutputEfficiency: vi.fn().mockReturnValue(true),
|
||||
getSandboxEnabled: vi.fn().mockReturnValue(false),
|
||||
getUsageStatisticsEnabled: vi.fn().mockReturnValue(false),
|
||||
sanitizationConfig: {},
|
||||
sandboxManager: new NoopSandboxManager(),
|
||||
} as unknown as Config;
|
||||
|
||||
@@ -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,24 @@ import {
|
||||
stripShellWrapper,
|
||||
parseCommandDetails,
|
||||
hasRedirection,
|
||||
inferFileOperation,
|
||||
} from '../utils/shell-utils.js';
|
||||
import { SHELL_TOOL_NAME } from './tool-names.js';
|
||||
import {
|
||||
getSpecificMimeType,
|
||||
readFileWithEncoding,
|
||||
detectFileType,
|
||||
} from '../utils/fileUtils.js';
|
||||
import { getLanguageFromFilePath } from '../utils/language-detection.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 +145,21 @@ export class ShellToolInvocation extends BaseToolInvocation<
|
||||
}
|
||||
|
||||
override getDisplayTitle(): string {
|
||||
const inferred = inferFileOperation(this.params.command);
|
||||
if (inferred) {
|
||||
switch (inferred.type) {
|
||||
case 'search':
|
||||
return `Shell (Search)`;
|
||||
case 'edit':
|
||||
return `Shell (Replace)`;
|
||||
case 'write':
|
||||
return `Shell (Write File)`;
|
||||
case 'read':
|
||||
return `Shell (Read File)`;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
return this.params.command;
|
||||
}
|
||||
|
||||
@@ -162,10 +195,97 @@ 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,
|
||||
): Promise<ToolCallConfirmationDetails | false> {
|
||||
const command = stripShellWrapper(this.params.command);
|
||||
const inferred = inferFileOperation(this.params.command);
|
||||
|
||||
if (inferred && (inferred.type === 'edit' || inferred.type === '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 === 'edit' && originalContent !== null) {
|
||||
if (
|
||||
inferred.metadata?.type === 'edit' &&
|
||||
inferred.metadata.sedExpression
|
||||
) {
|
||||
newContent = this.simulateSed(
|
||||
originalContent,
|
||||
inferred.metadata.sedExpression,
|
||||
);
|
||||
} else if (
|
||||
inferred.metadata?.type === 'edit' &&
|
||||
inferred.metadata.oldString &&
|
||||
inferred.metadata.newString
|
||||
) {
|
||||
newContent = this.simulatePsReplace(
|
||||
originalContent,
|
||||
inferred.metadata.oldString,
|
||||
inferred.metadata.newString,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
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 +336,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 +497,65 @@ 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 'search':
|
||||
operation = FileOperation.READ;
|
||||
canonicalToolName = GREP_TOOL_NAME;
|
||||
break;
|
||||
case 'read':
|
||||
operation = FileOperation.READ;
|
||||
canonicalToolName = READ_FILE_TOOL_NAME;
|
||||
break;
|
||||
case 'edit':
|
||||
operation = FileOperation.UPDATE;
|
||||
canonicalToolName = EDIT_TOOL_NAME;
|
||||
break;
|
||||
case 'write':
|
||||
operation = FileOperation.UPDATE;
|
||||
canonicalToolName = WRITE_FILE_TOOL_NAME;
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
|
||||
if (operation) {
|
||||
const absolutePath = path.resolve(cwd, inferred.filePath);
|
||||
const extension = path.extname(absolutePath);
|
||||
|
||||
let lines = 0;
|
||||
const mimetype = getSpecificMimeType(absolutePath) || '';
|
||||
const programmingLanguage =
|
||||
getLanguageFromFilePath(absolutePath) || '';
|
||||
|
||||
try {
|
||||
const fileType = await detectFileType(absolutePath);
|
||||
if (fileType === 'text' || fileType === 'svg') {
|
||||
const content = await readFileWithEncoding(absolutePath);
|
||||
lines = content.split('\n').length;
|
||||
}
|
||||
} catch {
|
||||
// Best effort line counting
|
||||
}
|
||||
|
||||
logFileOperation(
|
||||
this.context.config,
|
||||
new FileOperationEvent(
|
||||
canonicalToolName,
|
||||
operation,
|
||||
lines,
|
||||
mimetype,
|
||||
extension,
|
||||
programmingLanguage,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const backgroundPIDs: number[] = [];
|
||||
if (os.platform() !== 'win32') {
|
||||
let tempFileExists = false;
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -392,6 +392,32 @@ export abstract class BaseToolInvocation<
|
||||
*/
|
||||
export type AnyToolInvocation = ToolInvocation<object, ToolResult>;
|
||||
|
||||
/**
|
||||
* Type guard to check if an object is an AnyToolInvocation.
|
||||
*/
|
||||
export function isAnyToolInvocation(
|
||||
invocation: unknown,
|
||||
): invocation is AnyToolInvocation {
|
||||
if (typeof invocation !== 'object' || invocation === null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return (
|
||||
'execute' in invocation &&
|
||||
typeof invocation.execute === 'function' &&
|
||||
'getDescription' in invocation &&
|
||||
typeof invocation.getDescription === 'function' &&
|
||||
'params' in invocation &&
|
||||
isRecord(invocation.params)
|
||||
);
|
||||
}
|
||||
|
||||
export function hasDisplayTitle(
|
||||
invocation: AnyToolInvocation,
|
||||
): invocation is AnyToolInvocation & { getDisplayTitle(): string } {
|
||||
return typeof invocation.getDisplayTitle === 'function';
|
||||
}
|
||||
|
||||
/**
|
||||
* Interface for a tool builder that validates parameters and creates invocations.
|
||||
*/
|
||||
|
||||
@@ -23,6 +23,7 @@ import {
|
||||
stripShellWrapper,
|
||||
hasRedirection,
|
||||
resolveExecutable,
|
||||
inferFileOperation,
|
||||
} from './shell-utils.js';
|
||||
import path from 'node:path';
|
||||
|
||||
@@ -603,3 +604,52 @@ 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('edit');
|
||||
expect(result?.filePath).toBe('test.ts');
|
||||
expect(result?.metadata?.type).toBe('edit');
|
||||
if (result?.metadata?.type === 'edit') {
|
||||
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('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('search');
|
||||
expect(result?.filePath).toBe('file.txt');
|
||||
expect(result?.metadata?.type).toBe('search');
|
||||
if (result?.metadata?.type === 'search') {
|
||||
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('edit');
|
||||
expect(result?.filePath).toBe('file.txt');
|
||||
expect(result?.metadata?.type).toBe('edit');
|
||||
if (result?.metadata?.type === 'edit') {
|
||||
expect(result.metadata.oldString).toBe('a');
|
||||
expect(result.metadata.newString).toBe('b');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -623,6 +623,41 @@ export function getShellConfiguration(): ShellConfiguration {
|
||||
*/
|
||||
export const isWindows = () => os.platform() === 'win32';
|
||||
|
||||
export type FileOperationType = 'search' | 'edit' | 'write' | 'read';
|
||||
|
||||
export type SearchFileMetadata = {
|
||||
type: 'search';
|
||||
pattern: string;
|
||||
};
|
||||
|
||||
export type EditFileMetadata = {
|
||||
type: 'edit';
|
||||
sedExpression?: string;
|
||||
oldString?: string;
|
||||
newString?: string;
|
||||
};
|
||||
|
||||
export type WriteFileMetadata = {
|
||||
type: 'write';
|
||||
};
|
||||
|
||||
export type ReadFileMetadata = {
|
||||
type: 'read';
|
||||
};
|
||||
|
||||
export type InferredFileMetadata =
|
||||
| SearchFileMetadata
|
||||
| EditFileMetadata
|
||||
| WriteFileMetadata
|
||||
| ReadFileMetadata;
|
||||
|
||||
export interface InferredFileOperation {
|
||||
type: FileOperationType;
|
||||
filePath: string;
|
||||
/** Inferred details (e.g., the sed expression or replacement string) */
|
||||
metadata?: InferredFileMetadata;
|
||||
}
|
||||
|
||||
/**
|
||||
* Escapes a string so that it can be safely used as a single argument
|
||||
* in a shell command, preventing command injection.
|
||||
@@ -751,6 +786,163 @@ 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: 'edit',
|
||||
filePath: sedMatch[2].trim(),
|
||||
metadata: { type: 'edit', 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: 'write',
|
||||
filePath: redirectionMatch[1].trim(),
|
||||
};
|
||||
}
|
||||
|
||||
// cat > file <<EOF ...
|
||||
const heredocMatch = stripped.match(
|
||||
/(?:^|&&|\|\||;)\s*cat\s*>\s*(\S+)\s*<<\s*(\S+)/,
|
||||
);
|
||||
if (heredocMatch) {
|
||||
return {
|
||||
type: 'write',
|
||||
filePath: heredocMatch[1].trim(),
|
||||
};
|
||||
}
|
||||
|
||||
// cp src dest
|
||||
const cpMatch = stripped.match(
|
||||
/^(?:cp)\s+(?:-[^ ]+\s+)*['"]?([^'"]+)['"]?\s+['"]?([^'"]+)['"]?\s*$/,
|
||||
);
|
||||
if (cpMatch) {
|
||||
return {
|
||||
type: 'write',
|
||||
filePath: cpMatch[2].trim(),
|
||||
};
|
||||
}
|
||||
|
||||
// mv src dest
|
||||
const mvMatch = stripped.match(
|
||||
/^(?:mv)\s+(?:-[^ ]+\s+)*['"]?([^'"]+)['"]?\s+['"]?([^'"]+)['"]?\s*$/,
|
||||
);
|
||||
if (mvMatch) {
|
||||
return {
|
||||
type: 'write',
|
||||
filePath: mvMatch[2].trim(),
|
||||
};
|
||||
}
|
||||
|
||||
// ... | tee file
|
||||
const teeMatch = stripped.match(
|
||||
/\|\s*tee\s+(?:-a\s+)?['"]?([^'"]+)['"]?\s*$/,
|
||||
);
|
||||
if (teeMatch) {
|
||||
return {
|
||||
type: 'write',
|
||||
filePath: teeMatch[1].trim(),
|
||||
};
|
||||
}
|
||||
|
||||
// grep "pattern" file
|
||||
const grepMatch = stripped.match(
|
||||
/^(?:grep|rg|ripgrep)\s+(?:-[^ ]+\s+)*['"]?([^'"]+)['"]?\s+['"]?([^'"]+)['"]?\s*$/,
|
||||
);
|
||||
if (grepMatch) {
|
||||
return {
|
||||
type: 'search',
|
||||
filePath: grepMatch[2].trim(),
|
||||
metadata: { type: 'search', 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: '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: 'edit',
|
||||
filePath: psReplaceMatch[1].trim(),
|
||||
metadata: {
|
||||
type: 'edit',
|
||||
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: '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: 'search',
|
||||
filePath: psSearchMatch[1].trim(),
|
||||
metadata: { type: 'search', 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: '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;
|
||||
|
||||
Reference in New Issue
Block a user