Compare commits

..

5 Commits

Author SHA1 Message Date
Christian Gunderman e2fe41ed18 Revert 2026-04-13 20:56:01 -07:00
Christian Gunderman 19430fdeca Line counting. 2026-04-13 20:52:04 -07:00
Christian Gunderman e2b69dc267 PR cleanup. 2026-04-13 20:41:55 -07:00
Christian Gunderman 6621bd88c8 Format. 2026-04-13 19:44:05 -07:00
Christian Gunderman 44d8db20c8 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.
2026-04-13 19:23:50 -07:00
23 changed files with 687 additions and 146 deletions
+123
View File
@@ -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');
});
});
-1
View File
@@ -534,7 +534,6 @@ export const mockAppState: AppState = {
};
const mockUIActions: UIActions = {
toggleAlternateBuffer: vi.fn(),
handleThemeSelect: vi.fn(),
closeThemeDialog: vi.fn(),
handleThemeHighlight: vi.fn(),
+1 -58
View File
@@ -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');
});
});
+1 -6
View File
@@ -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;
+3 -1
View File
@@ -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);
};
-3
View File
@@ -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.',
+5 -1
View File
@@ -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
+13 -4
View File
@@ -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,
};
});
+14 -7
View File
@@ -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>;
+1
View File
@@ -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;
+181 -2
View File
@@ -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', () => {
+13
View File
@@ -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);
+26
View File
@@ -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');
}
});
});
+192
View File
@@ -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;