Compare commits

...
Author SHA1 Message Date
Cursor Agentand陈大猫 014b13e07e fix(terminal): coalesce a CRLF split across prompt-security chunks
A chunk that ends in CR completes the heredoc opener. The next chunk's
leading LF is the rest of that break, so dropping it keeps the opener
and a following bare PS2 stays trusted.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-10-20 10:49:20 +00:00
Cursor Agentand陈大猫 cea229e800 fix(terminal): trust bare PS2 only inside a heredoc
A global bare ">" confirmation cleared the sensitive-input latch for
echo-disabled prompts such as read -s -p '> '. Trust that line only when
the preceding command is a confirmed shell prompt containing <<.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-10-20 10:21:32 +00:00
Cursor Agentand陈大猫 c939df7564 fix(terminal): treat bare bash PS2 as a confirmed shell prompt
Broadcast input latched on the default ">" continuation prompt, so the
final Enter of a heredoc paste reached only the focused session.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-10-20 09:53:33 +00:00
4 changed files with 239 additions and 9 deletions

No files matched your search

+15 -7
View File
@@ -59,7 +59,7 @@ import { MULTILINE_PASTE_CONFIRM_MIN_LINES_DEFAULT } from "../domain/terminalPas
import { isPluginHostProtocol } from "../domain/pluginConnection";
import { clearTerminalBootEpoch, setTerminalBootEpoch } from "../domain/terminalBootEpoch";
import {
appendTerminalPromptSecurityTail,
foldTerminalPromptSecurityOutput,
isConfirmedTerminalShellPrompt,
isUntrustedTerminalInputPrompt,
} from "../domain/terminalPromptSecurity";
@@ -452,6 +452,8 @@ const TerminalComponent: React.FC<TerminalProps> = ({
), [sessionId]);
useEffect(() => () => clearTerminalBroadcastUserInput(sessionId), [sessionId]);
const sensitivePromptOutputTailRef = useRef("");
const heredocPromptOriginRef = useRef("");
const promptSecurityPendingCrRef = useRef(false);
const [activeScriptRun, setActiveScriptRun] = useState<import('@/types/global/netcatty-bridge-script.d.ts').ScriptRun | undefined>(undefined);
const dismissedScriptRunIdsRef = useRef(new Set<string>());
@@ -2011,10 +2013,14 @@ const TerminalComponent: React.FC<TerminalProps> = ({
chunk: string,
meta?: TerminalSessionDataMeta,
) => {
sensitivePromptOutputTailRef.current = appendTerminalPromptSecurityTail(
sensitivePromptOutputTailRef.current,
chunk,
);
const folded = foldTerminalPromptSecurityOutput({
origin: heredocPromptOriginRef.current,
current: sensitivePromptOutputTailRef.current,
pendingCr: promptSecurityPendingCrRef.current,
}, chunk);
heredocPromptOriginRef.current = folded.origin;
sensitivePromptOutputTailRef.current = folded.current;
promptSecurityPendingCrRef.current = folded.pendingCr;
const promptSecurityOptions = {
allowHostStyleGreaterThan: isNetworkDevice,
alternateScreen: termRef.current
@@ -2030,17 +2036,19 @@ const TerminalComponent: React.FC<TerminalProps> = ({
autocompleteCloseRef.current?.();
} else {
sensitivePromptOutputTailRef.current = "";
heredocPromptOriginRef.current = "";
promptSecurityPendingCrRef.current = false;
}
return;
} else if (isUntrustedTerminalInputPrompt(
sensitivePromptOutputTailRef.current,
folded.promptText,
promptSecurityOptions,
)) {
passwordPromptActiveRef.current = true;
passwordPromptActiveRef.source = "heuristic";
autocompleteCloseRef.current?.();
} else if (isConfirmedTerminalShellPrompt(
sensitivePromptOutputTailRef.current,
folded.promptText,
promptSecurityOptions,
)) {
passwordPromptActiveRef.current = false;
+51 -1
View File
@@ -77,6 +77,8 @@ function isConfirmedTerminalShellPrompt(promptText, options = {}) {
if (code >= 0xE000 && code <= 0xF8FF) return true;
}
if (/[$#%]$/u.test(prompt)) return true;
// POSIX sh/bash/ksh default PS2. Trusted only with heredoc context (#3796).
if (prompt === ">") return isHeredocPs2Continuation(promptText, options);
if (!prompt.endsWith(">")) return false;
if (/^(?:PS\s+)?[A-Za-z]:[\\/].*>$/u.test(prompt)) return true;
if (/[@\\/~:]\S*>$/u.test(prompt)) return true;
@@ -84,6 +86,53 @@ function isConfirmedTerminalShellPrompt(promptText, options = {}) {
&& /^[A-Za-z0-9_.-]+(?:\([^)]{1,128}\))?>$/u.test(prompt);
}
function splitTerminalLogicalLines(value) {
const lines = [];
let start = 0;
for (let i = 0; i < value.length; i += 1) {
const ch = value[i];
if (ch !== "\n" && ch !== "\r") continue;
lines.push(value.slice(start, i));
if (ch === "\r" && value[i + 1] === "\n") i += 1;
start = i + 1;
}
lines.push(value.slice(start));
return lines;
}
function isBashPs2ContinuationLine(line) {
const trimmed = line.trim();
return trimmed === ">" || trimmed.startsWith("> ");
}
function commandAfterConfirmedShellPrompt(line, options) {
const text = line.trim();
for (let i = 0; i < text.length - 1; i += 1) {
const ch = text[i];
if (ch !== "$" && ch !== "#" && ch !== "%" && ch !== ">") continue;
const rest = text.slice(i + 1);
if (!/^\s+\S/u.test(rest)) continue;
const candidate = text.slice(0, i + 1);
if (!isConfirmedTerminalShellPrompt(candidate, options)) continue;
if ((ch === "$" || ch === "#" || ch === "%") && !isPlausibleShellPromptBody(candidate.slice(0, -1))) {
continue;
}
return rest;
}
return null;
}
// Bare ">" is bash PS2 only inside a heredoc opened by a confirmed shell
// command (#3796). read -s -p '> ' and other origins stay fail-closed.
function isHeredocPs2Continuation(value, options) {
const lines = splitTerminalLogicalLines(stripTerminalControlSequences(value));
let index = lines.length - 2;
while (index >= 0 && isBashPs2ContinuationLine(lines[index])) index -= 1;
if (index < 0) return false;
const command = commandAfterConfirmedShellPrompt(lines[index], options);
return command != null && command.includes("<<");
}
function isPlausibleShellPromptBody(body) {
const trimmed = body.trim();
if (!trimmed) return true;
@@ -117,7 +166,8 @@ function isUntrustedTerminalInputPrompt(value, options = {}) {
if (options.alternateScreen && /^:\s*$/.test(prompt)) return false;
if (!/[::>›»]\s*$/u.test(prompt)) return false;
if (hasTypedInputAfterConfirmedPrompt(prompt, options)) return false;
return !isConfirmedTerminalShellPrompt(prompt, options);
// Pass the original text so a bare ">" can see heredoc lines above it.
return !isConfirmedTerminalShellPrompt(value, options);
}
module.exports = { isUntrustedTerminalInputPrompt };
+72
View File
@@ -4,6 +4,7 @@ import test from 'node:test';
import {
appendTerminalPromptSecurityTail,
foldTerminalPromptSecurityOutput,
isConfirmedTerminalShellPrompt,
isSensitiveTerminalChallenge,
isUntrustedTerminalInputPrompt,
@@ -122,6 +123,69 @@ test('ordinary shell commands ending with a colon are not untrusted input prompt
assert.equal(isUntrustedTerminalInputPrompt('Password: '), true);
});
test('bash PS2 is trusted only while a heredoc continuation is open (#3796)', () => {
// CentOS-7-style bash has no bracketed paste, so a heredoc paste echoes the
// default PS2 "> " after each line. That must keep broadcast on.
const heredocEcho = '[root@h ~]# cat >> f << EOF\r\n> ';
assert.equal(isConfirmedTerminalShellPrompt(heredocEcho), true);
assert.equal(isUntrustedTerminalInputPrompt(heredocEcho), false);
const heredocBody = '[root@h ~]# cat >> f << EOF\r\n> echo madvise > /sys/kernel/mm/transparent_hugepage/enabled\r\n> ';
assert.equal(isConfirmedTerminalShellPrompt(heredocBody), true);
assert.equal(isUntrustedTerminalInputPrompt(heredocBody), false);
// No preceding command, or an echo-disabled prompt labelled ">", fails closed.
assert.equal(isConfirmedTerminalShellPrompt('>'), false);
assert.equal(isConfirmedTerminalShellPrompt('> '), false);
assert.equal(isUntrustedTerminalInputPrompt('>'), true);
assert.equal(isUntrustedTerminalInputPrompt('> '), true);
const secretPrompt = "[root@h ~]# read -s -p '> ' secret\r\n> ";
assert.equal(isConfirmedTerminalShellPrompt(secretPrompt), false);
assert.equal(isUntrustedTerminalInputPrompt(secretPrompt), true);
// The echoed terminator line is ordinary output, not a prompt latch.
assert.equal(isConfirmedTerminalShellPrompt('> EOF'), false);
assert.equal(isUntrustedTerminalInputPrompt('> EOF'), false);
// Labelled challenges stay fail-closed. zsh "heredoc>" is out of scope.
assert.equal(isUntrustedTerminalInputPrompt('Custom>'), true);
assert.equal(isUntrustedTerminalInputPrompt('Token>'), true);
assert.equal(isUntrustedTerminalInputPrompt('Password:'), true);
assert.equal(isConfirmedTerminalShellPrompt('Custom>'), false);
assert.equal(isConfirmedTerminalShellPrompt('Token>'), false);
assert.equal(isConfirmedTerminalShellPrompt('Password:'), false);
assert.equal(isUntrustedTerminalInputPrompt('heredoc>'), true);
assert.equal(isConfirmedTerminalShellPrompt('heredoc>'), false);
});
test('prompt observation keeps the heredoc opener and drops it after the shell returns (#3796)', () => {
let state = { origin: '', current: '' };
state = foldTerminalPromptSecurityOutput(state, '[root@h ~]# cat >> f << EOF\r\n');
state = foldTerminalPromptSecurityOutput(state, '> echo madvise > /sys/kernel/mm/transparent_hugepage/enabled\r\n');
state = foldTerminalPromptSecurityOutput(state, '> ');
assert.equal(isConfirmedTerminalShellPrompt(state.promptText), true);
assert.equal(isUntrustedTerminalInputPrompt(state.promptText), false);
state = foldTerminalPromptSecurityOutput(state, "\r\n[root@h ~]# ");
state = foldTerminalPromptSecurityOutput(state, '\r\n> ');
assert.equal(isConfirmedTerminalShellPrompt(state.promptText), false);
assert.equal(isUntrustedTerminalInputPrompt(state.promptText), true);
state = { origin: '', current: '' };
state = foldTerminalPromptSecurityOutput(state, "[root@h ~]# read -s -p '> ' secret\r\n");
state = foldTerminalPromptSecurityOutput(state, '> ');
assert.equal(isConfirmedTerminalShellPrompt(state.promptText), false);
assert.equal(isUntrustedTerminalInputPrompt(state.promptText), true);
state = foldTerminalPromptSecurityOutput({ origin: '', current: '' }, '> ');
assert.equal(isUntrustedTerminalInputPrompt(state.promptText), true);
assert.equal(isConfirmedTerminalShellPrompt(state.promptText), false);
});
test('prompt observation coalesces a CRLF split across chunks (#3796)', () => {
let state = { origin: '', current: '' };
state = foldTerminalPromptSecurityOutput(state, "[root@h ~]# cat >> f << EOF\r");
state = foldTerminalPromptSecurityOutput(state, '\n> ');
assert.equal(isConfirmedTerminalShellPrompt(state.promptText), true);
assert.equal(isUntrustedTerminalInputPrompt(state.promptText), false);
});
test('vim ex command-line is not an untrusted input prompt (#3422)', () => {
assert.equal(isUntrustedTerminalInputPrompt(':', { alternateScreen: true }), false);
assert.equal(isUntrustedTerminalInputPrompt(': ', { alternateScreen: true }), false);
@@ -147,6 +211,14 @@ test('renderer and main-process prompt classifiers stay aligned', () => {
['Challenge # 1:', undefined, true],
['router> show ip:', { allowHostStyleGreaterThan: true }, false],
['router> show ip:', undefined, true],
['>', undefined, true],
['> ', undefined, true],
['[root@h ~]# cat >> f << EOF\r\n> ', undefined, false],
['[root@h ~]# cat >> f << EOF\r\n> echo madvise > /sys/kernel/mm/transparent_hugepage/enabled\r\n> ', undefined, false],
["[root@h ~]# read -s -p '> ' secret\r\n> ", undefined, true],
['Custom>', undefined, true],
['Token>', undefined, true],
['Password:', undefined, true],
];
for (const [prompt, options, expected] of cases) {
+101 -1
View File
@@ -72,6 +72,45 @@ export function appendTerminalPromptSecurityTail(previous: string, chunk: string
return combined.slice(boundary + 1).slice(-MAX_PROMPT_SECURITY_TAIL_CHARS);
}
function nextHeredocPromptOrigin(origin: string, completedLine: string): string {
const plain = stripTerminalControlSequences(completedLine).trim();
// A blank line ends the continuation. A PS2 echo (">" or "> ...") does not.
if (isBashPs2ContinuationLine(plain)) return origin;
return plain.slice(0, MAX_PROMPT_SECURITY_TAIL_CHARS);
}
/**
* Current logical line, plus the heredoc opener when the line is a bare ">".
* `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.
*/
export function foldTerminalPromptSecurityOutput(
state: { origin: string; current: string; pendingCr?: boolean },
chunk: string,
): { origin: string; current: string; promptText: string; pendingCr: boolean } {
// A PTY can split CRLF across chunks. The LF is the rest of that break,
// not a second empty line that would drop the heredoc opener.
let pendingCr = state.pendingCr === true;
let input = chunk;
if (pendingCr && input.startsWith('\n')) {
input = input.slice(1);
pendingCr = false;
}
const combined = `${state.current}${input}`;
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;
if (input.endsWith('\r')) pendingCr = true;
else if (input.length > 0) pendingCr = false;
return { origin, current, promptText, pendingCr };
}
/**
* Detect a prompt-shaped authentication challenge. Vocabulary intentionally
* matches the SSH keyboard-interactive boundary and also covers PIN/auth-code
@@ -120,6 +159,8 @@ export function isConfirmedTerminalShellPrompt(
if (code >= 0xE000 && code <= 0xF8FF) return true;
}
if (/[$#%]$/u.test(prompt)) return true;
// POSIX sh/bash/ksh default PS2. Trusted only with heredoc context (#3796).
if (prompt === '>') return isHeredocPs2Continuation(promptText, options);
if (!prompt.endsWith('>')) return false;
if (/^(?:PS\s+)?[A-Za-z]:[\\/].*>$/u.test(prompt)) return true;
if (/[@\\/~:]\S*>$/u.test(prompt)) return true;
@@ -139,6 +180,64 @@ export function shouldUsePluginTerminalCompletionProvider(input: {
});
}
function splitTerminalLogicalLines(value: string): string[] {
const lines: string[] = [];
let start = 0;
for (let i = 0; i < value.length; i += 1) {
const ch = value[i];
if (ch !== '\n' && ch !== '\r') continue;
lines.push(value.slice(start, i));
if (ch === '\r' && value[i + 1] === '\n') i += 1;
start = i + 1;
}
lines.push(value.slice(start));
return lines;
}
/** Bash default PS2 echo: ">" or "> ...". Not "heredoc>" / "Custom>". */
function isBashPs2ContinuationLine(line: string): boolean {
const trimmed = line.trim();
return trimmed === '>' || trimmed.startsWith('> ');
}
/**
* Command text after the first confirmed shell prompt on this line.
* Returns null when the line is not a confirmed prompt plus typed input.
*/
function commandAfterConfirmedShellPrompt(
line: string,
options: ConfirmedPromptOptions,
): string | null {
const text = line.trim();
for (let i = 0; i < text.length - 1; i += 1) {
const ch = text[i];
if (ch !== '$' && ch !== '#' && ch !== '%' && ch !== '>') continue;
const rest = text.slice(i + 1);
if (!/^\s+\S/u.test(rest)) continue;
const candidate = text.slice(0, i + 1);
if (!isConfirmedTerminalShellPrompt(candidate, options)) continue;
if ((ch === '$' || ch === '#' || ch === '%') && !isPlausibleShellPromptBody(candidate.slice(0, -1))) {
continue;
}
return rest;
}
return null;
}
/**
* Bare ">" is bash PS2 only while output is still inside a heredoc opened by
* a confirmed shell command (#3796). Any other origin, including
* `read -s -p '> '`, stays fail-closed.
*/
function isHeredocPs2Continuation(value: string, options: ConfirmedPromptOptions): boolean {
const lines = splitTerminalLogicalLines(stripTerminalControlSequences(value));
let index = lines.length - 2;
while (index >= 0 && isBashPs2ContinuationLine(lines[index])) index -= 1;
if (index < 0) return false;
const command = commandAfterConfirmedShellPrompt(lines[index], options);
return command != null && command.includes('<<');
}
/**
* Body text before a `$` / `#` / `%` terminator that is plausible as a shell
* PS1 (bare marker, user@host, path, shell-version, or lowercase identity).
@@ -204,7 +303,8 @@ export function isUntrustedTerminalInputPrompt(
// Mid-command punctuation after a real shell prompt must keep broadcasting
// (#2709). Standalone `Label:` / `Custom>` challenges still fail closed.
if (hasTypedInputAfterConfirmedPrompt(prompt, options)) return false;
return !isConfirmedTerminalShellPrompt(prompt, options);
// Pass the original text so a bare ">" can see heredoc lines above it.
return !isConfirmedTerminalShellPrompt(value, options);
}
export { MAX_PROMPT_SECURITY_TAIL_CHARS };