Some checks failed
build-packages / resolve bundled mosh-client (push) Has been cancelled
build-packages / resolve bundled et-client (push) Has been cancelled
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / build-linux-x64 (push) Has been cancelled
build-packages / build-linux-arm64 (push) Has been cancelled
build-packages / release (push) Has been cancelled
build-packages / update Nix release metadata (push) Has been cancelled
build-packages / bump homebrew tap (push) Has been cancelled
test / lint-and-test (push) Has been cancelled
AI automation / Route event (push) Has been cancelled
AI automation / Hand reopened issue to maintainers (push) Has been cancelled
AI automation / Clean source issue state (push) Has been cancelled
AI automation / Reconcile handoffs (push) Has been cancelled
AI automation / Classify issue (push) Has been cancelled
AI automation / Claude Code smoke (push) Has been cancelled
AI automation / Review issue follow-up (push) Has been cancelled
AI automation / Publish issue follow-up (push) Has been cancelled
AI automation / Implement with Claude Code (push) Has been cancelled
AI automation / Publish implement PR (push) Has been cancelled
AI automation / Continue queued issue comments (push) Has been cancelled
AI automation / Codex review loop (push) Has been cancelled
AI automation / Publish Codex fix (push) Has been cancelled
AI automation / Clear Codex dispatch marker (push) Has been cancelled
AI automation / Own PR re-request Codex (push) Has been cancelled
AI automation / External PR re-request Codex (push) Has been cancelled
AI automation / Poll Codex reaction / retry (push) Has been cancelled
build-et-binaries / build-linux-x64 (push) Has been cancelled
build-et-binaries / build-linux-arm64 (push) Has been cancelled
build-et-binaries / build-macos-universal (push) Has been cancelled
build-et-binaries / build-windows-x64 (push) Has been cancelled
build-et-binaries / release (push) Has been cancelled
203 lines
7.7 KiB
TypeScript
203 lines
7.7 KiB
TypeScript
const ESCAPE_SEQUENCE = "\\x" + "1b";
|
||
const BELL_SEQUENCE = "\\x" + "07";
|
||
const ANSI_CONTROL_PATTERN = new RegExp(`${ESCAPE_SEQUENCE}\\[[0-?]*[ -/]*[@-~]`, 'gu');
|
||
const OSC_CONTROL_PATTERN = new RegExp(
|
||
`${ESCAPE_SEQUENCE}\\][^${BELL_SEQUENCE}]*(?:${BELL_SEQUENCE}|${ESCAPE_SEQUENCE}\\\\)`,
|
||
'gu',
|
||
);
|
||
const MAX_PROMPT_SECURITY_TAIL_CHARS = 2_048;
|
||
|
||
const SENSITIVE_ENGLISH_LABEL = [
|
||
String.raw`pass(?:word|phrase|code)`,
|
||
String.raw`one[\s-]?time(?:\s+(?:password|passcode|code|token))?`,
|
||
String.raw`\botp\b`,
|
||
String.raw`verification(?:\s+(?:code|token|passcode))?`,
|
||
String.raw`authentication\s+(?:code|token|passcode)`,
|
||
String.raw`security\s+(?:code|token|passcode|pin)`,
|
||
String.raw`\bpin\b`,
|
||
String.raw`\btoken\b`,
|
||
String.raw`2fa`,
|
||
String.raw`two[\s-]?factor`,
|
||
String.raw`multi[\s-]?factor`,
|
||
String.raw`\bmfa\b`,
|
||
String.raw`second\s+factor`,
|
||
String.raw`secondary(?:\s+\w+){0,3}\s+passw(?:ord)?`,
|
||
String.raw`second(?:\s+\w+){0,3}\s+passw(?:ord)?`,
|
||
String.raw`additional(?:\s+\w+){0,3}\s+passw(?:ord)?`,
|
||
String.raw`re[-\s]?enter\s+passw(?:ord)?`,
|
||
String.raw`confirm\s+passw(?:ord)?`,
|
||
String.raw`\bedr\b`,
|
||
String.raw`\bduo\b`,
|
||
].join('|');
|
||
|
||
const SENSITIVE_CJK_LABEL = [
|
||
'\u5bc6\u7801',
|
||
'\u53e3\u4ee4',
|
||
'\u52a8\u6001',
|
||
'\u4e00\u6b21\u6027',
|
||
'\u9a8c\u8bc1\u7801',
|
||
'\u9a8c\u8bc1\u4fe1\u606f',
|
||
'\u4ee4\u724c',
|
||
'\u53cc\u56e0\u7d20',
|
||
'\u591a\u56e0\u7d20',
|
||
'\u77ed\u4fe1\u9a8c\u8bc1',
|
||
'\u624b\u673a\u9a8c\u8bc1',
|
||
'\u4e8c\u6b21',
|
||
'\u5b89\u5168\u5bc6\u7801',
|
||
'\u6311\u6218\u7801',
|
||
].join('|');
|
||
|
||
const SENSITIVE_LABEL_PATTERN = new RegExp(
|
||
`(?:${SENSITIVE_ENGLISH_LABEL}|${SENSITIVE_CJK_LABEL})`,
|
||
'iu',
|
||
);
|
||
|
||
function stripTerminalControlSequences(value: string): string {
|
||
return value.replace(OSC_CONTROL_PATTERN, '').replace(ANSI_CONTROL_PATTERN, '');
|
||
}
|
||
|
||
function lastLogicalLine(value: string): string {
|
||
const plain = stripTerminalControlSequences(value);
|
||
const boundary = Math.max(plain.lastIndexOf('\n'), plain.lastIndexOf('\r'));
|
||
return plain.slice(boundary + 1).slice(-MAX_PROMPT_SECURITY_TAIL_CHARS);
|
||
}
|
||
|
||
/**
|
||
* Keep enough raw output to recognize authentication prompts split across
|
||
* transport chunks without retaining terminal history or unbounded data.
|
||
*/
|
||
export function appendTerminalPromptSecurityTail(previous: string, chunk: string): string {
|
||
const combined = `${previous}${chunk}`;
|
||
const boundary = Math.max(combined.lastIndexOf('\n'), combined.lastIndexOf('\r'));
|
||
return combined.slice(boundary + 1).slice(-MAX_PROMPT_SECURITY_TAIL_CHARS);
|
||
}
|
||
|
||
/**
|
||
* Detect a prompt-shaped authentication challenge. Vocabulary intentionally
|
||
* matches the SSH keyboard-interactive boundary and also covers PIN/auth-code
|
||
* variants used by local, Mosh, bastion, and device sessions.
|
||
*/
|
||
export function isSensitiveTerminalChallenge(value: string): boolean {
|
||
const line = lastLogicalLine(value).trim();
|
||
if (!line) return false;
|
||
const label = SENSITIVE_LABEL_PATTERN.exec(line);
|
||
if (!label) return false;
|
||
const prefix = line.slice(0, label.index ?? 0).trim();
|
||
const suffix = line.slice((label.index ?? 0) + label[0].length);
|
||
if (suffix.trim().length === 0) {
|
||
return prefix.length === 0
|
||
|| /(?:^|\s)(?:enter|input|provide|type|scan|please|your|current|new|old)\s*$/iu.test(prefix)
|
||
|| /(?:\u8f93\u5165|\u8bf7\u8f93\u5165|\u8bf7)\s*$/u.test(prefix);
|
||
}
|
||
if (/^\s+(?:for|of)\s+[^\r\n]{1,96}$/iu.test(suffix)) return true;
|
||
return /^[^\r\n::>›»]{0,96}[::>›»]\s*[^\r\n]{0,1024}$/u.test(suffix);
|
||
}
|
||
|
||
type ConfirmedPromptOptions = {
|
||
/** Network-device shells commonly use a bare host name followed by `>`. */
|
||
allowHostStyleGreaterThan?: boolean;
|
||
};
|
||
|
||
/**
|
||
* Positive policy for data that may cross the ordinary completion Provider
|
||
* boundary. Generic prompt parsing remains permissive for host UX/history,
|
||
* while third-party Providers require a recognizable shell/device prompt.
|
||
*/
|
||
export function isConfirmedTerminalShellPrompt(
|
||
promptText: string,
|
||
options: ConfirmedPromptOptions = {},
|
||
): boolean {
|
||
const prompt = lastLogicalLine(promptText).trim();
|
||
if (!prompt || isSensitiveTerminalChallenge(prompt)) return false;
|
||
if (/[❯❮→➜➤⟩»›]/u.test(prompt)) return true;
|
||
for (const character of prompt) {
|
||
const code = character.charCodeAt(0);
|
||
if (code >= 0xE000 && code <= 0xF8FF) return true;
|
||
}
|
||
if (/[$#%]$/u.test(prompt)) return true;
|
||
if (!prompt.endsWith('>')) return false;
|
||
if (/^(?:PS\s+)?[A-Za-z]:[\\/].*>$/u.test(prompt)) return true;
|
||
if (/[@\\/~:]\S*>$/u.test(prompt)) return true;
|
||
return options.allowHostStyleGreaterThan === true
|
||
&& /^[A-Za-z0-9_.-]+(?:\([^)]{1,128}\))?>$/u.test(prompt);
|
||
}
|
||
|
||
export function shouldUsePluginTerminalCompletionProvider(input: {
|
||
sensitiveInputActive: boolean;
|
||
promptText: string;
|
||
allowHostStyleGreaterThan?: boolean;
|
||
}): boolean {
|
||
return !input.sensitiveInputActive
|
||
&& !isSensitiveTerminalChallenge(input.promptText)
|
||
&& isConfirmedTerminalShellPrompt(input.promptText, {
|
||
allowHostStyleGreaterThan: input.allowHostStyleGreaterThan,
|
||
});
|
||
}
|
||
|
||
/**
|
||
* Body text before a `$` / `#` / `%` terminator that is plausible as a shell
|
||
* PS1 (bare marker, user@host, path, shell-version, or lowercase identity).
|
||
* Rejects English challenge labels that only happen to end with those markers
|
||
* (`Challenge #`, `Account $`).
|
||
*/
|
||
function isPlausibleShellPromptBody(body: string): boolean {
|
||
const trimmed = body.trim();
|
||
if (!trimmed) return true;
|
||
if (/@|[/\\~:]/u.test(trimmed)) return true;
|
||
if (/^(?:bash|zsh|sh|fish|ksh|csh|tcsh|dash)(?:-[\d.]+)?$/iu.test(trimmed)) return true;
|
||
// Single lowercase identity token: root, ubuntu, pi — not Title-Case labels.
|
||
return /^[a-z0-9_.-]+$/u.test(trimmed);
|
||
}
|
||
|
||
/**
|
||
* True when a confirmed shell/device prompt is followed by typed text on the
|
||
* same logical line (e.g. `user@host:~$ lsof -i:`). Trailing `:` / `>` in that
|
||
* typed text is ordinary command input, not an authentication boundary.
|
||
*
|
||
* Requires a real prompt boundary (terminator + whitespace) so mid-label
|
||
* shapes like `Challenge #1:` stay fail-closed. For `$`/`#`/`%`, also requires
|
||
* a plausible PS1 body so `Challenge # 1:` / `Account $ code:` stay untrusted.
|
||
*/
|
||
function hasTypedInputAfterConfirmedPrompt(
|
||
line: string,
|
||
options: ConfirmedPromptOptions = {},
|
||
): boolean {
|
||
for (let i = 0; i < line.length - 1; i += 1) {
|
||
const ch = line[i];
|
||
// Only terminators that can end a confirmed prompt without relying on a
|
||
// glyph appearing later in the typed command.
|
||
if (ch !== '$' && ch !== '#' && ch !== '%' && ch !== '>') continue;
|
||
const rest = line.slice(i + 1);
|
||
// Prompt terminators are followed by whitespace before typed input.
|
||
if (!/^\s+\S/u.test(rest)) continue;
|
||
const candidate = line.slice(0, i + 1);
|
||
if (!isConfirmedTerminalShellPrompt(candidate, options)) continue;
|
||
if ((ch === '$' || ch === '#' || ch === '%') && !isPlausibleShellPromptBody(candidate.slice(0, -1))) {
|
||
continue;
|
||
}
|
||
return true;
|
||
}
|
||
return false;
|
||
}
|
||
|
||
/**
|
||
* Fail closed for prompt-shaped input boundaries that are not positively
|
||
* identified as an ordinary shell/device prompt. This protects custom PAM,
|
||
* bastion, and appliance challenges whose labels contain no known vocabulary.
|
||
*/
|
||
export function isUntrustedTerminalInputPrompt(
|
||
value: string,
|
||
options: ConfirmedPromptOptions = {},
|
||
): boolean {
|
||
const prompt = lastLogicalLine(value).trim();
|
||
if (!prompt) return false;
|
||
if (isSensitiveTerminalChallenge(prompt)) return true;
|
||
if (!/[::>›»]\s*$/u.test(prompt)) return false;
|
||
// 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);
|
||
}
|
||
|
||
export { MAX_PROMPT_SECURITY_TAIL_CHARS };
|