Compare commits

...
Author SHA1 Message Date
陈大猫 b7efbe347c fix: fold vim cursor redraws before prompt security checks (#4107) 2026-11-04 12:21:06 +08:00
陈大猫 64d2bc4c0b chore: retrigger CI after cancelled lint-and-test (#4107) 2026-11-04 12:00:36 +08:00
陈大猫 b8b63f73e0 fix: honor raw terminal screen exits in prompt security
Use unknown screen state until a raw transition or snapshot is authoritative, so a raw exit overrides the stale xterm alternate buffer. Cover the exit race with complete and split DEC screen-exit sequences.

Leave arbitrary unknown colon-prefixed alternate-screen challenges such as : Challenge: as a residual edge case outside #4107; known sensitive challenge detection remains unchanged.
2026-11-04 11:55:21 +08:00
陈大猫 8b2378e601 fix: preserve prompt protection across vim and display snapshots 2026-11-04 11:49:03 +08:00
陈大猫 a1a0481eb1 fix(terminal): preserve tmux password latch (#4107) 2026-11-04 11:43:47 +08:00
陈大猫 71d82e29b0 fix(terminal): keep alt-screen password latch fail-closed (#4107)
Fix P1-1 by removing the blanket alternate-screen bypass for heuristic sensitive prompts. Password prompts remain closed on every screen unless the user enables broadcastPasswordBypass; explicit plugin protection keeps its existing behavior.

Fix P1-2 by clearing primary-screen heuristic latches only at a confirmed shell prompt, retaining protection through background output. Preserve vim ex/redraw latch recovery on the alternate screen and authoritative plugin sensitivity.

Cover sensitive broadcast gates, pending xterm parsing, bypass behavior, primary-screen background output, and alternate-screen vim/password regressions. All 64 focused tests and lint for changed files pass.
2026-11-04 11:13:57 +08:00
陈大猫 313a134298 fix(terminal): clear sticky broadcast latch on vim ex (#4107) 2026-11-04 10:59:27 +08:00
15 changed files with 499 additions and 35 deletions

No files matched your search

+49 -10
View File
@@ -2,6 +2,7 @@ import { publishTerminalSelectionTarget } from "../infrastructure/plugins/termin
import { usePluginTerminalSelection } from "../application/state/usePluginTerminalSelection";
import { clearTerminalBroadcastUserInput, markTerminalBroadcastUserInput } from "./terminal/runtime/terminalPacedBroadcast";
import { shouldBroadcastDuringSensitivePrompt } from "../domain/terminalBroadcast";
import { probeAltScreenScheduling } from "./terminal/runtime/terminalWriteCoalescer";
import { STORAGE_KEY_TERMINAL_BROADCAST_PASSWORD_BYPASS } from "../infrastructure/config/storageKeys";
import { createTerminalReflowReadingPosition } from "./terminal/terminalReflowReadingPosition";
import { resolveHostOs } from '../domain/host';
@@ -63,6 +64,7 @@ import { clearTerminalBootEpoch, setTerminalBootEpoch } from "../domain/terminal
import {
foldTerminalPromptSecurityOutput,
isConfirmedTerminalShellPrompt,
isSensitiveTerminalChallenge,
isUntrustedTerminalInputPrompt,
} from "../domain/terminalPromptSecurity";
import { listPasswordPromptFillCandidates } from "../domain/passwordPromptAssist";
@@ -464,6 +466,11 @@ const TerminalComponent: React.FC<TerminalProps> = ({
const sensitivePromptOutputTailRef = useRef("");
const heredocPromptOriginRef = useRef("");
const promptSecurityPendingCrRef = useRef(false);
const promptSecurityScreenRef = useRef<{
// Null until a raw transition or snapshot establishes authoritative screen state.
active: boolean | null;
incomplete: Parameters<typeof probeAltScreenScheduling>[1];
}>({ active: null, incomplete: null });
const [activeScriptRun, setActiveScriptRun] = useState<import('@/types/global/netcatty-bridge-script.d.ts').ScriptRun | undefined>(undefined);
const dismissedScriptRunIdsRef = useRef(new Set<string>());
@@ -506,6 +513,10 @@ const TerminalComponent: React.FC<TerminalProps> = ({
const availableFonts = useAvailableFonts();
const containerRef = useRef<HTMLDivElement>(null);
const termRef = useRef<XTerm | null>(null);
const isPromptSecurityAlternateScreenActive = useCallback(() => (
promptSecurityScreenRef.current.active
?? (termRef.current ? isTerminalAlternateScreenActive(termRef.current) : false)
), []);
const fitAddonRef = useRef<FitAddon | null>(null);
const serializeAddonRef = useRef<SerializeAddon | null>(null);
const searchAddonRef = useRef<SearchAddon | null>(null);
@@ -1117,6 +1128,8 @@ const TerminalComponent: React.FC<TerminalProps> = ({
shouldBroadcastDuringSensitivePrompt({
sensitivePromptActive: sensitive,
broadcastPasswordBypass: broadcastPasswordBypassRef.current,
sensitivePromptSource: passwordPromptActiveRef.source,
alternateScreen: isPromptSecurityAlternateScreenActive(),
})
&& isBroadcastEnabledRef.current
&& onBroadcastInputRef.current
@@ -1507,6 +1520,9 @@ const TerminalComponent: React.FC<TerminalProps> = ({
}, []);
const updateStatus = useCallback((next: TerminalSession["status"]) => {
if (next === "disconnected") {
promptSecurityScreenRef.current = { active: null, incomplete: null };
}
statusRef.current = next;
setStatus(next);
hasConnectedRef.current = next === "connected";
@@ -1728,6 +1744,10 @@ const TerminalComponent: React.FC<TerminalProps> = ({
throw new Error("Terminal output did not settle before applying the snapshot");
}
}
promptSecurityScreenRef.current = {
active: payload.alternateScreen,
incomplete: null,
};
if (typeof payload.perCharacterInput === "boolean") {
restoreTerminalPerCharacterInput(terminalSettingsRef, payload.perCharacterInput);
xtermRuntimeRef.current?.restorePerCharacterInput(payload.perCharacterInput);
@@ -2061,6 +2081,14 @@ const TerminalComponent: React.FC<TerminalProps> = ({
chunk: string,
meta?: TerminalSessionDataMeta,
) => {
// PTY output reaches this observer before coalesced writes finish parsing.
const screenProbe = probeAltScreenScheduling(chunk, promptSecurityScreenRef.current.incomplete);
promptSecurityScreenRef.current = {
active: screenProbe.lastTransition === null
? promptSecurityScreenRef.current.active
: screenProbe.lastTransition === "enter",
incomplete: screenProbe.incomplete,
};
const folded = foldTerminalPromptSecurityOutput({
origin: heredocPromptOriginRef.current,
current: sensitivePromptOutputTailRef.current,
@@ -2071,9 +2099,9 @@ const TerminalComponent: React.FC<TerminalProps> = ({
promptSecurityPendingCrRef.current = folded.pendingCr;
const promptSecurityOptions = {
allowHostStyleGreaterThan: isNetworkDevice,
alternateScreen: termRef.current
alternateScreen: promptSecurityScreenRef.current.active ?? (termRef.current
? isTerminalAlternateScreenActive(termRef.current)
: false,
: false),
};
if (typeof meta?.pluginPipelineSensitiveInput === "boolean") {
passwordPromptActiveRef.current = meta.pluginPipelineSensitiveInput;
@@ -2088,17 +2116,24 @@ const TerminalComponent: React.FC<TerminalProps> = ({
promptSecurityPendingCrRef.current = false;
}
return;
} else if (isUntrustedTerminalInputPrompt(
folded.promptText,
promptSecurityOptions,
)) {
} else if (passwordPromptActiveRef.current && passwordPromptActiveRef.source === "explicit") {
return;
} else if (isSensitiveTerminalChallenge(folded.promptText)
|| (!(promptSecurityOptions.alternateScreen && /^:/.test(folded.promptText.trimStart()))
&& isUntrustedTerminalInputPrompt(
folded.promptText,
promptSecurityOptions,
))) {
passwordPromptActiveRef.current = true;
passwordPromptActiveRef.source = "heuristic";
autocompleteCloseRef.current?.();
} else if (isConfirmedTerminalShellPrompt(
} else if ((promptSecurityOptions.alternateScreen && /^:/.test(folded.promptText.trimStart())) || isConfirmedTerminalShellPrompt(
folded.promptText,
promptSecurityOptions,
)) {
// Vim ex can end a stale heuristic latch on the alternate screen (#4107).
// Other output must confirm a shell prompt: background job output can
// arrive while a password is still expected, including inside tmux.
passwordPromptActiveRef.current = false;
passwordPromptActiveRef.source = "heuristic";
}
@@ -3449,6 +3484,8 @@ const TerminalComponent: React.FC<TerminalProps> = ({
shouldBroadcastDuringSensitivePrompt({
sensitivePromptActive: passwordPromptActiveRef.current,
broadcastPasswordBypass: broadcastPasswordBypassRef.current,
sensitivePromptSource: options?.sensitive === true ? "explicit" : passwordPromptActiveRef.source,
alternateScreen: isPromptSecurityAlternateScreenActive(),
})
&& sessionRef.current
&& isBroadcastEnabledRef.current
@@ -3466,7 +3503,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
return true;
}
return false;
}, [sessionId]);
}, [isPromptSecurityAlternateScreenActive, sessionId]);
const executeSnippetCommand = useCallback(async (
command: string,
@@ -3544,6 +3581,8 @@ const TerminalComponent: React.FC<TerminalProps> = ({
shouldBroadcastDuringSensitivePrompt({
sensitivePromptActive: sensitive,
broadcastPasswordBypass: broadcastPasswordBypassRef.current,
sensitivePromptSource: options?.sensitive === true ? "explicit" : passwordPromptActiveRef.source,
alternateScreen: isPromptSecurityAlternateScreenActive(),
})
&& broadcastThisSend
&& onBroadcastInputRef.current
@@ -3584,7 +3623,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
term.focus();
}
return true;
}, [prepareProgrammaticSudoInput, scrollToBottomAfterProgrammaticInput, terminalBackend, sessionId, host.protocol, serialConfig?.lineMode]);
}, [isPromptSecurityAlternateScreenActive, prepareProgrammaticSudoInput, scrollToBottomAfterProgrammaticInput, terminalBackend, sessionId, host.protocol, serialConfig?.lineMode]);
const executeSnippet = useCallback(async (snippet: Snippet) => {
if (isScriptSnippet(snippet)) {
@@ -4840,7 +4879,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
onWake: wakeFromHibernateRuntime,
});
useTerminalEffects({ CONNECTION_TIMEOUT, Error, XTERM_PERFORMANCE_CONFIG, applyUserCursorPreference, applyUserCursorWidthPreference, shouldApplyUserCursorPreference, snapshotUserCursorPreference, auth, autocompleteCloseRef, autocompleteInputRef, autocompleteKeyEventRef, autocompleteRepositionRef, captureTerminalLogData, chainHosts: resolvedChainHosts, chainProgress, clearTerminalCwd, commandBufferRef, connectionLogBufferRef, containerRef, createPromptLineBreakState, createReplaySafeTerminalLogSanitizer, createXTermRuntime, deferTerminalResizeRef, disableTerminalFontZoomRef, effectiveFontSize, effectiveFontWeight, effectiveTheme, error, executeSnippetCommand, finalizeTerminalLogData, fitAddonRef, fontFamilyId, fontSize, fontWeightFixupDoneRef, forceCloseHibernatedSession, forceSyncRenderAfterResize, handleOsc52ReadRequest, handleTerminalDataCaptureOnce, hasConnectedRef, hasRuntimeRef, host, hotkeySchemeRef, hibernatedRef, identities, inWorkspace, isBootActiveRef, bootEpochRef, isBroadcastEnabledRef, broadcastPasswordBypassRef, isComposeBarOpen: effectiveComposeBarOpen, isConnectionAwaitingUserInput, isConnectionPastTcpDial, isFocusMode, isFocused, isLocalConnection, isNetworkDevice, isResizing: deferTerminalResize, isRestoringSelectionRef, isSearchOpen, isSerialConnection, isVisible, isVisibleRef, keyBindingsRef, keys, kittyKeyboardProtocolEnabledForSession, knownCwdRef, lastFittedSizeRef, lastToastedErrorRef, logger, mouseTrackingRef, needsHostKeyVerification, onBroadcastInputRef, onBroadcastInterruptPriorityChange, onCommandExecuted, onCommandSubmitted: cwdAwareOnCommandSubmitted, onCommandSettled: onCommandSubmitted, onHotkeyActionRef, onOpenExternalError, onOutputTriggerUserInputRef: noteOutputTriggerUserInputRef, onPluginRuntimeCwdChange: pluginAwareOnRuntimeCwdChange, onSnippetShortkeyRef, onSnippetExecutorChange, onTerminalCwdChange, onTerminalTitleChange, onTerminalBell, onTerminalFontSizeChange, paneLayoutKey, passwordPromptActiveRef, pendingAuthRef, pendingOutputScrollRef, pluginDecorationRefreshRef, pluginDecorationRules, pluginDecorationRulesRef, pluginTerminalLifecycle, pluginTerminalProviderRevision, isPluginTerminalProviderAvailable, requestPluginTerminalProviders, prepareRestoredReconnect, prepareInitialCwdIntent, prevIsResizingRef, promptLineBreakStateRef, resizeSession, resolveHostAuth, resolvedFontFamily, safeFit, scriptRecorderRef: recorderRef, searchAddonRef, serialConfig, serialLineBufferRef, serializeAddonRef, sessionId, sessionRef, sessionStarters, setError, setHasMouseTracking, setIsCancelling, setIsDisconnectedDialogDismissed, requestSearchFocus, setNeedsHostKeyVerification, setPendingHostKeyInfo, setPendingHostKeyRequestId, setProgressLogs, setProgressValue, setShowLogs, setStatus, setTimeLeft, shellType, shouldEnableNativeUserInputAutoScroll, shouldProbeSessionCwd, shouldStartTerminalBackend, vaultInitialized, attachExistingSession, attachAuthorization, attachHomeWebContentsIdRef, snippetsRef, splitResizeActive: isResizing, status, statusRef, sudoAutofillRef, t, teardown, telnetLocalEchoRef, termRef, terminalAltKeyOptions, terminalBackend, terminalContextActionsRef, terminalCwdTracker, terminalDataCapturedRef, terminalLogSanitizerRef, terminalOutputHistory: terminalOutputHistoryRef.current, terminalSettings, terminalSettingsRef, terminalTitleRef, toHostKeyInfo, toast, updateStatus, useEffect, useLayoutEffect, workspaceId, xtermRuntimeRef, zmodem, zmodemToastedRef, restoreState });
useTerminalEffects({ isPromptSecurityAlternateScreenActive, CONNECTION_TIMEOUT, Error, XTERM_PERFORMANCE_CONFIG, applyUserCursorPreference, applyUserCursorWidthPreference, shouldApplyUserCursorPreference, snapshotUserCursorPreference, auth, autocompleteCloseRef, autocompleteInputRef, autocompleteKeyEventRef, autocompleteRepositionRef, captureTerminalLogData, chainHosts: resolvedChainHosts, chainProgress, clearTerminalCwd, commandBufferRef, connectionLogBufferRef, containerRef, createPromptLineBreakState, createReplaySafeTerminalLogSanitizer, createXTermRuntime, deferTerminalResizeRef, disableTerminalFontZoomRef, effectiveFontSize, effectiveFontWeight, effectiveTheme, error, executeSnippetCommand, finalizeTerminalLogData, fitAddonRef, fontFamilyId, fontSize, fontWeightFixupDoneRef, forceCloseHibernatedSession, forceSyncRenderAfterResize, handleOsc52ReadRequest, handleTerminalDataCaptureOnce, hasConnectedRef, hasRuntimeRef, host, hotkeySchemeRef, hibernatedRef, identities, inWorkspace, isBootActiveRef, bootEpochRef, isBroadcastEnabledRef, broadcastPasswordBypassRef, isComposeBarOpen: effectiveComposeBarOpen, isConnectionAwaitingUserInput, isConnectionPastTcpDial, isFocusMode, isFocused, isLocalConnection, isNetworkDevice, isResizing: deferTerminalResize, isRestoringSelectionRef, isSearchOpen, isSerialConnection, isVisible, isVisibleRef, keyBindingsRef, keys, kittyKeyboardProtocolEnabledForSession, knownCwdRef, lastFittedSizeRef, lastToastedErrorRef, logger, mouseTrackingRef, needsHostKeyVerification, onBroadcastInputRef, onBroadcastInterruptPriorityChange, onCommandExecuted, onCommandSubmitted: cwdAwareOnCommandSubmitted, onCommandSettled: onCommandSubmitted, onHotkeyActionRef, onOpenExternalError, onOutputTriggerUserInputRef: noteOutputTriggerUserInputRef, onPluginRuntimeCwdChange: pluginAwareOnRuntimeCwdChange, onSnippetShortkeyRef, onSnippetExecutorChange, onTerminalCwdChange, onTerminalTitleChange, onTerminalBell, onTerminalFontSizeChange, paneLayoutKey, passwordPromptActiveRef, pendingAuthRef, pendingOutputScrollRef, pluginDecorationRefreshRef, pluginDecorationRules, pluginDecorationRulesRef, pluginTerminalLifecycle, pluginTerminalProviderRevision, isPluginTerminalProviderAvailable, requestPluginTerminalProviders, prepareRestoredReconnect, prepareInitialCwdIntent, prevIsResizingRef, promptLineBreakStateRef, resizeSession, resolveHostAuth, resolvedFontFamily, safeFit, scriptRecorderRef: recorderRef, searchAddonRef, serialConfig, serialLineBufferRef, serializeAddonRef, sessionId, sessionRef, sessionStarters, setError, setHasMouseTracking, setIsCancelling, setIsDisconnectedDialogDismissed, requestSearchFocus, setNeedsHostKeyVerification, setPendingHostKeyInfo, setPendingHostKeyRequestId, setProgressLogs, setProgressValue, setShowLogs, setStatus, setTimeLeft, shellType, shouldEnableNativeUserInputAutoScroll, shouldProbeSessionCwd, shouldStartTerminalBackend, vaultInitialized, attachExistingSession, attachAuthorization, attachHomeWebContentsIdRef, snippetsRef, splitResizeActive: isResizing, status, statusRef, sudoAutofillRef, t, teardown, telnetLocalEchoRef, termRef, terminalAltKeyOptions, terminalBackend, terminalContextActionsRef, terminalCwdTracker, terminalDataCapturedRef, terminalLogSanitizerRef, terminalOutputHistory: terminalOutputHistoryRef.current, terminalSettings, terminalSettingsRef, terminalTitleRef, toHostKeyInfo, toast, updateStatus, useEffect, useLayoutEffect, workspaceId, xtermRuntimeRef, zmodem, zmodemToastedRef, restoreState });
return (
<>
@@ -1,3 +1,5 @@
import { shouldBroadcastDuringSensitivePrompt } from "../../../domain/terminalBroadcast";
import type { SensitiveInputActiveRef } from "../autocomplete/terminalAutocompletePrompt";
import { clearTerminalBroadcastUserInput, captureTerminalBroadcastInput, isTerminalBroadcastInputCurrent, markTerminalBroadcastUserInput, type TerminalPacedBroadcast } from "./terminalPacedBroadcast";
import { stringCellWidth } from "../autocomplete/terminalStringCellWidth";
import type { TerminalBroadcastInputOptions } from "../terminalHelpers";
@@ -408,7 +410,9 @@ export type CreateXTermRuntimeContext = {
recordEnter: (options?: { sensitive?: boolean }) => Promise<void>;
captureSubmittedLineRecorder?: () => ((line: string, options?: { sensitive?: boolean; includePendingInput?: boolean; consumePendingInput?: boolean }) => Promise<void>) | undefined;
} | undefined>;
passwordPromptActiveRef?: RefObject<boolean>;
passwordPromptActiveRef?: SensitiveInputActiveRef;
/** Includes raw PTY screen transitions not yet parsed by xterm. */
isPromptSecurityAlternateScreenActive?: () => boolean;
/**
* Opt-in "broadcast without password protection" (#3488). When true, an
* active password prompt no longer pauses raw-string broadcast fan-out.
@@ -1054,8 +1058,12 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
// classification carried in the paste options must still tag the fan-out.
const sourceSensitive = dispatchingFromPasswordPrompt || options?.sensitive === true;
if (
(!dispatchingFromPasswordPrompt
|| ctx.broadcastPasswordBypassRef?.current === true)
shouldBroadcastDuringSensitivePrompt({
sensitivePromptActive: dispatchingFromPasswordPrompt,
broadcastPasswordBypass: ctx.broadcastPasswordBypassRef?.current === true,
sensitivePromptSource: ctx.passwordPromptActiveRef?.source,
alternateScreen: ctx.isPromptSecurityAlternateScreenActive?.() || term.buffer?.active.type === "alternate",
})
&& ctx.isBroadcastEnabledRef.current
&& ctx.onBroadcastInputRef.current
) {
@@ -1380,9 +1388,9 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
// reconnect may have cleared passwordPromptActiveRef while the dialog was
// open, so the live ref alone would downgrade the secret to nonsensitive.
// The override is consumed only for the flagged paste data.
const sensitive = ctx.passwordPromptActiveRef?.current === true
|| options?.sensitive === true
const carriedSensitive = options?.sensitive === true
|| shouldOverrideTerminalUserPasteSensitivity(term, logicalData ?? data);
const sensitive = ctx.passwordPromptActiveRef?.current === true || carriedSensitive;
if (!options?.lineDelayMs && logicalData !== null
&& !isPrintableInput(logicalData) && !isTerminalReportSequence(logicalData)) {
commandBufferRevision += 1;
@@ -1411,8 +1419,12 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
// The password bypass (#3488) only lifts the live password-prompt pause.
// Explicitly classified sensitive payloads (confirmed pastes, preloaded
// sudo credentials) stay non-broadcastable regardless of the opt-in.
const canBroadcastInput = (!sensitive
|| (ctx.broadcastPasswordBypassRef?.current === true && options?.sensitive !== true)) &&
const canBroadcastInput = shouldBroadcastDuringSensitivePrompt({
sensitivePromptActive: sensitive,
broadcastPasswordBypass: ctx.broadcastPasswordBypassRef?.current === true && options?.sensitive !== true,
sensitivePromptSource: carriedSensitive ? "explicit" : ctx.passwordPromptActiveRef?.source,
alternateScreen: ctx.isPromptSecurityAlternateScreenActive?.() || term.buffer?.active.type === "alternate",
}) &&
inputSource !== "kitty" &&
!handlingKittyBroadcast &&
!suppressTerminalBroadcast &&
@@ -1746,8 +1758,12 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
isHandlingBroadcast: () => handlingKittyBroadcast,
isBroadcastEnabled: () => ctx.isBroadcastEnabledRef.current,
// The #3488 bypass lifts the password-prompt pause for Kitty key fan-out too.
isSensitiveInput: () => ctx.passwordPromptActiveRef?.current === true
&& ctx.broadcastPasswordBypassRef?.current !== true,
isSensitiveInput: () => !shouldBroadcastDuringSensitivePrompt({
sensitivePromptActive: ctx.passwordPromptActiveRef?.current === true,
broadcastPasswordBypass: ctx.broadcastPasswordBypassRef?.current === true,
sensitivePromptSource: ctx.passwordPromptActiveRef?.source,
alternateScreen: ctx.isPromptSecurityAlternateScreenActive?.() || term.buffer?.active.type === "alternate",
}),
// Dispatching from an active prompt under the bypass must tag the payload
// so peer writes keep input interceptors skipped.
isSensitivePromptSource: () => ctx.passwordPromptActiveRef?.current === true,
@@ -10,7 +10,7 @@ const ts=require('typescript');
const bridge=require(root+'/electron/bridges/terminalBridge.cjs');
test('serial input combinations preserve the preceding device bytes after CJK deletion', async () => {
const modules = await Promise.all([
'components/terminal/runtime/terminalPacedBroadcast.ts', 'components/terminal/runtime/terminalReportSequence.ts', 'domain/serialCharMetrics.ts', 'components/terminal/runtime/terminalInputSanitize.ts',
'components/terminal/runtime/terminalPacedBroadcast.ts', 'components/terminal/runtime/terminalReportSequence.ts', 'domain/terminalBroadcast.ts', 'domain/serialCharMetrics.ts', 'components/terminal/runtime/terminalInputSanitize.ts',
'components/terminal/runtime/terminalBackspaceInput.ts', 'components/terminal/runtime/terminalPerCharacterInput.ts',
'components/terminal/runtime/terminalSudoAutofill.ts', 'components/terminal/runtime/terminalCommandExecution.ts',
'components/terminal/runtime/serialLocalEcho.ts', 'components/terminal/autocomplete/terminalStringCellWidth.ts',
@@ -269,7 +269,7 @@ test("runtime routes Shift+Enter text through the shared input handler", () => {
);
assert.match(
source,
/const canBroadcastInput = \(!sensitive\s*\|\|\s*\(ctx\.broadcastPasswordBypassRef\?\.current === true && options\?\.sensitive !== true\)\) &&[\s\S]*?const willBroadcastInput = canBroadcastInput && options\?\.skipBroadcast !== true;[\s\S]*?if \(!canBroadcastInput && !handlingKittyBroadcast\) \{\s*prepareSudoAutofillInput/s,
/const canBroadcastInput = shouldBroadcastDuringSensitivePrompt\(\{[\s\S]*?broadcastPasswordBypass: ctx\.broadcastPasswordBypassRef\?\.current === true && options\?\.sensitive !== true,[\s\S]*?\}\) &&[\s\S]*?const willBroadcastInput = canBroadcastInput && options\?\.skipBroadcast !== true;[\s\S]*?if \(!canBroadcastInput && !handlingKittyBroadcast\) \{\s*prepareSudoAutofillInput/s,
);
assert.match(
source,
@@ -107,6 +107,7 @@ test("attached popups retain the active mode across snapshots and return, includ
hibernateSnapshotRef: ref(""), hibernatePendingBufferRef: ref(""), hibernateViewportSnapshotRef: ref(""), hibernateScrollbackSnapshotRef: ref(""),
hibernateContextSnapshotRef: ref(""), hibernateContextViewportSnapshotRef: ref(""), hibernateContextScrollbackSnapshotRef: ref(""), hibernateAlternateScreenRef: ref(false),
kittyKeyboardProtocolEnabledForSession: false, passwordPromptActiveRef: ref(false), knownCwdRef: ref(null), terminalTitleRef: ref(null), pluginTerminalLifecycleRef: ref(null),
promptSecurityScreenRef: ref({ active: null, incomplete: null }),
onTerminalCwdChange: undefined, onTerminalTitleChange: undefined, terminalCwdTracker: { setRendererCwd: noop },
flushPendingTerminalWritesBeforeHibernate: async () => true, resetTerminalLineTimestamps: noop,
applyAuthoritativeHibernateSnapshot: noop, logger: { warn: noop },
@@ -147,6 +148,7 @@ test("attached popups retain the active mode across snapshots and return, includ
safeFit: noop, logger: { warn: noop, error: (...args: unknown[]) => errors.push(args) },
onPluginRuntimeCwdChange: noop, onOpenExternalError: noop, requestSearchFocus: noop,
requestPluginTerminalProviders: undefined, isPluginTerminalProviderAvailable: () => false,
isPromptSecurityAlternateScreenActive: undefined,
useEffect: (effect: React.EffectCallback, dependencies: React.DependencyList) => {
useEffect(() => {
if (effect.toString().includes("bootAbort")) return effect();
@@ -34,11 +34,11 @@ test("password-prompt input is classified before prompt state reset and cannot b
);
assert.match(
terminalSource,
/typeof meta\?\.pluginPipelineSensitiveInput === "boolean"[\s\S]*?passwordPromptActiveRef\.current = meta\.pluginPipelineSensitiveInput[\s\S]*?sensitivePromptOutputTailRef\.current = "";[\s\S]*?return;[\s\S]*?else if \(isUntrustedTerminalInputPrompt/u,
/typeof meta\?\.pluginPipelineSensitiveInput === "boolean"[\s\S]*?passwordPromptActiveRef\.current = meta\.pluginPipelineSensitiveInput[\s\S]*?sensitivePromptOutputTailRef\.current = "";[\s\S]*?return;[\s\S]*?isUntrustedTerminalInputPrompt/u,
);
assert.match(
runtimeSource,
/const sensitive = ctx\.passwordPromptActiveRef\?\.current === true[\s\S]*?const canBroadcastInput = \(!sensitive\s*\|\|\s*\(ctx\.broadcastPasswordBypassRef\?\.current === true && options\?\.sensitive !== true\)\) &&[\s\S]*?const willBroadcastInput = canBroadcastInput && options\?\.skipBroadcast !== true;/u,
/const sensitive = ctx\.passwordPromptActiveRef\?\.current === true[\s\S]*?const canBroadcastInput = shouldBroadcastDuringSensitivePrompt\(\{\s*sensitivePromptActive: sensitive,[\s\S]*?broadcastPasswordBypass: ctx\.broadcastPasswordBypassRef\?\.current === true && options\?\.sensitive !== true,[\s\S]*?\}\) &&[\s\S]*?const willBroadcastInput = canBroadcastInput && options\?\.skipBroadcast !== true;/u,
);
assert.match(
runtimeSource,
@@ -50,7 +50,7 @@ test("password-prompt input is classified before prompt state reset and cannot b
);
assert.match(
runtimeSource,
/const broadcastUserPasteData = \(\s*data: string,\s*options\?: TerminalBroadcastInputOptions,\s*\) => \{[\s\S]*?const dispatchingFromPasswordPrompt = ctx\.passwordPromptActiveRef\?\.current === true;[\s\S]*?const sourceSensitive = dispatchingFromPasswordPrompt \|\| options\?\.sensitive === true;[\s\S]*?\|\| ctx\.broadcastPasswordBypassRef\?\.current === true\)[\s\S]*?onBroadcastInputRef\.current\(\s*data,\s*ctx\.sessionId,\s*sourceSensitive[\s\S]*?sourceSensitive: true/u,
/const broadcastUserPasteData = \(\s*data: string,\s*options\?: TerminalBroadcastInputOptions,\s*\) => \{[\s\S]*?const dispatchingFromPasswordPrompt = ctx\.passwordPromptActiveRef\?\.current === true;[\s\S]*?const sourceSensitive = dispatchingFromPasswordPrompt \|\| options\?\.sensitive === true;[\s\S]*?broadcastPasswordBypass: ctx\.broadcastPasswordBypassRef\?\.current === true,[\s\S]*?onBroadcastInputRef\.current\(\s*data,\s*ctx\.sessionId,\s*sourceSensitive[\s\S]*?sourceSensitive: true/u,
);
assert.match(
terminalSource,
@@ -141,7 +141,7 @@ test("explicit plugin sensitivity is not treated as the heuristic prompt latch",
test("active and hibernated output share host-owned sensitive prompt classification", () => {
assert.match(
terminalSource,
/const observeTerminalInputPrompt = useCallback[\s\S]*?typeof meta\?\.pluginPipelineSensitiveInput === "boolean"[\s\S]*?passwordPromptActiveRef\.current = meta\.pluginPipelineSensitiveInput[\s\S]*?sensitivePromptOutputTailRef\.current = "";[\s\S]*?return;[\s\S]*?isConfirmedTerminalShellPrompt[\s\S]*?passwordPromptActiveRef\.current = false/u,
/const observeTerminalInputPrompt = useCallback[\s\S]*?typeof meta\?\.pluginPipelineSensitiveInput === "boolean"[\s\S]*?passwordPromptActiveRef\.current = meta\.pluginPipelineSensitiveInput[\s\S]*?sensitivePromptOutputTailRef\.current = "";[\s\S]*?return;[\s\S]*?isUntrustedTerminalInputPrompt[\s\S]*?passwordPromptActiveRef\.current = false/u,
);
assert.match(
terminalSource,
@@ -1,6 +1,8 @@
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import test from "node:test";
import { runInNewContext } from "node:vm";
import { shouldBroadcastDuringSensitivePrompt } from "../../../domain/terminalBroadcast.ts";
import { shouldBroadcastTerminalUserInput } from "./terminalUserPaste.ts";
@@ -39,3 +41,51 @@ test("keystroke fan-out stays on the toolbar gate and ignores the compose-bar ta
);
assert.doesNotMatch(keystrokeCall, /isComposeSendToAll/);
});
const runtimeSource = readFileSync(new URL("./createXTermRuntime.ts", import.meta.url), "utf8");
const gateStart = runtimeSource.indexOf(" const canBroadcastInput =");
const gateEnd = runtimeSource.indexOf(" const willBroadcastInput =", gateStart);
assert.ok(gateStart > 0 && gateEnd > gateStart);
const gateCode = runtimeSource.slice(gateStart, gateEnd) + "\ncanBroadcastInput;";
const kittyStart = runtimeSource.indexOf(" isSensitiveInput: () => !shouldBroadcastDuringSensitivePrompt(");
const kittyEnd = runtimeSource.indexOf(" // Dispatching from an active prompt", kittyStart);
assert.ok(kittyStart > 0 && kittyEnd > kittyStart);
const kittyCode = "({" + runtimeSource.slice(kittyStart, kittyEnd) + "}).isSensitiveInput()";
for (const alternateScreen of [false, true]) {
for (const source of ['heuristic', 'explicit'] as const) {
test(`raw and Kitty broadcast respect ${source} sensitivity on ${alternateScreen ? 'alternate' : 'primary'} screen`, () => {
const context = {
ctx: {
passwordPromptActiveRef: { current: true, source },
broadcastPasswordBypassRef: { current: false },
isBroadcastEnabledRef: { current: true },
},
term: { buffer: { active: { type: alternateScreen ? 'alternate' : 'normal' } } },
sensitive: true, carriedSensitive: false,
options: undefined, inputSource: 'terminal', handlingKittyBroadcast: false,
suppressTerminalBroadcast: false, id: 'session', broadcastDataBeforeSudo: 'password',
onBroadcastInput: () => {},
shouldBroadcastDuringSensitivePrompt, shouldBroadcastTerminalUserInput,
};
assert.equal(runInNewContext(gateCode, context), false);
assert.equal(runInNewContext(kittyCode, context), true);
const pendingParseContext = {
...context,
term: { buffer: { active: { type: 'normal' } } },
ctx: { ...context.ctx, isPromptSecurityAlternateScreenActive: () => true },
};
assert.equal(runInNewContext(gateCode, pendingParseContext), false);
assert.equal(runInNewContext(kittyCode, pendingParseContext), true);
const bypassContext = {
...context,
ctx: { ...context.ctx, broadcastPasswordBypassRef: { current: true } },
};
assert.equal(runInNewContext(gateCode, bypassContext), true);
assert.equal(runInNewContext(kittyCode, bypassContext), false);
assert.equal(runInNewContext(gateCode, { ...context, carriedSensitive: true }), false,
'a carried sensitive paste stays closed even on the alternate screen');
});
}
}
@@ -26,7 +26,7 @@ const code = ts.transpileModule(
{ compilerOptions: { target: ts.ScriptTarget.ES2022 } },
).outputText;
const helpers = await Promise.all([
"../../../domain/serialCharMetrics.ts", "./terminalReportSequence.ts", "./terminalInputSanitize.ts",
"../../../domain/terminalBroadcast.ts", "../../../domain/serialCharMetrics.ts", "./terminalReportSequence.ts", "./terminalInputSanitize.ts",
"./terminalBackspaceInput.ts", "./terminalPerCharacterInput.ts",
"./terminalSudoAutofill.ts", "./terminalCommandExecution.ts",
"./terminalPacedBroadcast.ts", "./terminalInterruptInputState.ts", "./serialLocalEcho.ts", "../autocomplete/terminalStringCellWidth.ts",
@@ -67,7 +67,7 @@ type AltScreenProbeResult = {
* DECSET modes that enter/leave the alternate screen. Resumes from incomplete
* parser state so long param lists can span PTY/schedule flushes.
*/
const probeAltScreenScheduling = (
export const probeAltScreenScheduling = (
data: string,
resume: IncompletePrivateCsi | null,
): AltScreenProbeResult => {
@@ -1,6 +1,13 @@
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import test from 'node:test';
import { runInNewContext } from 'node:vm';
import ts from 'typescript';
import { shouldBroadcastDuringSensitivePrompt } from '../../domain/terminalBroadcast.ts';
import { foldTerminalPromptSecurityOutput, isConfirmedTerminalShellPrompt, isSensitiveTerminalChallenge, isUntrustedTerminalInputPrompt } from '../../domain/terminalPromptSecurity.ts';
import { applyAuthoritativeHibernateSnapshot } from './terminalHibernateRuntime.ts';
import { probeAltScreenScheduling } from './runtime/terminalWriteCoalescer.ts';
import type { SensitiveInputActiveRef } from './autocomplete/terminalAutocompletePrompt.ts';
import { applyTerminalKeywordHighlightRules } from './terminalKeywordHighlightRules.ts';
@@ -170,11 +177,10 @@ test('password-prompt input is consumed before every semantic command callback',
test('terminal output treats unknown prompt-shaped input boundaries as sensitive', () => {
assert.match(
terminalSource,
/const promptSecurityOptions = \{\s*allowHostStyleGreaterThan: isNetworkDevice,\s*alternateScreen: termRef\.current\s*\? isTerminalAlternateScreenActive\(termRef\.current\)\s*: false,\s*\};/,
/const promptSecurityOptions = \{\s*allowHostStyleGreaterThan: isNetworkDevice,\s*alternateScreen: promptSecurityScreenRef\.current\.active \?\? \(termRef\.current\s*\? isTerminalAlternateScreenActive\(termRef\.current\)\s*: false\),\s*\};/,
);
assert.match(terminalSource, /isUntrustedTerminalInputPrompt\([\s\S]*?promptSecurityOptions/);
assert.match(terminalSource, /passwordPromptActiveRef\.current = true;[\s\S]*?autocompleteCloseRef\.current\?\.\(\);/);
assert.match(terminalSource, /isConfirmedTerminalShellPrompt\([\s\S]*?passwordPromptActiveRef\.current = false;/);
});
test('terminal view derives the network-device prompt policy before autocomplete renders', () => {
@@ -238,3 +244,291 @@ test('normal boot and hibernate wake share terminal link error feedback', () =>
/createXTermRuntime\(\{[\s\S]*?onOpenExternalError,/,
);
});
// Exercise the actual observer without mounting the terminal renderer/addon stack.
const observerStart = terminalSource.indexOf(' const observeTerminalInputPrompt = useCallback(');
const observerEnd = terminalSource.indexOf(' const beginHibernatedSessionListeners', observerStart);
assert.ok(observerStart > 0 && observerEnd > observerStart);
const observerCode = ts.transpileModule(
terminalSource.slice(observerStart, observerEnd) + '\nobserveTerminalInputPrompt;',
{ compilerOptions: { target: ts.ScriptTarget.ES2022 } },
).outputText;
function promptObserver(bufferAlternate = false) {
const latch: SensitiveInputActiveRef = { current: false, source: 'heuristic' };
const screen = { current: { active: null as boolean | null, incomplete: null } };
const context = {
useCallback: (callback: unknown) => callback,
isNetworkDevice: false,
promptSecurityScreenRef: screen,
sensitivePromptOutputTailRef: { current: '' },
heredocPromptOriginRef: { current: '' },
promptSecurityPendingCrRef: { current: false },
passwordPromptActiveRef: latch,
autocompleteCloseRef: { current: () => {} },
termRef: { current: {} },
isTerminalAlternateScreenActive: () => bufferAlternate,
probeAltScreenScheduling,
foldTerminalPromptSecurityOutput,
isConfirmedTerminalShellPrompt,
isSensitiveTerminalChallenge,
isUntrustedTerminalInputPrompt,
};
const observe = runInNewContext(observerCode, context) as (
chunk: string, meta?: { pluginPipelineSensitiveInput: boolean },
) => void;
return { latch, screen, observe, context };
}
test('raw alternate-screen entry clears a sticky vim latch before xterm parses (#4107)', () => {
for (const mode of ['47', '1047', '1049']) {
const entry = `\x1b[?${mode}h`;
for (let split = 1; split < entry.length; split++) {
const { latch, screen, observe } = promptObserver();
observe(':');
assert.equal(latch.current, true, 'primary-screen sudo colon stays closed');
observe(entry.slice(0, split));
observe(entry.slice(split) + '\r:');
assert.equal(screen.current.active, true);
assert.equal(latch.current, false, `${mode} entry split at ${split}`);
for (const output of ['q', '!', '\r:q:', '\r-- INSERT --', 'text']) {
observe(output);
assert.equal(latch.current, false, output);
}
observe(`\x1b[?${mode}l\rPassword:`);
assert.equal(screen.current.active, false);
assert.equal(latch.current, true, 'password protection resumes after vim exits');
}
}
});
test('parsed alternate-screen vim ex clears stale heuristic latches and keeps broadcasting (#4107)', () => {
for (const output of [':', ':q:']) {
const alternate = promptObserver(true);
alternate.latch.current = true;
alternate.observe(output);
assert.equal(alternate.latch.current, false, output);
alternate.observe('\r:'); // Delayed echo of the broadcast ex command opener.
for (const input of ['q!', '\r', '\x1b', 'i']) {
assert.equal(shouldBroadcastDuringSensitivePrompt({
sensitivePromptActive: alternate.latch.current,
broadcastPasswordBypass: false,
}), true, input);
alternate.observe(input === 'i' ? '\r-- INSERT --' : input);
assert.equal(alternate.latch.current, false, input);
}
alternate.observe('\r\x1b[2K');
assert.equal(alternate.latch.current, false, 'redraw after ex keeps the latch clear');
}
});
test('real vim CUP/EL redraws keep ex input broadcasting and clear stale heuristic latches (#4107)', () => {
const colon = '\x1b[?25l\x1b[24;1H\x1b[K\x1b[24;1H:\x1b[?25h';
const redraws = [
[colon],
['\x1b[?25l\x1b[24;', '1H', '\x1b[', 'K', ':', '\x1b[?25h'],
...Array.from({ length: colon.length - 1 }, (_, i) => [colon.slice(0, i + 1), colon.slice(i + 1)]),
];
for (const stale of [false, true]) {
for (const chunks of redraws) {
const { latch, screen, observe, context } = promptObserver();
observe('\x1b[?1049h~\x1b[2;1H~\x1b[24;1Hsmoke4107-broadcast\b');
assert.equal(screen.current.active, true);
assert.equal(latch.current, false, 'vim chrome alone is not a prompt');
if (stale) observe('\x1b[24;1HPassword:');
assert.equal(latch.current, stale);
for (const chunk of chunks) observe(chunk);
assert.equal(latch.current, false, JSON.stringify(chunks));
assert.equal(context.sensitivePromptOutputTailRef.current.includes('smoke4107'), false);
for (const input of ['q', '!', '\r']) {
assert.equal(shouldBroadcastDuringSensitivePrompt({
sensitivePromptActive: latch.current,
broadcastPasswordBypass: false,
}), true, input);
observe(input);
assert.equal(latch.current, false);
}
observe('\x1b[?1049l\x1b[24;1H\x1b[K:');
assert.equal(screen.current.active, false);
assert.equal(latch.current, true, 'raw screen leave restores primary colon protection');
assert.equal(shouldBroadcastDuringSensitivePrompt({
sensitivePromptActive: latch.current,
broadcastPasswordBypass: false,
}), false);
}
}
});
test('CUP/EL redraws retain alternate-screen challenge and explicit plugin protection', () => {
for (const label of [': Password: ', ': Verification code: ', 'Custom authentication> ']) {
const { latch, observe } = promptObserver();
observe('\x1b[?1049h~smoke4107-broadcast\b');
for (const chunk of ['\x1b[24;', '1H\x1b[', 'K', label]) observe(chunk);
assert.equal(latch.current, true, label);
assert.equal(shouldBroadcastDuringSensitivePrompt({
sensitivePromptActive: latch.current,
broadcastPasswordBypass: false,
}), false, label);
}
const { latch, observe } = promptObserver();
observe('\x1b[?1049h~smoke4107-broadcast\b', { pluginPipelineSensitiveInput: true });
for (const chunk of ['\x1b[24;', '1H\x1b[K', ':', 'q!']) observe(chunk);
assert.equal(latch.current, true);
assert.equal(latch.source, 'explicit');
assert.equal(shouldBroadcastDuringSensitivePrompt({
sensitivePromptActive: latch.current,
broadcastPasswordBypass: false,
}), false);
});
test('raw screen exits retain primary-screen colon protection while xterm still reports alternate', () => {
for (const mode of ['47', '1047', '1049']) {
const exit = `\x1b[?${mode}l`;
for (let split = 0; split < exit.length; split++) {
const { latch, screen, observe } = promptObserver(true);
observe(`\x1b[?${mode}h\rPassword: `);
assert.equal(latch.current, true);
observe(exit.slice(0, split));
observe(exit.slice(split) + '\r:');
assert.equal(screen.current.active, false);
assert.equal(latch.current, true, `${mode} exit split at ${split}`);
observe('q');
assert.equal(latch.current, true, 'the observed exit remains authoritative on later output');
assert.equal(shouldBroadcastDuringSensitivePrompt({
sensitivePromptActive: latch.current,
broadcastPasswordBypass: false,
}), false, 'primary-screen colon input must not reach peers');
}
}
});
test('primary-screen colon prompts retain protection through unrelated echo', () => {
const primary = promptObserver();
primary.observe(':');
assert.equal(primary.latch.current, true);
primary.observe('q');
assert.equal(primary.latch.current, true, 'unrelated echo must not clear primary-screen protection');
});
test('primary-screen background output retains sensitivity until a confirmed shell prompt', () => {
const primary = promptObserver();
for (const label of ['Password:', 'Token:']) {
primary.observe('\r' + label);
assert.equal(primary.latch.current, true);
for (const output of ['secret', '\r\n[1] background job completed\r\n', '\x1b[2K', 'ordinary output']) {
primary.observe(output);
assert.equal(primary.latch.current, true, output);
}
primary.observe('\r\nuser@host:~$ ');
assert.equal(primary.latch.current, false, 'a confirmed shell prompt clears the heuristic latch');
}
});
test('alternate-screen password prompts block broadcast through background output', () => {
for (const bufferAlternate of [false, true]) {
const { latch, observe } = promptObserver(bufferAlternate);
if (!bufferAlternate) observe('\x1b[?1049h');
observe('\rPass');
for (const output of ['word: ', '\r\nbackground job output\r\n', '\x1b[2K', 'ordinary output', 'secret']) {
observe(output);
assert.equal(latch.current, true, output);
assert.equal(latch.source, 'heuristic');
assert.equal(shouldBroadcastDuringSensitivePrompt({
sensitivePromptActive: latch.current,
broadcastPasswordBypass: false,
}), false, 'password keystrokes must not reach peers');
}
}
});
test('colon-prefixed authentication prompts take precedence over alternate-screen vim ex', () => {
for (const bufferAlternate of [false, true]) {
for (const label of [': Password: ', ': Passphrase: ', ': Verification code: ', ': Token: ']) {
const { latch, observe } = promptObserver(bufferAlternate);
if (!bufferAlternate) observe('\x1b[?1049h');
observe('\r\n' + label);
assert.equal(latch.current, true, label);
assert.equal(latch.source, 'heuristic');
for (const output of ['secret', '\r\nbackground job output\r\n', '\x1b[2K']) {
observe(output);
assert.equal(latch.current, true, output);
assert.equal(shouldBroadcastDuringSensitivePrompt({
sensitivePromptActive: latch.current,
broadcastPasswordBypass: false,
}), false, 'colon-prefixed password input must not reach peers');
}
}
}
});
test('authoritative display snapshots synchronize the prompt screen probe before subsequent output', async () => {
const handlerStart = terminalSource.indexOf('async (payload) => {',
terminalSource.indexOf('if (bridge?.onTerminalSessionApplySnapshot)'));
const handlerEnd = terminalSource.indexOf('}));', handlerStart);
assert.ok(handlerStart > 0 && handlerEnd > handlerStart);
const handlerCode = ts.transpileModule(
'const applySnapshot = ' + terminalSource.slice(handlerStart, handlerEnd + 1) + ';\napplySnapshot;',
{ compilerOptions: { target: ts.ScriptTarget.ES2022 } },
).outputText;
for (const mounted of [true, false]) {
for (const alternateScreen of [false, true]) {
const { latch, screen, observe, context } = promptObserver();
observe('\x1b[?1049h\r:');
observe('\x1b[?1049'); // A snapshot must also discard a partial pre-snapshot transition.
assert.equal(screen.current.active, true);
assert.notEqual(screen.current.incomplete, null);
const parsed = { alternate: true };
context.isTerminalAlternateScreenActive = () => parsed.alternate;
const term = {
reset: () => { parsed.alternate = false; },
write: (_snapshot: string, done: () => void) => {
parsed.alternate = alternateScreen;
done();
},
};
Object.assign(context.termRef, { current: mounted ? term : null });
const applySnapshot = runInNewContext(handlerCode, {
...context,
sessionId: 'session',
flushPendingTerminalWritesBeforeHibernate: async () => true,
resetTerminalLineTimestamps: () => {},
xtermRuntimeRef: { current: null },
hibernatedRef: { current: !mounted },
...Object.fromEntries([
'Snapshot', 'ViewportSnapshot', 'ScrollbackSnapshot', 'ContextSnapshot',
'ContextViewportSnapshot', 'ContextScrollbackSnapshot', 'PendingBuffer', 'AlternateScreen',
].map(name => [`hibernate${name}Ref`, { current: '' }])),
applyAuthoritativeHibernateSnapshot,
}) as (payload: unknown) => Promise<boolean>;
assert.equal(await applySnapshot({
sessionId: 'session', snapshot: 'restored display',
contextSnapshot: '', contextViewportSnapshot: '', contextScrollbackSnapshot: '',
alternateScreen, passwordPromptActive: false,
}), true);
assert.equal(screen.current.active, alternateScreen);
assert.equal(screen.current.incomplete, null);
observe('\r\n:');
assert.equal(latch.current, !alternateScreen, 'primary-screen sudo colon must stay protected');
assert.equal(shouldBroadcastDuringSensitivePrompt({
sensitivePromptActive: latch.current,
broadcastPasswordBypass: false,
}), alternateScreen);
}
}
});
test('explicit plugin sensitivity survives vim ex, redraws and shell prompts', () => {
const { latch, screen, observe } = promptObserver();
observe('Password:', { pluginPipelineSensitiveInput: true });
for (const output of ['\x1b[?1049h\r:', 'q!', '\r-- INSERT --', '\x1b[?1049l\ruser@host:~$ ']) {
observe(output);
assert.equal(latch.current, true);
assert.equal(latch.source, 'explicit');
}
assert.equal(screen.current.active, false, 'screen tracking also runs under explicit metadata');
observe('', { pluginPipelineSensitiveInput: false });
assert.equal(latch.current, false);
observe(':');
assert.equal(latch.current, true);
assert.equal(latch.source, 'heuristic');
});
@@ -577,6 +577,7 @@ export function useTerminalEffects(ctx: TerminalEffectsContext) {
promptLineBreakStateRef,
scriptRecorderRef,
passwordPromptActiveRef,
isPromptSecurityAlternateScreenActive: ctx.isPromptSecurityAlternateScreenActive,
allowHostStyleGreaterThanPrompt: isNetworkDevice,
onOutputTriggerUserInputRef,
sudoAutofillRef,
+17
View File
@@ -161,3 +161,20 @@ test('sensitive prompts pause broadcasting unless the password bypass is on', ()
true,
);
});
test('sensitive prompts stay fail-closed on every screen unless bypassed (#4107)', () => {
for (const sensitivePromptSource of [undefined, 'heuristic', 'explicit'] as const) {
for (const alternateScreen of [false, true]) {
for (const sensitivePromptActive of [false, true]) {
for (const broadcastPasswordBypass of [false, true]) {
assert.equal(shouldBroadcastDuringSensitivePrompt({
sensitivePromptActive,
broadcastPasswordBypass,
sensitivePromptSource,
alternateScreen,
}), !sensitivePromptActive || broadcastPasswordBypass);
}
}
}
}
});
+2
View File
@@ -11,6 +11,8 @@ type BroadcastSession = Pick<TerminalSession, 'id' | 'workspaceId' | 'hiddenFrom
export function shouldBroadcastDuringSensitivePrompt(input: {
sensitivePromptActive: boolean;
broadcastPasswordBypass: boolean;
sensitivePromptSource?: 'heuristic' | 'explicit';
alternateScreen?: boolean;
}): boolean {
return !input.sensitivePromptActive || input.broadcastPasswordBypass;
}
+30
View File
@@ -47,6 +47,36 @@ test('terminal prompt security combines split output chunks within a bounded log
assert.ok(tail.length <= 2_048);
});
const vimChrome = '\x1b[?1049h~\x1b[2;1H~\x1b[24;1Hsmoke4107-broadcast\b';
const vimColon = '\x1b[?25l\x1b[24;1H\x1b[K\x1b[24;1H:\x1b[?25h';
test('prompt security folds real vim CUP/EL colon redraws without retaining chrome (#4107)', () => {
// Try every transport boundary, including inside CUP and EL sequences.
for (let split = 0; split <= vimColon.length; split++) {
let state = foldTerminalPromptSecurityOutput({ origin: '', current: '' }, vimChrome);
state = foldTerminalPromptSecurityOutput(state, vimColon.slice(0, split));
state = foldTerminalPromptSecurityOutput(state, vimColon.slice(split));
assert.equal(state.promptText, ':', `split at ${split}`);
assert.equal(isUntrustedTerminalInputPrompt(state.promptText, { alternateScreen: true }), false);
assert.equal(isUntrustedTerminalInputPrompt(state.promptText), true, 'primary colon stays protected');
}
});
test('prompt security recognizes CUP/HVP column-one defaults and EL across chunks', () => {
for (const position of ['H', 'f', '24H', '24f', '24;H', '24;f', '24;1H', '24;1f', ';1H', '24;0H']) {
for (const erase of ['K', '1K', '2K']) {
let state = foldTerminalPromptSecurityOutput({ origin: '', current: '' }, vimChrome);
for (const chunk of ['\x1b[', position, '\x1b[', erase, ':']) {
state = foldTerminalPromptSecurityOutput(state, chunk);
}
assert.equal(state.promptText, ':', `${position} + ${erase}`);
}
}
const state = foldTerminalPromptSecurityOutput({ origin: '', current: 'Challenge' }, '\x1b[24;2H:');
assert.equal(state.promptText, 'Challenge:', 'other columns must not discard a challenge label');
assert.equal(isUntrustedTerminalInputPrompt(state.promptText, { alternateScreen: true }), true);
});
test('terminal prompt security positively confirms shell prompts and fails closed on ambiguous prompts', () => {
const confirmed = [
'$ ',
+19 -6
View File
@@ -1,6 +1,7 @@
const ESCAPE_SEQUENCE = "\\x" + "1b";
const BELL_SEQUENCE = "\\x" + "07";
const ANSI_CONTROL_PATTERN = new RegExp(`${ESCAPE_SEQUENCE}\\[[0-?]*[ -/]*[@-~]`, 'gu');
const CURSOR_POSITION_PATTERN = new RegExp(`${ESCAPE_SEQUENCE}\\[\\d*(?:;(\\d*))?[Hf]`, 'gu');
const OSC_CONTROL_PATTERN = new RegExp(
`${ESCAPE_SEQUENCE}\\][^${BELL_SEQUENCE}]*(?:${BELL_SEQUENCE}|${ESCAPE_SEQUENCE}\\\\)`,
'gu',
@@ -80,7 +81,9 @@ function nextHeredocPromptOrigin(origin: string, completedLine: string): string
}
/**
* Current logical line, plus the heredoc opener when the line is a bare ">".
* Current logical line with controls stripped for classification, plus the
* heredoc opener when the line is a bare ">". `current` stays raw so partial
* control sequences can be completed by the next transport chunk.
* `appendTerminalPromptSecurityTail` keeps only the current line, which is all
* `observeTerminalInputPrompt` used to pass in. The opener is the last
* completed non-PS2 line, so a long heredoc body does not have to be retained.
@@ -97,15 +100,25 @@ export function foldTerminalPromptSecurityOutput(
input = input.slice(1);
pendingCr = false;
}
const combined = `${state.current}${input}`;
// Vim redraws lines with CUP/HVP + EL instead of CR/LF (#4107). A move
// to column one (also the default for an omitted/zero column) starts a
// new security line, so editor chrome cannot become part of its ":" prompt.
// Match after joining the raw tail to handle CSI split across PTY chunks.
const combined = `${state.current}${input}`.replace(
CURSOR_POSITION_PATTERN,
(sequence, column: string | undefined) => Number(column || 1) <= 1 ? '\r' : sequence,
);
let origin = state.origin;
for (const line of splitTerminalLogicalLines(combined).slice(0, -1)) {
origin = nextHeredocPromptOrigin(origin, line);
}
const current = appendTerminalPromptSecurityTail(state.current, input);
const promptText = lastLogicalLine(current).trim() === '>' && origin
? `${origin}\n${current}`
: current;
const current = appendTerminalPromptSecurityTail('', combined);
// EL and cursor-visibility controls can precede/follow a colon in separate
// chunks. They must not hide vim ex from the observer's stale-latch reset.
const plain = stripTerminalControlSequences(current);
const promptText = plain.trim() === '>' && origin
? `${origin}\n${plain}`
: plain;
if (input.endsWith('\r')) pendingCr = true;
else if (input.length > 0) pendingCr = false;
return { origin, current, promptText, pendingCr };