Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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,78 @@ 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,
): IDisposable {
const store = new DisposableStore();
let pending: { hide: () => void } | undefined;

store.add(outputMonitor.onDidDetectSensitiveInputNeeded(() => {
if (pending) {
return;
}
const chatModel = chatSessionResource && this._chatService.getSession(chatSessionResource);
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();
this._terminalService.setActiveInstance(terminalInstance);
await this._terminalService.revealTerminal(terminalInstance, true);
terminalInstance.focus();
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);
store.add({ dispose: () => part.hide() });
}));

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 @@ -1434,6 +1506,19 @@ 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
// it either completes or the user cancels it from the dialog.
if (outputMonitor) {
raceCleanup.add(this._registerSensitiveInputElicitation(
chatSessionResource,
toolTerminal.instance,
outputMonitor,
() => executeCancellation.cancel(),
));
}
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 @@ -2259,6 +2344,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
Original file line number Diff line number Diff line change
Expand Up @@ -1980,6 +1980,7 @@ suite('RunInTerminalTool', () => {

const outputMonitor = {
onDidDetectInputNeeded: inputNeededEmitter.event,
onDidDetectSensitiveInputNeeded: Event.None,
continueMonitoringAsync: () => { },
dispose: () => { },
} as unknown as { onDidDetectInputNeeded: Event<void>; continueMonitoringAsync: () => void; dispose: () => void };
Comment thread
meganrogge marked this conversation as resolved.
Outdated
Expand Down Expand Up @@ -2021,6 +2022,7 @@ suite('RunInTerminalTool', () => {

const outputMonitor = {
onDidDetectInputNeeded: inputNeededEmitter.event,
onDidDetectSensitiveInputNeeded: Event.None,
continueMonitoringAsync: () => { },
dispose: () => { },
} as unknown as { onDidDetectInputNeeded: Event<void>; continueMonitoringAsync: () => void; dispose: () => void };
Expand Down Expand Up @@ -2066,6 +2068,7 @@ suite('RunInTerminalTool', () => {

const outputMonitor = {
onDidDetectInputNeeded: inputNeededEmitter.event,
onDidDetectSensitiveInputNeeded: Event.None,
continueMonitoringAsync: () => { },
dispose: () => { },
} as unknown as { onDidDetectInputNeeded: Event<void>; continueMonitoringAsync: () => void; dispose: () => void };
Expand Down
Loading