From df31ffcee7872b8d263451807818b368a9ba1eb4 Mon Sep 17 00:00:00 2001 From: kaitranntt Date: Thu, 4 Dec 2025 05:05:45 -0500 Subject: [PATCH] fix(prompt): strip bracketed paste escape sequences from password input Terminals using bracketed paste mode wrap pasted content with ESC[200~ (start) and ESC[201~ (end) sequences. These were incorrectly passed through to API keys, causing "[200~API_KEY[201~" instead of "API_KEY". Now buffers ESC sequences and discards recognized paste markers. --- src/utils/prompt.ts | 32 ++++++++++++ tests/unit/utils/prompt.test.js | 87 +++++++++++++++++++++++++++++++++ 2 files changed, 119 insertions(+) diff --git a/src/utils/prompt.ts b/src/utils/prompt.ts index 08330081..ae15b382 100644 --- a/src/utils/prompt.ts +++ b/src/utils/prompt.ts @@ -143,6 +143,11 @@ export class InteractivePrompt { /** * Get password/secret input (masked) + * + * Handles bracketed paste mode escape sequences that terminals send: + * - Start paste: ESC[200~ + * - End paste: ESC[201~ + * These are stripped automatically so pasted API keys work correctly. */ static async password(message: string, options: PasswordOptions = {}): Promise { const { mask = '*' } = options; @@ -162,6 +167,7 @@ export class InteractivePrompt { return new Promise((resolve) => { let input = ''; + let escapeBuffer = ''; // Buffer for escape sequence detection const cleanup = (): void => { if (process.stdin.setRawMode) { @@ -177,6 +183,32 @@ export class InteractivePrompt { for (const char of str) { const charCode = char.charCodeAt(0); + // ESC character (start of escape sequence) + if (charCode === 27) { + escapeBuffer = '\x1b'; + continue; + } + + // If we're in an escape sequence, buffer chars until we detect the pattern + if (escapeBuffer) { + escapeBuffer += char; + + // Check for bracketed paste sequences: ESC[200~ (start) or ESC[201~ (end) + if (escapeBuffer === '\x1b[200~' || escapeBuffer === '\x1b[201~') { + // Discard bracketed paste markers + escapeBuffer = ''; + continue; + } + + // If buffer is getting too long without match, it's not a paste sequence + // Flush buffer as regular input (shouldn't happen with API keys) + if (escapeBuffer.length > 6) { + // Not a recognized sequence - skip it entirely (likely other escape seq) + escapeBuffer = ''; + } + continue; + } + // Enter key (CR or LF) if (charCode === 13 || charCode === 10) { cleanup(); diff --git a/tests/unit/utils/prompt.test.js b/tests/unit/utils/prompt.test.js index 9157e91a..d32a8975 100644 --- a/tests/unit/utils/prompt.test.js +++ b/tests/unit/utils/prompt.test.js @@ -152,6 +152,93 @@ describe('InteractivePrompt', () => { }); }); + describe('password - bracketed paste handling', () => { + /** + * Test helper: Simulates the escape sequence filtering logic from password() + * This mirrors the implementation to verify bracketed paste sequences are stripped + */ + function stripBracketedPaste(input) { + let result = ''; + let escapeBuffer = ''; + + for (const char of input) { + const charCode = char.charCodeAt(0); + + // ESC character (start of escape sequence) + if (charCode === 27) { + escapeBuffer = '\x1b'; + continue; + } + + // If we're in an escape sequence, buffer chars until we detect the pattern + if (escapeBuffer) { + escapeBuffer += char; + + // Check for bracketed paste sequences: ESC[200~ (start) or ESC[201~ (end) + if (escapeBuffer === '\x1b[200~' || escapeBuffer === '\x1b[201~') { + escapeBuffer = ''; + continue; + } + + // If buffer is getting too long without match, it's not a paste sequence + if (escapeBuffer.length > 6) { + escapeBuffer = ''; + } + continue; + } + + // Regular printable character + if (charCode >= 32) { + result += char; + } + } + + return result; + } + + it('strips ESC[200~ (start paste) sequence', () => { + const input = '\x1b[200~sk-ant-api-key\x1b[201~'; + const result = stripBracketedPaste(input); + assert.strictEqual(result, 'sk-ant-api-key'); + }); + + it('handles API key pasted with bracketed paste mode', () => { + const pastedKey = '\x1b[200~sk-ant-api03-abcdefghijklmnop\x1b[201~'; + const result = stripBracketedPaste(pastedKey); + assert.strictEqual(result, 'sk-ant-api03-abcdefghijklmnop'); + }); + + it('passes through normal typed input without escape sequences', () => { + const typedKey = 'sk-ant-api03-normal-typing'; + const result = stripBracketedPaste(typedKey); + assert.strictEqual(result, 'sk-ant-api03-normal-typing'); + }); + + it('handles only start paste sequence', () => { + const input = '\x1b[200~my-api-key'; + const result = stripBracketedPaste(input); + assert.strictEqual(result, 'my-api-key'); + }); + + it('handles only end paste sequence', () => { + const input = 'my-api-key\x1b[201~'; + const result = stripBracketedPaste(input); + assert.strictEqual(result, 'my-api-key'); + }); + + it('handles multiple paste sequences', () => { + const input = '\x1b[200~first\x1b[201~\x1b[200~second\x1b[201~'; + const result = stripBracketedPaste(input); + assert.strictEqual(result, 'firstsecond'); + }); + + it('handles empty paste', () => { + const input = '\x1b[200~\x1b[201~'; + const result = stripBracketedPaste(input); + assert.strictEqual(result, ''); + }); + }); + describe('confirm', () => { it('returns true when CCS_YES=1', async () => { process.env.CCS_YES = '1';