Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,14 @@ export interface IOutputMonitor extends Disposable {

readonly onDidFinishCommand: Event<void>;
readonly onDidDetectInputNeeded: Event<void>;
/**
* 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<void>;
}

export interface IOutputMonitorTelemetryCounters {
Expand Down Expand Up @@ -98,6 +106,9 @@ export class OutputMonitor extends Disposable implements IOutputMonitor {
private readonly _onDidDetectInputNeeded = this._register(new Emitter<void>());
readonly onDidDetectInputNeeded: Event<void> = this._onDidDetectInputNeeded.event;

private readonly _onDidDetectSensitiveInputNeeded = this._register(new Emitter<void>());
readonly onDidDetectSensitiveInputNeeded: Event<void> = this._onDidDetectSensitiveInputNeeded.event;

private _asyncMode = false;
private _command = '';
private _invocationContext: IToolInvocationContext | undefined;
Expand Down Expand Up @@ -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 };
Expand All @@ -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 };
}
Expand Down Expand Up @@ -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(/[.,:;]+$/, '');

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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.
*
Comment thread
meganrogge marked this conversation as resolved.
* 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<void> },
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;
Comment thread
meganrogge marked this conversation as resolved.
},
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)) {
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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 })),
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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(() => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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
Expand Down
Loading
Loading