[Init] Initial commit - NetMesh terminal manager
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
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
This commit is contained in:
202
domain/terminalPromptSecurity.ts
Normal file
202
domain/terminalPromptSecurity.ts
Normal file
@@ -0,0 +1,202 @@
|
||||
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 };
|
||||
Reference in New Issue
Block a user