feat: mask provider api key input (#772)
This commit is contained in:
@@ -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', () => {
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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 }),
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user