diff --git a/src/commands/provider/provider.test.tsx b/src/commands/provider/provider.test.tsx index f227ccdd..12113f11 100644 --- a/src/commands/provider/provider.test.tsx +++ b/src/commands/provider/provider.test.tsx @@ -487,8 +487,8 @@ test('buildCurrentProviderSummary redacts poisoned model and endpoint values', ( }) expect(summary.providerLabel).toBe('OpenAI-compatible') - expect(summary.modelLabel).toBe('sk-...5678') - expect(summary.endpointLabel).toBe('sk-...5678') + expect(summary.modelLabel).toBe('sk-...678') + expect(summary.endpointLabel).toBe('sk-...678') }) test('buildCurrentProviderSummary labels generic local openai-compatible providers', () => { diff --git a/src/components/ProviderManager.tsx b/src/components/ProviderManager.tsx index 45e02d24..6bf0df28 100644 --- a/src/components/ProviderManager.tsx +++ b/src/components/ProviderManager.tsx @@ -1150,6 +1150,7 @@ export function ProviderManager({ mode, onDone }: Props): React.ReactNode { focus={true} showCursor={true} placeholder={`${currentStep.placeholder}${figures.ellipsis}`} + mask={currentStepKey === 'apiKey' ? '*' : undefined} columns={80} cursorOffset={cursorOffset} onChangeCursorOffset={setCursorOffset} diff --git a/src/components/TextInput.test.tsx b/src/components/TextInput.test.tsx index ed6cac03..979974c0 100644 --- a/src/components/TextInput.test.tsx +++ b/src/components/TextInput.test.tsx @@ -6,6 +6,7 @@ import stripAnsi from 'strip-ansi' import { createRoot } from '../ink.js' import { AppStateProvider } from '../state/AppState.js' +import { maskTextWithVisibleEdges } from '../utils/Cursor.js' import TextInput from './TextInput.js' import VimTextInput from './VimTextInput.js' @@ -199,6 +200,13 @@ test('TextInput renders typed characters before delayed parent value commits', a expect(output).not.toContain('Type here...') }) +test('maskTextWithVisibleEdges preserves only the first and last three chars', () => { + expect(maskTextWithVisibleEdges('sk-secret-12345678', '*')).toBe( + 'sk-************678', + ) + expect(maskTextWithVisibleEdges('abcdef', '*')).toBe('******') +}) + test('VimTextInput preserves rapid typed characters before delayed parent value commits', async () => { const { stdout, stdin, getOutput } = createTestStreams() const root = await createRoot({ diff --git a/src/utils/Cursor.ts b/src/utils/Cursor.ts index 0317ece7..4622ea0a 100644 --- a/src/utils/Cursor.ts +++ b/src/utils/Cursor.ts @@ -148,6 +148,42 @@ type Position = { column: number } +export function maskTextWithVisibleEdges( + value: string, + mask: string, + visiblePrefix = 3, + visibleSuffix = 3, +): string { + if (!mask || !value) return value + + const graphemes = Array.from(getGraphemeSegmenter().segment(value)) + const secretGraphemeCount = graphemes.filter( + ({ segment }) => segment !== '\n', + ).length + const visibleCount = visiblePrefix + visibleSuffix + + if (secretGraphemeCount <= visibleCount) { + return graphemes + .map(({ segment }) => (segment === '\n' ? segment : mask)) + .join('') + } + + let secretIndex = 0 + return graphemes + .map(({ segment }) => { + if (segment === '\n') return segment + + const nextSegment = + secretIndex < visiblePrefix || + secretIndex >= secretGraphemeCount - visibleSuffix + ? segment + : mask + secretIndex += 1 + return nextSegment + }) + .join('') +} + export class Cursor { readonly offset: number constructor( @@ -208,7 +244,12 @@ export class Cursor { maxVisibleLines?: number, ) { const { line, column } = this.getPosition() - const allLines = this.measuredText.getWrappedText() + const allLines = mask + ? new MeasuredText( + maskTextWithVisibleEdges(this.text, mask), + this.measuredText.columns, + ).getWrappedText() + : this.measuredText.getWrappedText() const startLine = this.getViewportStartLine(maxVisibleLines) const endLine = @@ -221,23 +262,6 @@ export class Cursor { .map((text, i) => { const currentLine = i + startLine let displayText = text - if (mask) { - const graphemes = Array.from(getGraphemeSegmenter().segment(text)) - if (currentLine === allLines.length - 1) { - // Last line: mask all but the trailing 6 chars so the user can - // confirm they pasted the right thing without exposing the full token - const visibleCount = Math.min(6, graphemes.length) - const maskCount = graphemes.length - visibleCount - const splitOffset = - graphemes.length > visibleCount ? graphemes[maskCount]!.index : 0 - displayText = mask.repeat(maskCount) + text.slice(splitOffset) - } else { - // Earlier wrapped lines: fully mask. Previously only the last line - // was masked, leaking the start of the token on narrow terminals - // where the pasted OAuth code wraps across multiple lines. - displayText = mask.repeat(graphemes.length) - } - } // looking for the line with the cursor if (line !== currentLine) return displayText.trimEnd() diff --git a/src/utils/providerProfile.test.ts b/src/utils/providerProfile.test.ts index 4e6ba263..f057c5f9 100644 --- a/src/utils/providerProfile.test.ts +++ b/src/utils/providerProfile.test.ts @@ -621,8 +621,8 @@ test('buildStartupEnvFromProfile treats explicit falsey provider flags as user i }) test('maskSecretForDisplay preserves only a short prefix and suffix', () => { - assert.equal(maskSecretForDisplay('sk-secret-12345678'), 'sk-...5678') - assert.equal(maskSecretForDisplay('AIzaSecret12345678'), 'AIza...5678') + assert.equal(maskSecretForDisplay('sk-secret-12345678'), 'sk-...678') + assert.equal(maskSecretForDisplay('AIzaSecret12345678'), 'AIz...678') }) test('redactSecretValueForDisplay masks poisoned display fields that equal configured secrets', () => { @@ -630,7 +630,7 @@ test('redactSecretValueForDisplay masks poisoned display fields that equal confi assert.equal( redactSecretValueForDisplay(apiKey, { OPENAI_API_KEY: apiKey }), - 'sk-...5678', + 'sk-...678', ) assert.equal( redactSecretValueForDisplay('gpt-4o', { OPENAI_API_KEY: apiKey }), diff --git a/src/utils/providerSecrets.ts b/src/utils/providerSecrets.ts index 78c2c9bf..8f90d163 100644 --- a/src/utils/providerSecrets.ts +++ b/src/utils/providerSecrets.ts @@ -61,15 +61,7 @@ export function maskSecretForDisplay( return 'configured' } - if (sanitized.startsWith('sk-')) { - return `${sanitized.slice(0, 3)}...${sanitized.slice(-4)}` - } - - if (sanitized.startsWith('AIza')) { - return `${sanitized.slice(0, 4)}...${sanitized.slice(-4)}` - } - - return `${sanitized.slice(0, 2)}...${sanitized.slice(-4)}` + return `${sanitized.slice(0, 3)}...${sanitized.slice(-3)}` } export function redactSecretValueForDisplay(