Files
NetMesh/domain/terminalPromptSecurity.ts
zhaolei 3c72efcb7f
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
[Init] Initial commit - NetMesh terminal manager
2026-09-13 18:24:01 +08:00

203 lines
7.7 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 };