feat: mask provider api key input (#772)

This commit is contained in:
Kevin Codex
2026-04-20 08:25:22 +08:00
committed by GitHub
parent f828171ef1
commit 13e9f22a83
6 changed files with 57 additions and 32 deletions

View File

@@ -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', () => {

View File

@@ -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}

View File

@@ -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({

View File

@@ -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()

View File

@@ -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 }),

View File

@@ -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(