mirror of
https://github.com/binaricat/Netcatty.git
synced 2026-11-10 16:21:22 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b7efbe347c | ||
|
|
64d2bc4c0b | ||
|
|
b8b63f73e0 | ||
|
|
8b2378e601 | ||
|
|
a1a0481eb1 | ||
|
|
71d82e29b0 | ||
|
|
313a134298 |
No files matched your search
+49
-10
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 = [
|
||||
'$ ',
|
||||
|
||||
@@ -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 };
|
||||
|
||||
Reference in new issue
Block a user