diff --git a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/tools/monitoring/outputMonitor.ts b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/tools/monitoring/outputMonitor.ts index af3c934a1eb27a..3de8606057f215 100644 --- a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/tools/monitoring/outputMonitor.ts +++ b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/tools/monitoring/outputMonitor.ts @@ -20,6 +20,14 @@ export interface IOutputMonitor extends Disposable { readonly onDidFinishCommand: Event; readonly onDidDetectInputNeeded: Event; + /** + * Fires when the terminal is detected to be waiting for sensitive input + * (e.g. a password, passphrase, token, secret or verification code). This + * is fired *instead of* {@link onDidDetectInputNeeded} so callers can show + * UI that focuses the terminal rather than routing the prompt through the + * agent. + */ + readonly onDidDetectSensitiveInputNeeded: Event; } export interface IOutputMonitorTelemetryCounters { @@ -98,6 +106,9 @@ export class OutputMonitor extends Disposable implements IOutputMonitor { private readonly _onDidDetectInputNeeded = this._register(new Emitter()); readonly onDidDetectInputNeeded: Event = this._onDidDetectInputNeeded.event; + private readonly _onDidDetectSensitiveInputNeeded = this._register(new Emitter()); + readonly onDidDetectSensitiveInputNeeded: Event = this._onDidDetectSensitiveInputNeeded.event; + private _asyncMode = false; private _command = ''; private _invocationContext: IToolInvocationContext | undefined; @@ -374,8 +385,13 @@ export class OutputMonitor extends Disposable implements IOutputMonitor { // (passwords, [Y/n], etc.) and signal the agent to handle via send_to_terminal. if (this._asyncMode) { if (detectsInputRequiredPattern(outputLastLine)) { - this._logService.trace('OutputMonitor: Async mode - input-required pattern detected, signaling agent'); - this._onDidDetectInputNeeded.fire(); + if (detectsSensitiveInputPrompt(outputLastLine)) { + this._logService.trace('OutputMonitor: Async mode - sensitive input prompt detected, signaling sensitive UI'); + this._onDidDetectSensitiveInputNeeded.fire(); + } else { + this._logService.trace('OutputMonitor: Async mode - input-required pattern detected, signaling agent'); + this._onDidDetectInputNeeded.fire(); + } } this._cleanupIdleInputListener(); return { shouldContinuePolling: false, output }; @@ -384,10 +400,17 @@ export class OutputMonitor extends Disposable implements IOutputMonitor { // Use regex-based detection for input-required patterns (passwords, [Y/n], etc.) // In foreground mode, fire the event so the race in runInTerminalTool can pick it // up and return control to the agent (which uses send_to_terminal to provide input). - // No elicitation UI is shown — the agent handles it autonomously. + // For sensitive prompts (passwords, secrets, OTPs, …) we instead fire a separate + // event so the tool can show a confirmation dialog that focuses the terminal — + // the secret must never be routed through the model. if (detectsInputRequiredPattern(outputLastLine)) { - this._logService.trace('OutputMonitor: Input-required pattern detected, signaling agent'); - this._onDidDetectInputNeeded.fire(); + if (detectsSensitiveInputPrompt(outputLastLine)) { + this._logService.trace('OutputMonitor: Sensitive input prompt detected, signaling sensitive UI'); + this._onDidDetectSensitiveInputNeeded.fire(); + } else { + this._logService.trace('OutputMonitor: Input-required pattern detected, signaling agent'); + this._onDidDetectInputNeeded.fire(); + } this._cleanupIdleInputListener(); return { shouldContinuePolling: false, output }; } @@ -541,10 +564,21 @@ export class OutputMonitor extends Disposable implements IOutputMonitor { } private _isSensitivePrompt(prompt: string): boolean { - return /(password|passphrase|token|api\s*key|secret)/i.test(prompt); + return detectsSensitiveInputPrompt(prompt); } } +/** + * Returns true when the terminal's last visible line looks like a prompt for + * a sensitive secret (password, passphrase, token, API key, OTP, etc.). Used + * to short-circuit the normal "input needed → return to agent" flow so that + * the secret is never routed through the model — instead the user is asked + * via UI to focus the terminal and type the secret directly. + */ +export function detectsSensitiveInputPrompt(cursorLine: string): boolean { + return /(password|passphrase|token|api\s*key|secret|verification code|otp\b|one[\s-]?time (?:code|password)|2fa|mfa|pin\s*(?:code|number)?[: ]?\s*$|authentication code)/i.test(cursorLine); +} + export function matchTerminalPromptOption(options: readonly string[], suggestedOption: string): { option: string | undefined; index: number } { const normalize = (value: string) => value.replace(/['"`]/g, '').trim().replace(/[.,:;]+$/, ''); diff --git a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/tools/runInTerminalTool.ts b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/tools/runInTerminalTool.ts index 78fd504195ff8e..4d458c25718f90 100644 --- a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/tools/runInTerminalTool.ts +++ b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/tools/runInTerminalTool.ts @@ -10,7 +10,7 @@ import { Codicon } from '../../../../../../base/common/codicons.js'; import { CancellationError } from '../../../../../../base/common/errors.js'; import { Event } from '../../../../../../base/common/event.js'; import { escapeMarkdownSyntaxTokens, MarkdownString, type IMarkdownString } from '../../../../../../base/common/htmlContent.js'; -import { Disposable, DisposableMap, DisposableStore, MutableDisposable, toDisposable } from '../../../../../../base/common/lifecycle.js'; +import { Disposable, DisposableMap, DisposableStore, IDisposable, MutableDisposable, toDisposable } from '../../../../../../base/common/lifecycle.js'; import { ResourceMap } from '../../../../../../base/common/map.js'; import { getMediaMime } from '../../../../../../base/common/mime.js'; import { basename, posix, win32 } from '../../../../../../base/common/path.js'; @@ -1084,6 +1084,120 @@ export class RunInTerminalTool extends Disposable implements IToolImpl { : new MarkdownString(localize('runInTerminal.unsandboxed.autoRetry', "Run `{0}` command outside the sandbox?", shellType)); } + /** + * Surface a confirmation dialog when the terminal is detected to be waiting + * for sensitive input (password, passphrase, OTP, …). Sensitive prompts must + * never be routed through the model — the user types the secret directly + * into the terminal. The "Focus terminal" action reveals and focuses the + * terminal; the "Cancel" action cancels the running command. + * + * Returns a disposable that hides any pending elicitation. The handler + * itself dedupes concurrent elicitations so repeated polling cycles don't + * spam the chat session. + */ + private _registerSensitiveInputElicitation( + chatSessionResource: URI | undefined, + terminalInstance: ITerminalInstance, + outputMonitor: { onDidDetectSensitiveInputNeeded: Event }, + cancelExecution: () => void, + onAutoCancelled?: () => void, + ): IDisposable { + const store = new DisposableStore(); + let pending: { hide: () => void } | undefined; + let autoCancelled = false; + + store.add(outputMonitor.onDidDetectSensitiveInputNeeded(() => { + if (pending || autoCancelled) { + return; + } + const isAutoApproved = chatSessionResource && isSessionAutoApproveLevel(chatSessionResource, this._configurationService, this._chatWidgetService, this._chatService); + const chatModel = chatSessionResource && this._chatService.getSession(chatSessionResource); + if (isAutoApproved) { + // Autopilot / auto-approve: no human is in the loop to type the + // secret, and the terminal can't reliably be focused after the + // tool returns. Cancel the command and let the caller emit a + // steering note that tells the agent the user is unavailable. + // We also surface a small dismiss-only chat part so the user + // can see what happened even if the agent doesn't follow up + // with a message of its own. + autoCancelled = true; + if (chatModel instanceof ChatModel) { + const request = chatModel.getRequests().at(-1); + if (request) { + const infoPart = new ChatElicitationRequestPart( + new MarkdownString(localize('runInTerminal.sensitiveInput.autoCancelTitle', "Terminal command cancelled — sensitive input required")), + new MarkdownString(localize('runInTerminal.sensitiveInput.autoCancelMessage', "The terminal command was prompting for a password or other secret. Auto-approve / autopilot mode cannot safely supply secrets, so the command was cancelled. Run the command interactively if you want to provide the secret.")), + '', + localize('runInTerminal.sensitiveInput.dismiss', "Dismiss"), + '', + async () => { infoPart.hide(); return ElicitationState.Accepted; }, + async () => { infoPart.hide(); return ElicitationState.Rejected; }, + undefined, + undefined, + undefined, + undefined, + ); + chatModel.acceptResponseProgress(request, infoPart); + } + } + onAutoCancelled?.(); + cancelExecution(); + return; + } + if (!(chatModel instanceof ChatModel)) { + // No chat surface to attach to — fall back to focusing the + // terminal directly so the user is at least not left blocked. + this._terminalService.setActiveInstance(terminalInstance); + this._terminalService.revealTerminal(terminalInstance, true).catch(() => { }); + terminalInstance.focus(); + return; + } + const request = chatModel.getRequests().at(-1); + if (!request) { + return; + } + + const part = new ChatElicitationRequestPart( + new MarkdownString(localize('runInTerminal.sensitiveInput.title', "Terminal is waiting for sensitive input")), + new MarkdownString(localize('runInTerminal.sensitiveInput.message', "The terminal command appears to be prompting for a password or other sensitive value. Focus the terminal to type it directly — secrets must not be sent through chat.")), + '', + localize('runInTerminal.sensitiveInput.focus', "Focus Terminal"), + localize('runInTerminal.sensitiveInput.cancel', "Cancel Command"), + async () => { + pending = undefined; + part.hide(); + try { + this._terminalService.setActiveInstance(terminalInstance); + await this._terminalService.revealTerminal(terminalInstance, true); + terminalInstance.focus(); + } catch (err) { + this._logService.warn(`RunInTerminalTool: failed to reveal terminal for sensitive input`, err); + } + return ElicitationState.Accepted; + }, + async () => { + pending = undefined; + part.hide(); + cancelExecution(); + return ElicitationState.Rejected; + }, + undefined, + undefined, + () => { pending = undefined; }, + undefined, + ); + + pending = part; + chatModel.acceptResponseProgress(request, part); + // Intentionally do NOT register a disposable that hides the part on store + // dispose: the elicitation must persist past the tool call returning so the + // user can still focus the terminal (and type their secret) after the + // agent has surrendered its turn. The part hides itself on accept/reject. + })); + + return store; + } + private _acceptAutomaticUnsandboxRetryToolInvocationUpdate(sessionResource: URI | undefined, toolCallId: string, toolSpecificData: IChatTerminalToolInvocationData, isComplete: boolean, toolResultMessage?: string | IMarkdownString): void { const chatModel = sessionResource && this._chatService.getSession(sessionResource); if (!(chatModel instanceof ChatModel)) { @@ -1271,6 +1385,7 @@ export class RunInTerminalTool extends Disposable implements IToolImpl { let altBufferResult: IToolResult | undefined; let didTimeout = false; let didInputNeeded = false; + let didSensitiveAutoCancelled = false; // Covers both terminals that start as background (persistentSession) and // foreground terminals that later move to background (timeout/continue-in-bg). let isBackgroundExecution = executionOptions.persistentSession; @@ -1434,6 +1549,30 @@ export class RunInTerminalTool extends Disposable implements IToolImpl { // Also race on output monitor input-needed so that interactive prompts // return output to the agent early instead of waiting for timeout. const raceCleanup = new DisposableStore(); + // Sensitive prompts (passwords, OTPs, …) must never reach the model. + // Show a confirmation dialog that focuses the terminal so the user + // types the secret directly. The race is *not* resolved by sensitive + // prompts — the running command keeps waiting for user input until + // either it completes (executionPromise wins) or the user cancels + // it from the dialog (which cancels execution and also makes + // executionPromise resolve). This means we never hand a secret + // prompt back to the model; the user is always in control. + // + // outputMonitor is created later inside `onDidCreateStartMarker`, + // so we must wait on `startMarkerPromise` before registering the + // listener — otherwise outputMonitor is still undefined here and + // the sensitive event never reaches us. + startMarkerPromise.then(() => { + if (outputMonitor && !raceCleanup.isDisposed) { + raceCleanup.add(this._registerSensitiveInputElicitation( + chatSessionResource, + toolTerminal.instance, + outputMonitor, + () => executeCancellation.cancel(), + () => { didSensitiveAutoCancelled = true; }, + )); + } + }); const raceCandidates: Promise<{ type: 'completed'; result: ITerminalExecuteStrategyResult } | { type: 'background' } | { type: 'timeout' } | { type: 'inputNeeded' }>[] = [ executionPromise.then(result => ({ type: 'completed' as const, result })), continueInBackgroundPromise.then(() => ({ type: 'background' as const })), @@ -1731,7 +1870,9 @@ export class RunInTerminalTool extends Disposable implements IToolImpl { resultText.push(`Note: This terminal execution was moved to the background using the ID ${termId}\n`); } } - if (didInputNeeded) { + if (didSensitiveAutoCancelled) { + resultText.push(`Note: The command in terminal ID ${termId} was prompting for a password, passphrase, or other secret. The user is unavailable (auto-approve / autopilot mode is on, so no human can focus the terminal to type a secret) and the command has been cancelled. Stop, do NOT retry the command, do NOT call ${TerminalToolId.SendToTerminal}, and do NOT call vscode_askQuestions for the secret. Tell the user to run the command interactively when they are available.\n\n`); + } else if (didInputNeeded) { resultText.push(`Note: The command is running in terminal ID ${termId} and may be waiting for input.\n${this._buildInputNeededSteeringText(chatSessionResource, termId, /*mentionTimeout*/ false)}\n\n`); } else if (didTimeout && timeoutValue !== undefined && timeoutValue > 0) { const notificationHint = shouldSendNotifications @@ -2259,6 +2400,20 @@ export class RunInTerminalTool extends Disposable implements IToolImpl { store.add(outputMonitor); outputMonitor.continueMonitoringAsync(bgCts.token); + // Sensitive prompts (passwords, OTPs, …) detected while the command runs + // in the background must not generate a steering message — the secret + // must never reach the model. Show a confirmation dialog that focuses + // the terminal so the user can type the secret directly. + store.add(this._registerSensitiveInputElicitation( + chatSessionResource, + terminalInstance, + outputMonitor, + () => { + const execution = RunInTerminalTool._activeExecutions.get(termId); + execution?.dispose(); + }, + )); + // When the output monitor detects the terminal is waiting for input, // send a steering message so the agent handles it via send_to_terminal. store.add(outputMonitor.onDidDetectInputNeeded(() => { diff --git a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/test/browser/outputMonitor.test.ts b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/test/browser/outputMonitor.test.ts index 3781814f337a61..af9dc182dcb972 100644 --- a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/test/browser/outputMonitor.test.ts +++ b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/test/browser/outputMonitor.test.ts @@ -4,7 +4,7 @@ *--------------------------------------------------------------------------------------------*/ import * as assert from 'assert'; -import { detectsGenericPressAnyKeyPattern, detectsHighConfidenceInputPattern, detectsInputRequiredPattern, detectsNonInteractiveHelpPattern, detectsVSCodeTaskFinishMessage, getLastLine, matchTerminalPromptOption, OutputMonitor } from '../../browser/tools/monitoring/outputMonitor.js'; +import { detectsGenericPressAnyKeyPattern, detectsHighConfidenceInputPattern, detectsInputRequiredPattern, detectsNonInteractiveHelpPattern, detectsSensitiveInputPrompt, detectsVSCodeTaskFinishMessage, getLastLine, matchTerminalPromptOption, OutputMonitor } from '../../browser/tools/monitoring/outputMonitor.js'; import { CancellationTokenSource } from '../../../../../../base/common/cancellation.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../base/test/common/utils.js'; import { IExecution, IPollingResult, OutputMonitorState } from '../../browser/tools/monitoring/types.js'; @@ -233,6 +233,41 @@ suite('OutputMonitor', () => { }); }); + test('sensitive prompt fires onDidDetectSensitiveInputNeeded and not onDidDetectInputNeeded', async () => { + return runWithFakedTimers({}, async () => { + execution.getOutput = () => 'Password: '; + monitor = store.add(instantiationService.createInstance(OutputMonitor, execution, undefined, createTestContext('1'), cts.token, 'test command')); + + let inputNeededFired = false; + let sensitiveFired = false; + store.add(monitor.onDidDetectInputNeeded(() => { inputNeededFired = true; })); + store.add(monitor.onDidDetectSensitiveInputNeeded(() => { sensitiveFired = true; })); + + await Event.toPromise(monitor.onDidFinishCommand); + + assert.strictEqual(sensitiveFired, true, 'onDidDetectSensitiveInputNeeded should fire for sensitive prompts'); + assert.strictEqual(inputNeededFired, false, 'onDidDetectInputNeeded must not fire for sensitive prompts so the secret is not routed to the agent'); + }); + }); + + test('detectsSensitiveInputPrompt matches common secret prompts', () => { + assert.strictEqual(detectsSensitiveInputPrompt('Password: '), true); + assert.strictEqual(detectsSensitiveInputPrompt('[sudo] password for jdoe: '), true); + assert.strictEqual(detectsSensitiveInputPrompt('Passphrase for key /Users/foo/.ssh/id_rsa: '), true); + assert.strictEqual(detectsSensitiveInputPrompt('Enter your API key: '), true); + assert.strictEqual(detectsSensitiveInputPrompt('Token: '), true); + assert.strictEqual(detectsSensitiveInputPrompt('Verification code: '), true); + assert.strictEqual(detectsSensitiveInputPrompt('Enter OTP: '), true); + assert.strictEqual(detectsSensitiveInputPrompt('One-time code: '), true); + assert.strictEqual(detectsSensitiveInputPrompt('Enter your 2FA code: '), true); + assert.strictEqual(detectsSensitiveInputPrompt('Enter MFA code: '), true); + + assert.strictEqual(detectsSensitiveInputPrompt('Continue? (y/n) '), false); + assert.strictEqual(detectsSensitiveInputPrompt('Press any key to continue...'), false); + assert.strictEqual(detectsSensitiveInputPrompt('Enter your name: '), false); + assert.strictEqual(detectsSensitiveInputPrompt('package name: (test_npm_init) '), false); + }); + test('extended timeout with isActive fires onDidDetectInputNeeded', async () => { return runWithFakedTimers({}, async () => { // Simulate a process that stays active with output that doesn't diff --git a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/test/electron-browser/runInTerminalTool.test.ts b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/test/electron-browser/runInTerminalTool.test.ts index 4e496253093d11..70d3fb3721e6b9 100644 --- a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/test/electron-browser/runInTerminalTool.test.ts +++ b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/test/electron-browser/runInTerminalTool.test.ts @@ -1980,16 +1980,17 @@ suite('RunInTerminalTool', () => { const outputMonitor = { onDidDetectInputNeeded: inputNeededEmitter.event, + onDidDetectSensitiveInputNeeded: Event.None, continueMonitoringAsync: () => { }, dispose: () => { }, - } as unknown as { onDidDetectInputNeeded: Event; continueMonitoringAsync: () => void; dispose: () => void }; + } as unknown as { onDidDetectInputNeeded: Event; onDidDetectSensitiveInputNeeded: Event; continueMonitoringAsync: () => void; dispose: () => void }; (runInTerminalTool.constructor as unknown as { _activeExecutions: Map })._activeExecutions.set(termId, { getOutput: () => output, }); // eslint-disable-next-line @typescript-eslint/naming-convention - (runInTerminalTool as unknown as { _registerCompletionNotification: (terminal: ITerminalInstance, termId: string, session: URI, commandName: string, outputMonitor: { onDidDetectInputNeeded: Event; continueMonitoringAsync: () => void; dispose: () => void }) => void }) + (runInTerminalTool as unknown as { _registerCompletionNotification: (terminal: ITerminalInstance, termId: string, session: URI, commandName: string, outputMonitor: { onDidDetectInputNeeded: Event; onDidDetectSensitiveInputNeeded: Event; continueMonitoringAsync: () => void; dispose: () => void }) => void }) ._registerCompletionNotification(terminalInstance, termId, sessionResource, 'npm init', outputMonitor); inputNeededEmitter.fire(); @@ -2021,9 +2022,10 @@ suite('RunInTerminalTool', () => { const outputMonitor = { onDidDetectInputNeeded: inputNeededEmitter.event, + onDidDetectSensitiveInputNeeded: Event.None, continueMonitoringAsync: () => { }, dispose: () => { }, - } as unknown as { onDidDetectInputNeeded: Event; continueMonitoringAsync: () => void; dispose: () => void }; + } as unknown as { onDidDetectInputNeeded: Event; onDidDetectSensitiveInputNeeded: Event; continueMonitoringAsync: () => void; dispose: () => void }; (runInTerminalTool.constructor as unknown as { _activeExecutions: Map })._activeExecutions.set(termId, { getOutput: () => output, @@ -2034,7 +2036,7 @@ suite('RunInTerminalTool', () => { // monitor's first re-detection of the same prompt must not fire a steering // message that would yield the agent's in-flight `send_to_terminal` reply. // eslint-disable-next-line @typescript-eslint/naming-convention - (runInTerminalTool as unknown as { _registerCompletionNotification: (terminal: ITerminalInstance, termId: string, session: URI, commandName: string, outputMonitor: { onDidDetectInputNeeded: Event; continueMonitoringAsync: () => void; dispose: () => void }, alreadyNotifiedInputNeededOutput?: string) => void }) + (runInTerminalTool as unknown as { _registerCompletionNotification: (terminal: ITerminalInstance, termId: string, session: URI, commandName: string, outputMonitor: { onDidDetectInputNeeded: Event; onDidDetectSensitiveInputNeeded: Event; continueMonitoringAsync: () => void; dispose: () => void }, alreadyNotifiedInputNeededOutput?: string) => void }) ._registerCompletionNotification(terminalInstance, termId, sessionResource, 'mkdir -p foo && cd foo && npm init', outputMonitor, output); inputNeededEmitter.fire(); @@ -2066,9 +2068,10 @@ suite('RunInTerminalTool', () => { const outputMonitor = { onDidDetectInputNeeded: inputNeededEmitter.event, + onDidDetectSensitiveInputNeeded: Event.None, continueMonitoringAsync: () => { }, dispose: () => { }, - } as unknown as { onDidDetectInputNeeded: Event; continueMonitoringAsync: () => void; dispose: () => void }; + } as unknown as { onDidDetectInputNeeded: Event; onDidDetectSensitiveInputNeeded: Event; continueMonitoringAsync: () => void; dispose: () => void }; // Set up fg terminal association and active execution runInTerminalTool.sessionTerminalAssociations.set(sessionResource, { @@ -2082,7 +2085,7 @@ suite('RunInTerminalTool', () => { }); // eslint-disable-next-line @typescript-eslint/naming-convention - (runInTerminalTool as unknown as { _registerCompletionNotification: (terminal: ITerminalInstance, termId: string, session: URI, commandName: string, outputMonitor: { onDidDetectInputNeeded: Event; continueMonitoringAsync: () => void; dispose: () => void }) => void }) + (runInTerminalTool as unknown as { _registerCompletionNotification: (terminal: ITerminalInstance, termId: string, session: URI, commandName: string, outputMonitor: { onDidDetectInputNeeded: Event; onDidDetectSensitiveInputNeeded: Event; continueMonitoringAsync: () => void; dispose: () => void }) => void }) ._registerCompletionNotification(terminalInstance, termId, sessionResource, 'ssh host', outputMonitor); // Fire inputNeeded — this simulates the output monitor detecting a prompt