mirror of
https://github.com/binaricat/Netcatty.git
synced 2026-11-07 08:31:00 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c4eb08c64e | ||
|
|
5eb46538c4 | ||
|
|
84e4cd25c3 | ||
|
|
af55db31f4 | ||
|
|
43da1f9f7e | ||
|
|
853265b31a | ||
|
|
a190a6c81e | ||
|
|
9a86f1e414 | ||
|
|
5a8e2a6ff7 | ||
|
|
914c608cbd |
No files matched your search
+10
-1
@@ -165,6 +165,7 @@ import { registerTerminalSensitiveInputReader } from "./terminal/runtime/termina
|
||||
import { registerTerminalCommandInjectionReadyReader } from "./terminal/runtime/terminalCommandInjectionReadyRegistry";
|
||||
import { isIdleShellReadyForCommandInjection } from "../domain/terminalCommandInjectionReady";
|
||||
import { detectPrompt } from "./terminal/autocomplete/promptDetector";
|
||||
import type { SensitiveInputActiveRef } from "./terminal/autocomplete/terminalAutocompletePrompt";
|
||||
import {
|
||||
applyUserCursorPreference,
|
||||
applyUserCursorWidthPreference,
|
||||
@@ -439,7 +440,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
const recorder = useScriptRecorder(sessionId);
|
||||
const recorderRef = useRef(recorder);
|
||||
recorderRef.current = recorder;
|
||||
const passwordPromptActiveRef = useRef(false);
|
||||
const passwordPromptActiveRef = useRef(false) as SensitiveInputActiveRef;
|
||||
const terminalTitleRef = useRef<string | undefined>(undefined);
|
||||
const pluginTerminalLifecycleRef = useRef<ReturnType<typeof usePluginTerminalSessionLifecycle> | null>(null);
|
||||
useEffect(() => registerTerminalSensitiveInputReader(
|
||||
@@ -1695,6 +1696,9 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
}
|
||||
if (typeof payload.passwordPromptActive === "boolean") {
|
||||
passwordPromptActiveRef.current = payload.passwordPromptActive;
|
||||
// The snapshot boolean has no source. A restored true latch stays
|
||||
// explicit so Email:/Username: cannot bypass a plugin-marked field.
|
||||
if (payload.passwordPromptActive) passwordPromptActiveRef.source = "explicit";
|
||||
}
|
||||
if (payload.cwd !== undefined) {
|
||||
const cwd = terminalCwdTracker.setRendererCwd(payload.cwd, "snapshot");
|
||||
@@ -2013,6 +2017,9 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
};
|
||||
if (typeof meta?.pluginPipelineSensitiveInput === "boolean") {
|
||||
passwordPromptActiveRef.current = meta.pluginPipelineSensitiveInput;
|
||||
// Authoritative metadata must not be downgraded to the heuristic latch,
|
||||
// or trusted labels such as Email: would bypass snippet blocking.
|
||||
passwordPromptActiveRef.source = meta.pluginPipelineSensitiveInput ? "explicit" : "heuristic";
|
||||
if (meta.pluginPipelineSensitiveInput) {
|
||||
autocompleteCloseRef.current?.();
|
||||
} else {
|
||||
@@ -2024,12 +2031,14 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
promptSecurityOptions,
|
||||
)) {
|
||||
passwordPromptActiveRef.current = true;
|
||||
passwordPromptActiveRef.source = "heuristic";
|
||||
autocompleteCloseRef.current?.();
|
||||
} else if (isConfirmedTerminalShellPrompt(
|
||||
sensitivePromptOutputTailRef.current,
|
||||
promptSecurityOptions,
|
||||
)) {
|
||||
passwordPromptActiveRef.current = false;
|
||||
passwordPromptActiveRef.source = "heuristic";
|
||||
}
|
||||
}, [isNetworkDevice]);
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import { detectPrompt, getAlignedPrompt } from "./autocomplete/promptDetector.ts";
|
||||
import { detectPrompt, getAlignedPrompt, isProgramInputPromptText } from "./autocomplete/promptDetector.ts";
|
||||
import { resolveAutocompleteQueryInput } from "./autocomplete/terminalAutocompletePrompt.ts";
|
||||
import { getSnippetSuggestions } from "./autocomplete/snippetCompleter.ts";
|
||||
import { stringCellWidth } from "./autocomplete/terminalStringCellWidth.ts";
|
||||
@@ -817,3 +817,63 @@ test("CMD path prompts with CJK directories do not absorb padding into echoed Ch
|
||||
"zh",
|
||||
);
|
||||
});
|
||||
|
||||
test("program input prompts expose snippet text and stay out of shell history (#3622)", () => {
|
||||
const line = "Email: use";
|
||||
const term = createFakeTerm(line, line.length);
|
||||
const snippet = { id: "mail", label: "邮箱", command: "user@example.com", noAutoRun: true };
|
||||
|
||||
assert.equal(detectPrompt(term as never).isAtPrompt, false);
|
||||
|
||||
const echoed = getAlignedPrompt(term as never, "use", true);
|
||||
assert.equal(echoed.prompt.isAtPrompt, true);
|
||||
assert.equal(echoed.prompt.promptText, "Email: ");
|
||||
assert.equal(echoed.prompt.userInput, "use");
|
||||
assert.equal(echoed.alignedTyped, null);
|
||||
assert.equal(resolveAutocompleteQueryInput(echoed.prompt, "use", true), "use");
|
||||
assert.equal(
|
||||
getSnippetSuggestions(echoed.prompt.userInput, [snippet as never])[0]?.snippet?.command,
|
||||
"user@example.com",
|
||||
);
|
||||
assert.equal(getCommandToRecordOnEnter(echoed.prompt, echoed.alignedTyped, "use", true), null);
|
||||
|
||||
const pending = getAlignedPrompt(createFakeTerm("Email: ", "Email: ".length) as never, "use", true);
|
||||
assert.equal(pending.prompt.promptText, "Email: ");
|
||||
assert.equal(resolveAutocompleteQueryInput(pending.prompt, "use", true), "use");
|
||||
assert.equal(pending.alignedTyped, null);
|
||||
|
||||
const shell = "user@host:~$ git pull use";
|
||||
const shellPrompt = getAlignedPrompt(createFakeTerm(shell, shell.length) as never, "git pull use", true);
|
||||
assert.equal(shellPrompt.prompt.promptText, "user@host:~$ ");
|
||||
assert.equal(shellPrompt.prompt.userInput, "git pull use");
|
||||
assert.equal(shellPrompt.alignedTyped, "git pull use");
|
||||
});
|
||||
|
||||
test("a colon shell prompt is not a program input prompt (#3622)", () => {
|
||||
for (const prompt of ["dev: ", "development: ", "PS1: "]) {
|
||||
assert.equal(isProgramInputPromptText(prompt), false, prompt);
|
||||
const line = `${prompt}ls`;
|
||||
const term = createFakeTerm(line, line.length);
|
||||
const aligned = getAlignedPrompt(term as never, "ls", true);
|
||||
assert.equal(aligned.prompt.isAtPrompt, true, prompt);
|
||||
assert.equal(aligned.prompt.promptText, prompt, prompt);
|
||||
assert.equal(aligned.prompt.userInput, "ls", prompt);
|
||||
assert.equal(
|
||||
getCommandToRecordOnEnter(aligned.prompt, aligned.alignedTyped, "ls", true),
|
||||
"ls",
|
||||
prompt,
|
||||
);
|
||||
}
|
||||
|
||||
assert.equal(isProgramInputPromptText("Email: "), true);
|
||||
assert.equal(isProgramInputPromptText("Enter your username: "), true);
|
||||
assert.equal(isProgramInputPromptText("用户名: "), true);
|
||||
});
|
||||
|
||||
test("password, passphrase, PIN, and secret prompts stay uncompletable (#3622)", () => {
|
||||
for (const line of ["Password: use", "Passphrase: use", "PIN: use", "Secret: use", "Enter secret: use"]) {
|
||||
const result = getAlignedPrompt(createFakeTerm(line, line.length) as never, "use", true);
|
||||
assert.equal(result.prompt.isAtPrompt, false, line);
|
||||
assert.equal(resolveAutocompleteQueryInput(result.prompt, "use", true), null, line);
|
||||
}
|
||||
});
|
||||
@@ -31,10 +31,55 @@ export function isNonPromptLine(lineText: string): boolean {
|
||||
return NON_PROMPT_PATTERNS.some((pattern) => pattern.test(lineText));
|
||||
}
|
||||
|
||||
const PROGRAM_INPUT_LABEL = /^(?=[\p{L}\p{N}])[\p{L}\p{N} ._'()-]{1,60}$/u;
|
||||
|
||||
/** Identity prompts this feature targets. A custom PS1 like `dev: ` is not one. */
|
||||
const ORDINARY_INPUT_LABEL =
|
||||
/^(?:e-?mail(?: address)?|user(?: ?name| ?id)?|login(?: name)?|(?:full )?name|account(?: name)?|\u90ae\u7bb1|\u7535\u5b50\u90ae\u7bb1|\u7535\u5b50\u90ae\u4ef6|\u7528\u6237\u540d|\u7528\u6237|\u59d3\u540d|\u8d26\u53f7|\u5e10\u6237|\u8d26\u6237|\u767b\u5f55\u540d)$/u;
|
||||
|
||||
function colonLabelText(promptText: string): string | null {
|
||||
if (!promptText || promptText.length > 80) return null;
|
||||
if (isSensitiveTerminalChallenge(promptText)) return null;
|
||||
if (/\b(?:secret|passphrase|password|pin)\b/iu.test(promptText)) return null;
|
||||
|
||||
const trimmedEnd = promptText.trimEnd();
|
||||
const marker = trimmedEnd.at(-1);
|
||||
if (marker !== ":" && marker !== "\uFF1A") return null;
|
||||
|
||||
const label = trimmedEnd.slice(0, -1).trim();
|
||||
if (!PROGRAM_INPUT_LABEL.test(label)) return null;
|
||||
if (startsWithCommonShellCommand(label)) return null;
|
||||
return label;
|
||||
}
|
||||
|
||||
/** Colon label shape, including a custom shell PS1 (`dev: `). */
|
||||
function isColonLabelPromptText(promptText: string): boolean {
|
||||
return colonLabelText(promptText) !== null;
|
||||
}
|
||||
|
||||
/**
|
||||
* A program waiting for ordinary input (`read -p 'Email: '`).
|
||||
* Password, passphrase, PIN, and secret prompts stay closed.
|
||||
* Only the identity-label allowlist matches, so `PS1='dev: '` stays a shell prompt.
|
||||
*/
|
||||
export function isProgramInputPromptText(promptText: string): boolean {
|
||||
const label = colonLabelText(promptText);
|
||||
if (!label) return false;
|
||||
const normalized = label
|
||||
.replace(/\s+/g, " ")
|
||||
.toLowerCase()
|
||||
.replace(/^(?:please\s+)?(?:enter|input|type)\s+(?:your\s+)?/u, "")
|
||||
.replace(/^your\s+/u, "")
|
||||
.replace(/^(?:\u8bf7\u8f93\u5165|\u8bf7|\u8f93\u5165)/u, "")
|
||||
.trim();
|
||||
return ORDINARY_INPUT_LABEL.test(normalized);
|
||||
}
|
||||
|
||||
function isSpecificShellPromptCandidate(
|
||||
promptText: string,
|
||||
options: { allowGreaterThanTerminator?: boolean } = {},
|
||||
): boolean {
|
||||
if (isProgramInputPromptText(promptText)) return true;
|
||||
const trimmed = promptText.trim();
|
||||
if (
|
||||
!options.allowGreaterThanTerminator &&
|
||||
@@ -158,6 +203,9 @@ function inferPromptTextBeforeTypedInput(
|
||||
if (isLikelyNoSpaceShellPromptText(promptText)) return promptText;
|
||||
}
|
||||
|
||||
// `Email: ` with the reply still in the keystroke buffer (echo not painted yet).
|
||||
if (isProgramInputPromptText(cursorPrefix)) return cursorPrefix;
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -388,6 +436,11 @@ function alignTypedInputFromCursorPrefix(
|
||||
return null;
|
||||
}
|
||||
|
||||
// Program read-prompts are snippet queries only. Do not override a real shell
|
||||
// prompt, and do not authorize Enter history recording.
|
||||
const programInput = isProgramInputPromptText(promptText);
|
||||
if (programInput && raw.isAtPrompt) return null;
|
||||
|
||||
return {
|
||||
prompt: {
|
||||
isAtPrompt: true,
|
||||
@@ -395,7 +448,7 @@ function alignTypedInputFromCursorPrefix(
|
||||
userInput: typedBuffer,
|
||||
cursorOffset: typedBuffer.length,
|
||||
},
|
||||
alignedTyped: typedBuffer,
|
||||
alignedTyped: programInput ? null : typedBuffer,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -571,7 +624,9 @@ export function detectPrompt(term: XTerm): PromptDetectionResult {
|
||||
? lineText
|
||||
: cursorLinePrefix;
|
||||
const promptEnd = findPromptBoundary(promptScanText);
|
||||
if (promptEnd >= 0) {
|
||||
// Colon read-prompts (`Email: `) stay out of shell prompt detection. Autocomplete
|
||||
// opts in later via alignTypedInputFromCursorPrefix.
|
||||
if (promptEnd >= 0 && !isProgramInputPromptText(lineText.substring(0, promptEnd))) {
|
||||
const promptText = lineText.substring(0, promptEnd);
|
||||
// Input is whatever sits between the prompt and the cursor on the cell-
|
||||
// accurate prefix — don't use cursorX as a character index.
|
||||
@@ -601,7 +656,7 @@ export function detectPrompt(term: XTerm): PromptDetectionResult {
|
||||
const promptLineText = promptLine.translateToString(false);
|
||||
if (isSensitiveTerminalChallenge(promptLineText) || isNonPromptLine(promptLineText)) return NO_PROMPT;
|
||||
const pEnd = findPromptBoundary(promptLineText);
|
||||
if (pEnd >= 0) {
|
||||
if (pEnd >= 0 && !isProgramInputPromptText(promptLineText.substring(0, pEnd))) {
|
||||
const promptText = promptLineText.substring(0, pEnd);
|
||||
// Concatenate all rows from promptRow to cursorY to get full input
|
||||
let fullInput = promptLineText.substring(pEnd);
|
||||
@@ -817,7 +872,24 @@ function findPromptBoundary(lineText: string): number {
|
||||
}
|
||||
}
|
||||
|
||||
return lastStandardBoundary >= 0 ? lastStandardBoundary : lastPuaBoundary;
|
||||
if (lastStandardBoundary >= 0) return lastStandardBoundary;
|
||||
if (lastPuaBoundary >= 0) return lastPuaBoundary;
|
||||
return findProgramInputPromptBoundary(lineText);
|
||||
}
|
||||
|
||||
function findProgramInputPromptBoundary(lineText: string): number {
|
||||
const scanLimit = Math.min(lineText.trimEnd().length, 200);
|
||||
let last = -1;
|
||||
for (let i = 0; i < scanLimit; i++) {
|
||||
const ch = lineText[i];
|
||||
if (ch !== ":" && ch !== "\uFF1A") continue;
|
||||
const next = i + 1 < lineText.length ? lineText[i + 1] : null;
|
||||
if (next !== null && next !== " ") continue;
|
||||
const boundary = next === " " ? i + 2 : i + 1;
|
||||
if (!isColonLabelPromptText(lineText.slice(0, boundary))) continue;
|
||||
last = boundary;
|
||||
}
|
||||
return last;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import { isSensitiveTerminalChallenge } from "../../../domain/terminalPromptSecurity";
|
||||
import {
|
||||
isNonPromptLine,
|
||||
isProgramInputPromptText,
|
||||
reconcilePromptWithExternalCommand,
|
||||
type PromptDetectionResult,
|
||||
} from "./promptDetector";
|
||||
import { computeLivePreviewWrite } from "./livePreviewSequence";
|
||||
import { parseCommandLine, type CompletionSuggestion } from "./completionEngine";
|
||||
import { computeLivePreviewWrite, isWindowsShellLineInput } from "./livePreviewSequence";
|
||||
|
||||
const THEMED_PROMPT_MARKERS = /[❯❮→➜➤⟩»›]/;
|
||||
|
||||
@@ -103,10 +105,87 @@ export function isSameAutocompleteQuery(options: {
|
||||
return options.previewActive && options.previewBaseline === options.queryInput;
|
||||
}
|
||||
|
||||
const TRUSTED_ORDINARY_INPUT_LABELS = new Set([
|
||||
"email",
|
||||
"e-mail",
|
||||
"email address",
|
||||
"e-mail address",
|
||||
"username",
|
||||
"user name",
|
||||
"user",
|
||||
"userid",
|
||||
"user id",
|
||||
"login",
|
||||
"login name",
|
||||
"name",
|
||||
"full name",
|
||||
"account",
|
||||
"account name",
|
||||
"\u90ae\u7bb1",
|
||||
"\u7535\u5b50\u90ae\u7bb1",
|
||||
"\u7535\u5b50\u90ae\u4ef6",
|
||||
"\u7528\u6237\u540d",
|
||||
"\u7528\u6237",
|
||||
"\u59d3\u540d",
|
||||
"\u8d26\u53f7",
|
||||
"\u5e10\u6237",
|
||||
"\u8d26\u6237",
|
||||
"\u767b\u5f55\u540d",
|
||||
]);
|
||||
|
||||
const UNTRUSTED_INPUT_LABEL = /(?:\b(?:secret|passphrase|password|passcode|token|key|pin|otp)\b|\u5bc6\u7801|\u53e3\u4ee4|\u4ee4\u724c|\u5bc6\u94a5|\u9a8c\u8bc1\u7801)/iu;
|
||||
|
||||
function trustedOrdinaryInputLabel(promptText: string): string | null {
|
||||
const trimmedEnd = promptText.trimEnd();
|
||||
const marker = trimmedEnd.at(-1);
|
||||
if (marker !== ":" && marker !== "\uFF1A") return null;
|
||||
return trimmedEnd
|
||||
.slice(0, -1)
|
||||
.trim()
|
||||
.replace(/\s+/g, " ")
|
||||
.toLowerCase()
|
||||
.replace(/^(?:please\s+)?(?:enter|input|type)\s+(?:your\s+)?/u, "")
|
||||
.replace(/^your\s+/u, "")
|
||||
.replace(/^(?:\u8bf7\u8f93\u5165|\u8bf7|\u8f93\u5165)/u, "")
|
||||
.trim();
|
||||
}
|
||||
|
||||
/**
|
||||
* Positively identified ordinary identity prompts (`Email:`, `Username:`).
|
||||
* Unknown labels stay fail-closed so they cannot bypass the sensitive latch.
|
||||
*/
|
||||
function isTrustedOrdinaryInputPromptText(promptText: string): boolean {
|
||||
if (UNTRUSTED_INPUT_LABEL.test(promptText)) return false;
|
||||
const label = trustedOrdinaryInputLabel(promptText);
|
||||
if (!label || UNTRUSTED_INPUT_LABEL.test(label)) return false;
|
||||
return TRUSTED_ORDINARY_INPUT_LABELS.has(label);
|
||||
}
|
||||
|
||||
/**
|
||||
* Why `sensitiveInputActive` is latched.
|
||||
*
|
||||
* `heuristic` is the untrusted-prompt detector (`Email:`, unknown `Label:`).
|
||||
* `explicit` is authoritative metadata such as `pluginPipelineSensitiveInput`.
|
||||
* Omitted means heuristic, so existing callers keep the trusted-label bypass.
|
||||
*/
|
||||
export type SensitiveAutocompleteLatchSource = "heuristic" | "explicit";
|
||||
|
||||
export type SensitiveInputActiveRef = {
|
||||
current: boolean;
|
||||
source?: SensitiveAutocompleteLatchSource;
|
||||
};
|
||||
|
||||
/**
|
||||
* Whether fetchSuggestions must refuse to query/render for an already-known
|
||||
* sensitive line (host latch or auth-challenge prompt text).
|
||||
*
|
||||
* The heuristic latch is set for every untrusted input boundary, including
|
||||
* safe program prompts such as `Email:`. Only a small allowlist of plain
|
||||
* identity labels may bypass that latch so snippet candidates can render.
|
||||
* An explicit latch (plugin pipeline or other authoritative sensitive
|
||||
* metadata) stays closed for those labels too. Unknown labels, and anything
|
||||
* password/token/key/PIN/OTP-like, stay closed.
|
||||
*
|
||||
* This is *not* a substitute for the empty-echo / `allowExternalProviders:
|
||||
* false` wait in useTerminalAutocomplete: `read -s -p '$ '` still looks like
|
||||
* a normal shell PS1 until echo validates, so that path stays fail-closed
|
||||
@@ -115,7 +194,16 @@ export function isSameAutocompleteQuery(options: {
|
||||
export function shouldBlockAutocompleteForSensitivePrompt(options: {
|
||||
sensitiveInputActive: boolean;
|
||||
promptText: string;
|
||||
sensitiveSource?: SensitiveAutocompleteLatchSource;
|
||||
}): boolean {
|
||||
const explicitLatch = options.sensitiveInputActive && options.sensitiveSource === "explicit";
|
||||
if (
|
||||
options.sensitiveInputActive
|
||||
&& !explicitLatch
|
||||
&& isTrustedOrdinaryInputPromptText(options.promptText)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
if (options.sensitiveInputActive) return true;
|
||||
return isSensitiveTerminalChallenge(options.promptText);
|
||||
}
|
||||
@@ -169,6 +257,7 @@ export function getCommandToRecordOnEnter(
|
||||
typedBufferReliable: boolean,
|
||||
): string | null {
|
||||
if (!livePrompt.isAtPrompt) return null;
|
||||
if (isProgramInputPromptText(livePrompt.promptText)) return null;
|
||||
const alignedCommand = alignedTyped?.trim();
|
||||
if (alignedCommand) return alignedCommand;
|
||||
|
||||
@@ -209,3 +298,46 @@ export function getCommandToRecordOnEnter(
|
||||
return liveCommand;
|
||||
}
|
||||
|
||||
/**
|
||||
* Keystrokes that remove the text a snippet acceptance replaces.
|
||||
* Line-start keeps the shell kill-line (Ctrl+U, or backspaces on Windows).
|
||||
* Mid-line and program read-prompts delete only the word being typed.
|
||||
* POSIX word delete sends DEL, the tty erase character. Ctrl-H is literal
|
||||
* input to canonical `read` when erase is ^?, while readline still deletes
|
||||
* on DEL. Windows line editing keeps backspace.
|
||||
* Script snippets run automation and do not replace the line, so they keep
|
||||
* the full-line clear.
|
||||
*/
|
||||
export function computeSnippetAcceptClearSequence(options: {
|
||||
line: string | null;
|
||||
os: string;
|
||||
promptText: string;
|
||||
snippetKind?: "snippet" | "script";
|
||||
}): string {
|
||||
const line = options.line ?? "";
|
||||
if (line.length === 0) return "";
|
||||
|
||||
const { currentWord, wordIndex } = parseCommandLine(line);
|
||||
const replaceWord = options.snippetKind !== "script"
|
||||
&& currentWord.length > 0
|
||||
&& line.endsWith(currentWord)
|
||||
&& (wordIndex > 0 || isProgramInputPromptText(options.promptText));
|
||||
if (replaceWord) {
|
||||
const eraseChar = isWindowsShellLineInput(options.os, options.promptText) ? "\b" : "\x7f";
|
||||
return eraseChar.repeat(Array.from(currentWord).length);
|
||||
}
|
||||
|
||||
return isWindowsShellLineInput(options.os, options.promptText)
|
||||
? "\b".repeat(line.length)
|
||||
: "\x15";
|
||||
}
|
||||
|
||||
/** Program read-prompts offer snippets only; shell lines keep every source. */
|
||||
export function limitSuggestionsForPrompt(
|
||||
promptText: string,
|
||||
suggestions: CompletionSuggestion[],
|
||||
): CompletionSuggestion[] {
|
||||
if (!isProgramInputPromptText(promptText)) return suggestions;
|
||||
return suggestions.filter((suggestion) => suggestion.source === "snippet");
|
||||
}
|
||||
|
||||
@@ -27,7 +27,7 @@ import {
|
||||
shouldPreferRemoteShellCwd,
|
||||
} from "./remotePathCompleter";
|
||||
import { decideGhostSuggestion } from "./ghostSuggestionPolicy";
|
||||
import { isWindowsShellLineInput, shouldWriteAutocompleteLivePreview } from "./livePreviewSequence";
|
||||
import { shouldWriteAutocompleteLivePreview } from "./livePreviewSequence";
|
||||
import {
|
||||
areSubDirPanelsEqual,
|
||||
areSuggestionsEqual,
|
||||
@@ -41,9 +41,12 @@ import { handleTerminalAutocompleteInput } from "./terminalAutocompleteInput";
|
||||
import { handleTerminalAutocompleteKeyEvent } from "./terminalAutocompleteKeyEvent";
|
||||
import {
|
||||
computeAutocompleteAcceptWrite,
|
||||
computeSnippetAcceptClearSequence,
|
||||
isSameAutocompleteQuery,
|
||||
limitSuggestionsForPrompt,
|
||||
resolveAutocompleteQueryInput,
|
||||
shouldBlockAutocompleteForSensitivePrompt,
|
||||
type SensitiveInputActiveRef,
|
||||
} from "./terminalAutocompletePrompt";
|
||||
import { sliceStringByCellColumns } from "./terminalStringCellWidth";
|
||||
import { isTerminalAlternateScreenActive } from "../terminalHibernateRuntime";
|
||||
@@ -234,7 +237,7 @@ interface UseTerminalAutocompleteOptions {
|
||||
* Host-owned password/auth prompt latch. When true, suppress autocomplete
|
||||
* even if the PS1 still looks like a normal shell (e.g. `read -s -p '$ '`).
|
||||
*/
|
||||
sensitiveInputActiveRef?: RefObject<boolean>;
|
||||
sensitiveInputActiveRef?: SensitiveInputActiveRef;
|
||||
/** Host-owned completion Provider adapter; defaults to Netcatty's built-in Provider. */
|
||||
provideCompletions?: (
|
||||
input: string,
|
||||
@@ -876,6 +879,7 @@ export function useTerminalAutocomplete(
|
||||
if (
|
||||
shouldBlockAutocompleteForSensitivePrompt({
|
||||
sensitiveInputActive: sensitiveInputActiveRef?.current === true,
|
||||
sensitiveSource: sensitiveInputActiveRef?.source,
|
||||
promptText: prompt.promptText,
|
||||
})
|
||||
) {
|
||||
@@ -1004,10 +1008,11 @@ export function useTerminalAutocomplete(
|
||||
};
|
||||
|
||||
const applyCompletions = (
|
||||
completions: CompletionSuggestion[],
|
||||
incomingCompletions: CompletionSuggestion[],
|
||||
currentPrompt: ReturnType<typeof getAlignedPrompt>["prompt"],
|
||||
options?: { preserveSelection?: boolean },
|
||||
) => {
|
||||
const completions = limitSuggestionsForPrompt(currentPrompt.promptText, incomingCompletions);
|
||||
// Echo-lag-aware logical caret: resolved query `input` can be ahead of
|
||||
// the live xterm cursor / currentPrompt.userInput. When a late path
|
||||
// refresh lands while live-preview has already rewritten the shell line,
|
||||
@@ -1340,8 +1345,8 @@ export function useTerminalAutocomplete(
|
||||
[handleCommandHistoryCleared],
|
||||
);
|
||||
|
||||
/** Accept a snippet: clear the user's typed input, then run it via the
|
||||
* host-canonical send path (onAcceptSnippet). */
|
||||
/** Accept a snippet: clear the matched word (or the whole line for a script
|
||||
* or a shell prompt), then run it via onAcceptSnippet. */
|
||||
const acceptSnippet = useCallback((snippet: Snippet): boolean => {
|
||||
const term = termRef.current;
|
||||
if (term) {
|
||||
@@ -1353,10 +1358,13 @@ export function useTerminalAutocomplete(
|
||||
);
|
||||
if (line !== null && line.length > 0) {
|
||||
if (!settingsRef.current.allowLineReplacement) return false;
|
||||
const clearSequence = isWindowsShellLineInput(hostOsRef.current, prompt.promptText)
|
||||
? "\b".repeat(line.length)
|
||||
: "\x15"; // Ctrl+U (readline kill-line)
|
||||
writeToTerminal(clearSequence);
|
||||
const clearSequence = computeSnippetAcceptClearSequence({
|
||||
line,
|
||||
os: hostOsRef.current,
|
||||
promptText: prompt.promptText,
|
||||
snippetKind: snippet.kind,
|
||||
});
|
||||
if (clearSequence) writeToTerminal(clearSequence);
|
||||
}
|
||||
}
|
||||
typedInputBufferRef.current = "";
|
||||
|
||||
@@ -113,6 +113,32 @@ test('acknowledged pasted commands enter history over output without becoming tr
|
||||
}
|
||||
});
|
||||
|
||||
test('colon shell prompts still record the submitted command (#3622)', () => {
|
||||
const executed: string[] = [];
|
||||
const result = recordTerminalCommandExecution('ls', {
|
||||
host: { id: 'host-1', label: 'Host' },
|
||||
sessionId: 'session-1',
|
||||
onCommandExecuted: (command) => executed.push(command),
|
||||
commandBufferRef: { current: 'ls' },
|
||||
}, createFakeTerm('dev: ls') as never);
|
||||
assert.equal(result, 'ls');
|
||||
assert.deepEqual(executed, ['ls']);
|
||||
});
|
||||
|
||||
test('program input replies are not stored as shell commands (#3622)', () => {
|
||||
for (const line of ['Email: use', 'Password: use', 'Secret: use']) {
|
||||
const executed: string[] = [];
|
||||
const result = recordTerminalCommandExecution('use', {
|
||||
host: { id: 'host-1', label: 'Host' },
|
||||
sessionId: 'session-1',
|
||||
onCommandExecuted: (command) => executed.push(command),
|
||||
commandBufferRef: { current: 'use' },
|
||||
}, createFakeTerm(line) as never);
|
||||
assert.equal(result, null, line);
|
||||
assert.deepEqual(executed, [], line);
|
||||
}
|
||||
});
|
||||
|
||||
test('acknowledged writes retain both live challenge and explicit sensitive checks', () => {
|
||||
for (const [line, sensitive] of [['Password: ', false], ['OTP> ', false], ['output', true]] as const) {
|
||||
const history: string[] = [];
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
import {
|
||||
getAlignedPrompt,
|
||||
isNonPromptLine,
|
||||
isProgramInputPromptText,
|
||||
reconcilePromptWithExternalCommand,
|
||||
reconcilePromptWithTypedInput,
|
||||
type PromptDetectionResult,
|
||||
@@ -384,6 +385,7 @@ export const shouldRecordShellHistory = (
|
||||
|
||||
const trimmed = command.trim();
|
||||
const alignedResult = getAlignedPrompt(term, command, true);
|
||||
if (isProgramInputPromptText(alignedResult.prompt.promptText)) return false;
|
||||
const prompt = expandPromptUserInputToFullLine(term, alignedResult.prompt, command);
|
||||
if (!prompt.isAtPrompt) return false;
|
||||
if (alignedResult.alignedTyped?.trim() === trimmed) return true;
|
||||
|
||||
@@ -3,6 +3,10 @@ import { readFileSync } from "node:fs";
|
||||
import test from "node:test";
|
||||
|
||||
const runtimeSource = readFileSync(new URL("./createXTermRuntime.ts", import.meta.url), "utf8");
|
||||
const autocompleteHookSource = readFileSync(
|
||||
new URL("../autocomplete/useTerminalAutocomplete.ts", import.meta.url),
|
||||
"utf8",
|
||||
);
|
||||
const attachmentSource = readFileSync(new URL("./terminalSessionAttachment.ts", import.meta.url), "utf8");
|
||||
const terminalSource = readFileSync(new URL("../../Terminal.tsx", import.meta.url), "utf8");
|
||||
const terminalLayerSource = readFileSync(new URL("../../TerminalLayer.tsx", import.meta.url), "utf8");
|
||||
@@ -110,6 +114,30 @@ test("renderer flow control acknowledges host ingress rather than transformed di
|
||||
);
|
||||
});
|
||||
|
||||
test("explicit plugin sensitivity is not treated as the heuristic prompt latch", () => {
|
||||
const effectsSource = readFileSync(new URL("../useTerminalEffects.ts", import.meta.url), "utf8");
|
||||
assert.match(
|
||||
terminalSource,
|
||||
/passwordPromptActiveRef\.current = meta\.pluginPipelineSensitiveInput;[\s\S]*?passwordPromptActiveRef\.source = meta\.pluginPipelineSensitiveInput \? "explicit" : "heuristic";/,
|
||||
);
|
||||
assert.match(
|
||||
terminalSource,
|
||||
/passwordPromptActiveRef\.current = payload\.passwordPromptActive;[\s\S]*?if \(payload\.passwordPromptActive\) passwordPromptActiveRef\.source = "explicit";/,
|
||||
);
|
||||
assert.match(
|
||||
effectsSource,
|
||||
/passwordPromptActiveRef\.current = snap\.passwordPromptActive;[\s\S]*?if \(snap\.passwordPromptActive\) passwordPromptActiveRef\.source = "explicit";/,
|
||||
);
|
||||
assert.match(
|
||||
terminalSource,
|
||||
/passwordPromptActiveRef\.current = true;[\s\S]*?passwordPromptActiveRef\.source = "heuristic";/,
|
||||
);
|
||||
assert.match(
|
||||
autocompleteHookSource,
|
||||
/sensitiveSource: sensitiveInputActiveRef\?\.source,/,
|
||||
);
|
||||
});
|
||||
|
||||
test("active and hibernated output share host-owned sensitive prompt classification", () => {
|
||||
assert.match(
|
||||
terminalSource,
|
||||
|
||||
@@ -3,10 +3,13 @@ import assert from "node:assert/strict";
|
||||
|
||||
import {
|
||||
computeAutocompleteAcceptWrite,
|
||||
computeSnippetAcceptClearSequence,
|
||||
isSameAutocompleteQuery,
|
||||
limitSuggestionsForPrompt,
|
||||
resolveAutocompleteQueryInput,
|
||||
shouldBlockAutocompleteForSensitivePrompt,
|
||||
} from "./autocomplete/terminalAutocompletePrompt.ts";
|
||||
import type { CompletionSuggestion } from "./autocomplete/completionEngine.ts";
|
||||
import type { PromptDetectionResult } from "./autocomplete/promptDetector.ts";
|
||||
|
||||
function atPrompt(userInput: string, promptText = "$ "): PromptDetectionResult {
|
||||
@@ -57,6 +60,97 @@ test("shouldBlockAutocompleteForSensitivePrompt blocks auth-challenge prompts",
|
||||
);
|
||||
});
|
||||
|
||||
test("shouldBlockAutocompleteForSensitivePrompt lets safe program prompts past the untrusted latch", () => {
|
||||
// observeTerminalInputPrompt latches every untrusted colon prompt, including
|
||||
// `Email:`, before limitSuggestionsForPrompt can offer snippets.
|
||||
for (const promptText of [
|
||||
"Email: ",
|
||||
"email:",
|
||||
"E-mail: ",
|
||||
"EMAIL ADDRESS: ",
|
||||
"Enter your username: ",
|
||||
"user: ",
|
||||
"User Name: ",
|
||||
"Login: ",
|
||||
"Name: ",
|
||||
"\u90ae\u7bb1: ",
|
||||
"\u90ae\u7bb1\uFF1A",
|
||||
"\u7528\u6237\u540d: ",
|
||||
]) {
|
||||
assert.equal(
|
||||
shouldBlockAutocompleteForSensitivePrompt({
|
||||
sensitiveInputActive: true,
|
||||
promptText,
|
||||
}),
|
||||
false,
|
||||
promptText,
|
||||
);
|
||||
}
|
||||
assert.equal(
|
||||
shouldBlockAutocompleteForSensitivePrompt({
|
||||
sensitiveInputActive: true,
|
||||
promptText: "Password: ",
|
||||
}),
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
test("shouldBlockAutocompleteForSensitivePrompt keeps explicit plugin sensitivity closed for trusted labels", () => {
|
||||
// pluginPipelineSensitiveInput (and any other authoritative latch) must not
|
||||
// use the Email:/Username: bypass. A heuristic-only latch still may.
|
||||
assert.equal(
|
||||
shouldBlockAutocompleteForSensitivePrompt({
|
||||
sensitiveInputActive: true,
|
||||
sensitiveSource: "explicit",
|
||||
promptText: "Email:",
|
||||
}),
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
shouldBlockAutocompleteForSensitivePrompt({
|
||||
sensitiveInputActive: true,
|
||||
sensitiveSource: "explicit",
|
||||
promptText: "Username:",
|
||||
}),
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
shouldBlockAutocompleteForSensitivePrompt({
|
||||
sensitiveInputActive: true,
|
||||
sensitiveSource: "heuristic",
|
||||
promptText: "Email:",
|
||||
}),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test("shouldBlockAutocompleteForSensitivePrompt keeps the latch for unknown and secret labels", () => {
|
||||
for (const promptText of [
|
||||
"API key:",
|
||||
"API key: ",
|
||||
"Recovery key: ",
|
||||
"Token: ",
|
||||
"TOKEN:",
|
||||
"Password: ",
|
||||
"Passphrase: ",
|
||||
"Secret: ",
|
||||
"PIN: ",
|
||||
"OTP: ",
|
||||
"One-time password: ",
|
||||
"Favorite color: ",
|
||||
"SSH key: ",
|
||||
]) {
|
||||
assert.equal(
|
||||
shouldBlockAutocompleteForSensitivePrompt({
|
||||
sensitiveInputActive: true,
|
||||
promptText,
|
||||
}),
|
||||
true,
|
||||
promptText,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test("isSameAutocompleteQuery keeps late paths alive during live preview", () => {
|
||||
// Highlighting a candidate rewrites typedInputBuffer to the preview text.
|
||||
// Late path listings for the original query must still apply.
|
||||
@@ -226,6 +320,126 @@ test("computeAutocompleteAcceptWrite refuses line replacement when disabled", ()
|
||||
);
|
||||
});
|
||||
|
||||
test("snippet accept deletes only the mid-line word (#3622)", () => {
|
||||
// POSIX erase is DEL (0x7f). Ctrl-H is literal under canonical `read`.
|
||||
assert.equal(
|
||||
computeSnippetAcceptClearSequence({
|
||||
line: "git pull use",
|
||||
os: "linux",
|
||||
promptText: "user@host:~$ ",
|
||||
}),
|
||||
"\x7f\x7f\x7f",
|
||||
);
|
||||
assert.equal(
|
||||
computeSnippetAcceptClearSequence({
|
||||
line: "git pull use",
|
||||
os: "windows",
|
||||
promptText: "C:\\Users\\me>",
|
||||
}),
|
||||
"\b\b\b",
|
||||
);
|
||||
assert.equal(
|
||||
computeSnippetAcceptClearSequence({
|
||||
line: "use",
|
||||
os: "linux",
|
||||
promptText: "user@host:~$ ",
|
||||
}),
|
||||
"\x15",
|
||||
);
|
||||
assert.equal(
|
||||
computeSnippetAcceptClearSequence({
|
||||
line: "deploy",
|
||||
os: "windows",
|
||||
promptText: "C:\\Users\\me>",
|
||||
}),
|
||||
"\b".repeat("deploy".length),
|
||||
);
|
||||
const emailClear = computeSnippetAcceptClearSequence({
|
||||
line: "use",
|
||||
os: "linux",
|
||||
promptText: "Email: ",
|
||||
});
|
||||
assert.equal(emailClear, "\x7f".repeat(3));
|
||||
assert.deepEqual([...emailClear].map((ch) => ch.charCodeAt(0)), [0x7f, 0x7f, 0x7f]);
|
||||
assert.equal(
|
||||
computeSnippetAcceptClearSequence({
|
||||
line: "foo use",
|
||||
os: "linux",
|
||||
promptText: "Email: ",
|
||||
}),
|
||||
"\x7f\x7f\x7f",
|
||||
);
|
||||
assert.equal(
|
||||
computeSnippetAcceptClearSequence({
|
||||
line: "use",
|
||||
os: "windows",
|
||||
promptText: "Email: ",
|
||||
}),
|
||||
"\b\b\b",
|
||||
);
|
||||
assert.equal(
|
||||
computeSnippetAcceptClearSequence({
|
||||
line: "git pull \u{1F680}",
|
||||
os: "linux",
|
||||
promptText: "user@host:~$ ",
|
||||
}),
|
||||
"\x7f",
|
||||
);
|
||||
});
|
||||
|
||||
test("script snippet accept still clears the whole line (#3622)", () => {
|
||||
assert.equal(
|
||||
computeSnippetAcceptClearSequence({
|
||||
line: "echo sm",
|
||||
os: "linux",
|
||||
promptText: "user@host:~$ ",
|
||||
snippetKind: "script",
|
||||
}),
|
||||
"\x15",
|
||||
);
|
||||
assert.equal(
|
||||
computeSnippetAcceptClearSequence({
|
||||
line: "echo sm",
|
||||
os: "windows",
|
||||
promptText: "C:\\Users\\me>",
|
||||
snippetKind: "script",
|
||||
}),
|
||||
"\b".repeat("echo sm".length),
|
||||
);
|
||||
assert.equal(
|
||||
computeSnippetAcceptClearSequence({
|
||||
line: "hello use",
|
||||
os: "linux",
|
||||
promptText: "Email: ",
|
||||
snippetKind: "script",
|
||||
}),
|
||||
"\x15",
|
||||
);
|
||||
assert.equal(
|
||||
computeSnippetAcceptClearSequence({
|
||||
line: "echo sm",
|
||||
os: "linux",
|
||||
promptText: "user@host:~$ ",
|
||||
snippetKind: "snippet",
|
||||
}),
|
||||
"\x7f\x7f",
|
||||
);
|
||||
});
|
||||
|
||||
test("program input prompts keep snippet suggestions only (#3622)", () => {
|
||||
const suggestions = [
|
||||
{ text: "useradd", displayText: "useradd", source: "history", score: 10 },
|
||||
{ text: "邮箱", displayText: "邮箱", source: "snippet", score: 20 },
|
||||
] as CompletionSuggestion[];
|
||||
|
||||
assert.deepEqual(
|
||||
limitSuggestionsForPrompt("Email: ", suggestions).map((suggestion) => suggestion.source),
|
||||
["snippet"],
|
||||
);
|
||||
assert.equal(limitSuggestionsForPrompt("user@host:~$ ", suggestions).length, 2);
|
||||
assert.equal(limitSuggestionsForPrompt("Password: ", suggestions).length, 2);
|
||||
});
|
||||
|
||||
test("computeAutocompleteAcceptWrite clears with backspaces on a mislabeled Windows shell (#3184)", () => {
|
||||
// Windows PowerShell host saved with the os:"linux" default: the detected
|
||||
// drive-letter prompt must route the clear to backspaces, not Ctrl-U.
|
||||
|
||||
@@ -705,6 +705,8 @@ export function useTerminalEffects(ctx: TerminalEffectsContext) {
|
||||
}
|
||||
if (typeof snap.passwordPromptActive === "boolean") {
|
||||
passwordPromptActiveRef.current = snap.passwordPromptActive;
|
||||
// Same conservative restore as the home apply-snapshot path.
|
||||
if (snap.passwordPromptActive) passwordPromptActiveRef.source = "explicit";
|
||||
}
|
||||
if (snap.cwd !== undefined) {
|
||||
const cwd = terminalCwdTracker.setRendererCwd(snap.cwd, "snapshot");
|
||||
|
||||
@@ -271,7 +271,13 @@ test("Cygwin agent validation closes a stalled negotiation socket", async (t) =>
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
assert.equal(await cygwinAgentConnectable(descriptorPath, { timeoutMs: 20 }), false);
|
||||
// The timeout covers a stalled negotiation. Reading the descriptor from
|
||||
// memory keeps a slow filesystem from expiring that budget before the
|
||||
// socket exists, which otherwise looks like a connection that stayed open.
|
||||
assert.equal(await cygwinAgentConnectable(descriptorPath, {
|
||||
timeoutMs: 20,
|
||||
readFileImpl: async () => fs.readFileSync(descriptorPath, "utf8"),
|
||||
}), false);
|
||||
await Promise.race([
|
||||
closed,
|
||||
new Promise((_, reject) => setTimeout(() => reject(new Error("Cygwin probe connection stayed open")), 2_000)),
|
||||
|
||||
Reference in new issue
Block a user