fix(copilot): redact sim_key API keys from persisted Mothership chat messages (#4434)

* fix(copilot): redact sim_key API keys from persisted Mothership chat messages

* improvement(emcn): promote ApiKeyReveal to SecretReveal in emcn

* fix(copilot): thread cursor across content blocks when restoring sim_key tags
This commit is contained in:
Theodore Li
2026-05-04 16:22:34 -04:00
committed by GitHub
parent 2f90e41274
commit ae20d1ce90
12 changed files with 724 additions and 113 deletions
@@ -2,7 +2,13 @@
import { createElement, useMemo, useState } from 'react'
import { useParams } from 'next/navigation'
import { ArrowRight, ChevronDown, Expandable, ExpandableContent } from '@/components/emcn'
import {
ArrowRight,
ChevronDown,
Expandable,
ExpandableContent,
SecretReveal,
} from '@/components/emcn'
import { cn } from '@/lib/core/utils/cn'
import { OAUTH_PROVIDERS } from '@/lib/oauth/oauth'
import { ContextMentionIcon } from '@/app/workspace/[workspaceId]/home/components/context-mention-icon'
@@ -47,9 +53,10 @@ export const CREDENTIAL_TAG_TYPES = [
export type CredentialTagType = (typeof CREDENTIAL_TAG_TYPES)[number]
export interface CredentialTagData {
value: string
value?: string
type: CredentialTagType
provider?: string
redacted?: boolean
}
export interface MothershipErrorTagData {
@@ -140,12 +147,15 @@ function isUsageUpgradeTagData(value: unknown): value is UsageUpgradeTagData {
function isCredentialTagData(value: unknown): value is CredentialTagData {
if (!isRecord(value)) return false
return (
typeof value.value === 'string' &&
typeof value.type === 'string' &&
(CREDENTIAL_TAG_TYPES as readonly string[]).includes(value.type) &&
(value.provider === undefined || typeof value.provider === 'string')
)
if (
typeof value.type !== 'string' ||
!(CREDENTIAL_TAG_TYPES as readonly string[]).includes(value.type)
) {
return false
}
if (value.provider !== undefined && typeof value.provider !== 'string') return false
if (value.redacted === true) return value.value === undefined || typeof value.value === 'string'
return typeof value.value === 'string'
}
function isMothershipErrorTagData(value: unknown): value is MothershipErrorTagData {
@@ -595,24 +605,30 @@ const LockIcon = (props: { className?: string }) => (
)
function CredentialDisplay({ data }: { data: CredentialTagData }) {
if (data.type !== 'link' || !data.provider) return null
if (data.type === 'link') {
if (!data.provider) return null
const Icon = getCredentialIcon(data.provider) ?? LockIcon
return (
<a
href={data.value}
target='_blank'
rel='noopener noreferrer'
className='flex items-center gap-2 rounded-lg border border-[var(--divider)] px-3 py-2.5 transition-colors hover-hover:bg-[var(--surface-5)]'
>
{createElement(Icon, { className: 'h-[16px] w-[16px] shrink-0' })}
<span className='flex-1 font-base text-[var(--text-body)] text-sm'>
Connect {data.provider}
</span>
<ArrowRight className='h-[16px] w-[16px] shrink-0 text-[var(--text-icon)]' />
</a>
)
}
const Icon = getCredentialIcon(data.provider) ?? LockIcon
if (data.type === 'sim_key') {
return <SecretReveal value={data.value} redacted={data.redacted || !data.value} />
}
return (
<a
href={data.value}
target='_blank'
rel='noopener noreferrer'
className='flex items-center gap-2 rounded-lg border border-[var(--divider)] px-3 py-2.5 transition-colors hover-hover:bg-[var(--surface-5)]'
>
{createElement(Icon, { className: 'h-[16px] w-[16px] shrink-0' })}
<span className='flex-1 font-base text-[var(--text-body)] text-sm'>
Connect {data.provider}
</span>
<ArrowRight className='h-[16px] w-[16px] shrink-0 text-[var(--text-icon)]' />
</a>
)
return null
}
function MothershipErrorDisplay({ data }: { data: MothershipErrorTagData }) {
@@ -13,6 +13,11 @@ import type {
PersistedMessage,
} from '@/lib/copilot/chat/persisted-message'
import { normalizeMessage, withBlockTiming } from '@/lib/copilot/chat/persisted-message'
import {
captureRevealedSimKeys,
type RevealedSimKeysByMessage,
restoreRevealedSimKeysForMessage,
} from '@/lib/copilot/chat/sim-key-redaction'
import { resolveStreamToolOutcome } from '@/lib/copilot/chat/stream-tool-outcome'
import { MOTHERSHIP_CHAT_API_PATH, STREAM_STORAGE_KEY } from '@/lib/copilot/constants'
import type {
@@ -1063,6 +1068,7 @@ export function useChat(
)
const [genericResourceData, setGenericResourceData] = useState<GenericResourceData | null>(null)
const onResourceEventRef = useRef(options?.onResourceEvent)
const revealedSimKeysRef = useRef<RevealedSimKeysByMessage>(new Map())
onResourceEventRef.current = options?.onResourceEvent
const apiPathRef = useRef(options?.apiPath ?? MOTHERSHIP_CHAT_API_PATH)
apiPathRef.current = options?.apiPath ?? MOTHERSHIP_CHAT_API_PATH
@@ -1384,10 +1390,10 @@ export function useChat(
}, [clearActiveTurn, clearQueueDispatchState, resetEphemeralPreviewState, setTransportIdle])
const { data: chatHistory } = useChatHistory(resolvedChatId)
const messages = useMemo(
() => chatHistory?.messages.map(toDisplayMessage) ?? pendingMessages,
[chatHistory, pendingMessages]
)
const messages = useMemo(() => {
const source = chatHistory?.messages.map(toDisplayMessage) ?? pendingMessages
return source.map((m) => restoreRevealedSimKeysForMessage(m, revealedSimKeysRef.current))
}, [chatHistory, pendingMessages])
const addResource = useCallback((resource: MothershipResource): boolean => {
if (resourcesRef.current.some((r) => r.type === resource.type && r.id === resource.id)) {
return false
@@ -1888,6 +1894,11 @@ export function useChat(
const flush = () => {
if (isStale()) return
streamingBlocksRef.current = [...blocks]
captureRevealedSimKeys(
revealedSimKeysRef.current,
[assistantId, streamRequestId],
runningText
)
const activeChatId = chatIdRef.current
if (!activeChatId) {
const snapshot: Partial<ChatMessage> = {
@@ -2,7 +2,6 @@
import { useState } from 'react'
import { createLogger } from '@sim/logger'
import { Check, Copy } from 'lucide-react'
import {
Button,
ButtonGroup,
@@ -13,6 +12,7 @@ import {
ModalContent,
ModalFooter,
ModalHeader,
SecretReveal,
} from '@/components/emcn'
import { type ApiKey, useCreateApiKey } from '@/hooks/queries/api-keys'
@@ -50,8 +50,6 @@ export function CreateApiKeyModal({
const [createError, setCreateError] = useState<string | null>(null)
const [newKey, setNewKey] = useState<ApiKey | null>(null)
const [showNewKeyDialog, setShowNewKeyDialog] = useState(false)
const [copySuccess, setCopySuccess] = useState(false)
const createApiKeyMutation = useCreateApiKey()
const handleCreateKey = async () => {
@@ -105,12 +103,6 @@ export function CreateApiKeyModal({
setCreateError(null)
}
const copyToClipboard = (key: string) => {
navigator.clipboard.writeText(key)
setCopySuccess(true)
setTimeout(() => setCopySuccess(false), 2000)
}
return (
<>
{/* Create API Key Dialog */}
@@ -209,7 +201,6 @@ export function CreateApiKeyModal({
setShowNewKeyDialog(dialogOpen)
if (!dialogOpen) {
setNewKey(null)
setCopySuccess(false)
}
}}
>
@@ -223,27 +214,7 @@ export function CreateApiKeyModal({
</span>
</p>
{newKey && (
<div className='relative mt-2.5'>
<div className='flex h-9 items-center rounded-md border bg-[var(--surface-1)] px-2.5 pr-10'>
<code className='flex-1 truncate font-mono text-[var(--text-primary)] text-sm'>
{newKey.key}
</code>
</div>
<Button
variant='ghost'
className='-translate-y-1/2 absolute top-1/2 right-[4px] h-[28px] w-[28px] rounded-sm text-[var(--text-muted)] hover-hover:text-[var(--text-primary)]'
onClick={() => copyToClipboard(newKey.key)}
>
{copySuccess ? (
<Check className='h-[14px] w-[14px]' />
) : (
<Copy className='h-[14px] w-[14px]' />
)}
<span className='sr-only'>Copy to clipboard</span>
</Button>
</div>
)}
{newKey && <SecretReveal value={newKey.key} className='mt-2.5' />}
</ModalBody>
</ModalContent>
</Modal>
@@ -4,7 +4,7 @@ import { useMemo, useState } from 'react'
// import { useParams } from 'next/navigation'
import { createLogger } from '@sim/logger'
import { formatDate } from '@sim/utils/formatting'
import { Check, Copy, Plus, Search } from 'lucide-react'
import { Plus, Search } from 'lucide-react'
import {
Button,
Input as EmcnInput,
@@ -13,6 +13,7 @@ import {
ModalContent,
ModalFooter,
ModalHeader,
SecretReveal,
// Switch,
} from '@/components/emcn'
import { Input } from '@/components/ui'
@@ -58,7 +59,6 @@ export function Copilot() {
const [newKeyName, setNewKeyName] = useState('')
const [newKey, setNewKey] = useState<string | null>(null)
const [showNewKeyDialog, setShowNewKeyDialog] = useState(false)
const [copySuccess, setCopySuccess] = useState(false)
const [deleteKey, setDeleteKey] = useState<CopilotKey | null>(null)
const [showDeleteDialog, setShowDeleteDialog] = useState(false)
const [searchTerm, setSearchTerm] = useState('')
@@ -115,12 +115,6 @@ export function Copilot() {
}
}
const copyToClipboard = (key: string) => {
navigator.clipboard.writeText(key)
setCopySuccess(true)
setTimeout(() => setCopySuccess(false), 2000)
}
const handleDeleteKey = async () => {
if (!deleteKey) return
try {
@@ -316,7 +310,6 @@ export function Copilot() {
setShowNewKeyDialog(open)
if (!open) {
setNewKey(null)
setCopySuccess(false)
}
}}
>
@@ -330,27 +323,7 @@ export function Copilot() {
</span>
</p>
{newKey && (
<div className='relative mt-2.5'>
<div className='flex h-9 items-center rounded-md border bg-[var(--surface-1)] px-2.5 pr-10'>
<code className='flex-1 truncate font-mono text-[var(--text-primary)] text-sm'>
{newKey}
</code>
</div>
<Button
variant='ghost'
className='-translate-y-1/2 absolute top-1/2 right-[4px] h-[28px] w-[28px] rounded-sm text-[var(--text-muted)] hover-hover:text-[var(--text-primary)]'
onClick={() => copyToClipboard(newKey)}
>
{copySuccess ? (
<Check className='h-[14px] w-[14px]' />
) : (
<Copy className='h-[14px] w-[14px]' />
)}
<span className='sr-only'>Copy to clipboard</span>
</Button>
</div>
)}
{newKey && <SecretReveal value={newKey} className='mt-2.5' />}
</ModalBody>
</ModalContent>
</Modal>
@@ -1,8 +1,8 @@
'use client'
import { useCallback, useEffect, useRef, useState } from 'react'
import { Button, Check, Copy } from '@/components/emcn'
import { cn } from '@/lib/core/utils/cn'
import { useCopyToClipboard } from '@/hooks/use-copy-to-clipboard'
interface CopyCodeButtonProps {
code: string
@@ -10,33 +10,16 @@ interface CopyCodeButtonProps {
}
export function CopyCodeButton({ code, className }: CopyCodeButtonProps) {
const [copied, setCopied] = useState(false)
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const handleCopy = useCallback(async () => {
try {
await navigator.clipboard.writeText(code)
setCopied(true)
if (timerRef.current) clearTimeout(timerRef.current)
timerRef.current = setTimeout(() => setCopied(false), 2000)
} catch {}
}, [code])
useEffect(
() => () => {
if (timerRef.current) clearTimeout(timerRef.current)
},
[]
)
const { copied, copy } = useCopyToClipboard()
return (
<Button
type='button'
variant='ghost'
onClick={handleCopy}
onClick={() => copy(code)}
className={cn('flex items-center gap-1 rounded px-1.5 py-0.5 text-xs', className)}
>
{copied ? <Check className='size-3.5' /> : <Copy className='size-3.5' />}
{copied ? <Check className='h-[14px] w-[14px]' /> : <Copy className='h-[14px] w-[14px]' />}
</Button>
)
}
@@ -126,6 +126,7 @@ export {
SModalTrigger,
} from './s-modal/s-modal'
export { SecretInput, type SecretInputProps } from './secret-input/secret-input'
export { SecretReveal, type SecretRevealProps } from './secret-reveal/secret-reveal'
export { Skeleton } from './skeleton/skeleton'
export { Slider, type SliderProps } from './slider/slider'
export { Switch } from './switch/switch'
@@ -0,0 +1,77 @@
/**
* A read-only display for a one-time secret reveal: the value renders inside
* a bordered code box with a copy button, or as masked dots when redacted.
*
* @remarks
* Use for surfaces that show a freshly-generated credential (API key, signing
* secret, etc.) once and then need to fall back to a redacted state on
* subsequent renders. Pair with `redacted` (or simply omit `value`) to render
* the masked state without a copy affordance.
*
* @example
* ```tsx
* import { SecretReveal } from '@/components/emcn'
*
* <SecretReveal value={apiKey} />
* <SecretReveal redacted />
* ```
*/
'use client'
import { Button, Check, Copy } from '@/components/emcn'
import { cn } from '@/lib/core/utils/cn'
import { useCopyToClipboard } from '@/hooks/use-copy-to-clipboard'
const REDACTED_DOTS = '••••••••••••••••••••••••••••••••'
export interface SecretRevealProps {
/** Secret value to display. When absent or `redacted` is true, renders masked dots. */
value?: string
/** Force the masked state even when `value` is provided. */
redacted?: boolean
className?: string
}
export function SecretReveal({ value, className, redacted = false }: SecretRevealProps) {
const { copied, copy } = useCopyToClipboard()
const isHidden = redacted || !value
const handleCopy = () => {
if (isHidden || !value) return
copy(value)
}
return (
<div className={cn('relative', className)}>
<div
className={cn(
'flex h-9 items-center rounded-md border bg-[var(--surface-1)] px-2.5',
!isHidden && 'pr-10'
)}
>
<code
className={cn(
'flex-1 truncate font-mono text-sm',
isHidden ? 'text-[var(--text-muted)]' : 'text-[var(--text-primary)]'
)}
>
{isHidden ? REDACTED_DOTS : value}
</code>
</div>
{!isHidden && (
<Button
variant='ghost'
className='-translate-y-1/2 absolute top-1/2 right-[4px] h-[28px] w-[28px] rounded-sm text-[var(--text-muted)] hover-hover:text-[var(--text-primary)]'
onClick={handleCopy}
>
{copied ? (
<Check className='h-[14px] w-[14px]' />
) : (
<Copy className='h-[14px] w-[14px]' />
)}
<span className='sr-only'>Copy to clipboard</span>
</Button>
)}
</div>
)
}
+59
View File
@@ -0,0 +1,59 @@
'use client'
import { useCallback, useEffect, useRef, useState } from 'react'
interface UseCopyToClipboardOptions {
/** How long the `copied` flag stays true before resetting. Defaults to 2000ms. */
resetMs?: number
}
interface UseCopyToClipboardReturn {
copied: boolean
copy: (text: string) => Promise<boolean>
}
/**
* Copy text to the clipboard with a transient `copied` flag for swap-icon
* feedback (e.g. Copy → Check for ~2s).
*
* Replaces the `[copied, setCopied] + setTimeout` boilerplate that's been
* duplicated across ~30 callsites. Each `copy()` call resets the timer so
* back-to-back copies don't stack timeouts; the timer is cleared on unmount.
*
* @example
* const { copied, copy } = useCopyToClipboard()
* <button onClick={() => copy(value)}>
* {copied ? <Check /> : <Copy />}
* </button>
*/
export function useCopyToClipboard(
options: UseCopyToClipboardOptions = {}
): UseCopyToClipboardReturn {
const { resetMs = 2000 } = options
const [copied, setCopied] = useState(false)
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const copy = useCallback(
async (text: string): Promise<boolean> => {
try {
await navigator.clipboard.writeText(text)
setCopied(true)
if (timerRef.current) clearTimeout(timerRef.current)
timerRef.current = setTimeout(() => setCopied(false), resetMs)
return true
} catch {
return false
}
},
[resetMs]
)
useEffect(
() => () => {
if (timerRef.current) clearTimeout(timerRef.current)
},
[]
)
return { copied, copy }
}
@@ -75,6 +75,102 @@ describe('persisted-message', () => {
expect(persisted.requestId).toBe('sim-request-1')
})
it('redacts sim_key credential tags so persisted assistant messages never re-expose the key', () => {
const live = `Here is your key: <credential>${JSON.stringify({ value: 'sk-sim-secret-123', type: 'sim_key' })}</credential> save it.`
const result: OrchestratorResult = {
success: true,
content: live,
requestId: 'req-1',
contentBlocks: [{ type: 'text', content: live }],
toolCalls: [],
}
const persisted = buildPersistedAssistantMessage(result)
expect(persisted.content).not.toContain('sk-sim-secret-123')
expect(persisted.content).toContain('"redacted":true')
const textBlock = persisted.contentBlocks?.find((b) => b.type === 'text')
expect(textBlock?.content).not.toContain('sk-sim-secret-123')
expect(textBlock?.content).toContain('"redacted":true')
})
it('redacts sim_key credential tags split across streamed text chunks', () => {
const chunks = [
'Here\'s your key:\n\n<credential>{"value": "sk-',
'sim-secret',
'-12345',
'", "type":',
' "sim_key"}</credential>',
'\n\nDone.',
]
const result: OrchestratorResult = {
success: true,
content: chunks.join(''),
requestId: 'req-1',
contentBlocks: chunks.map((c) => ({ type: 'text', content: c })),
toolCalls: [],
}
const persisted = buildPersistedAssistantMessage(result)
expect(persisted.content).not.toContain('sk-sim-secret-12345')
expect(persisted.contentBlocks).toBeDefined()
const joined = (persisted.contentBlocks ?? []).map((b) => b.content ?? '').join('')
expect(joined).not.toContain('sk-sim-secret-12345')
expect(joined).toContain('"redacted":true')
})
it('redacts the api key from a persisted generate_api_key tool result output', () => {
const result: OrchestratorResult = {
success: true,
content: '',
requestId: 'req-1',
contentBlocks: [
{
type: 'tool_call',
toolCall: {
id: 'tool-1',
name: 'generate_api_key',
status: 'success',
params: { name: 'workspace-key' },
result: {
success: true,
output: {
id: 'k1',
name: 'workspace-key',
key: 'sk-sim-tool-output-secret',
},
},
},
},
],
toolCalls: [],
}
const persisted = buildPersistedAssistantMessage(result)
const toolBlock = persisted.contentBlocks?.find((b) => b.toolCall?.name === 'generate_api_key')
const output = toolBlock?.toolCall?.result?.output as Record<string, unknown> | undefined
expect(output?.key).toBe('[REDACTED]')
expect(output?.redacted).toBe(true)
expect(JSON.stringify(persisted)).not.toContain('sk-sim-tool-output-secret')
})
it('leaves non-sim_key credential tags untouched', () => {
const live = `<credential>${JSON.stringify({ value: 'https://oauth.example/connect', type: 'link', provider: 'slack' })}</credential>`
const result: OrchestratorResult = {
success: true,
content: live,
requestId: 'req-1',
contentBlocks: [{ type: 'text', content: live }],
toolCalls: [],
}
const persisted = buildPersistedAssistantMessage(result)
expect(persisted.content).toContain('https://oauth.example/connect')
})
it('normalizes legacy tool_call and top-level toolCalls shapes', () => {
const normalized = normalizeMessage({
id: 'msg-1',
+10 -3
View File
@@ -1,4 +1,9 @@
import { generateId } from '@sim/utils/id'
import {
mergeAndRedactPersistedBlocks,
redactSensitiveContent,
redactToolCallResult,
} from '@/lib/copilot/chat/sim-key-redaction'
import {
MothershipStreamV1CompletionStatus,
MothershipStreamV1EventType,
@@ -164,11 +169,13 @@ function mapContentBlockBody(block: ContentBlock): PersistedContentBlock {
state === 'pending' ||
state === 'executing'
const redactedResult = redactToolCallResult(block.toolCall.name, block.toolCall.result)
const toolCall: PersistedToolCall = {
id: block.toolCall.id,
name: block.toolCall.name,
state,
...(isSubagentTool && isNonTerminal ? {} : { result: block.toolCall.result }),
...(isSubagentTool && isNonTerminal ? {} : { result: redactedResult }),
...(isSubagentTool && isNonTerminal
? {}
: block.toolCall.params
@@ -202,7 +209,7 @@ export function buildPersistedAssistantMessage(
const message: PersistedMessage = {
id: generateId(),
role: 'assistant',
content: result.content,
content: redactSensitiveContent(result.content),
timestamp: new Date().toISOString(),
}
@@ -211,7 +218,7 @@ export function buildPersistedAssistantMessage(
}
if (result.contentBlocks.length > 0) {
message.contentBlocks = result.contentBlocks.map(mapContentBlock)
message.contentBlocks = mergeAndRedactPersistedBlocks(result.contentBlocks.map(mapContentBlock))
}
return message
@@ -0,0 +1,154 @@
/**
* @vitest-environment node
*/
import { describe, expect, it } from 'vitest'
import type { ChatMessage } from '@/app/workspace/[workspaceId]/home/types'
import {
captureRevealedSimKeys,
extractRevealedSimKeys,
restoreRevealedSimKeysForMessage,
} from './sim-key-redaction'
const credential = (value: string) =>
`<credential>${JSON.stringify({ value, type: 'sim_key' })}</credential>`
const redacted = `<credential>${JSON.stringify({ type: 'sim_key', redacted: true })}</credential>`
describe('sim-key-redaction', () => {
describe('extractRevealedSimKeys', () => {
it('returns sim_key values in document order', () => {
const text = `first ${credential('sk-sim-A')} mid ${credential('sk-sim-B')}`
expect(extractRevealedSimKeys(text)).toEqual(['sk-sim-A', 'sk-sim-B'])
})
it('skips redacted entries and non-sim_key tags', () => {
const link = `<credential>${JSON.stringify({ value: 'https://x', type: 'link', provider: 'slack' })}</credential>`
const text = `${link} ${credential('sk-sim-A')} ${redacted}`
expect(extractRevealedSimKeys(text)).toEqual(['sk-sim-A'])
})
})
describe('captureRevealedSimKeys', () => {
it('records new keys under each provided key', () => {
const cache = new Map<string, string[]>()
captureRevealedSimKeys(cache, ['msg-1', 'req-1'], credential('sk-sim-A'))
expect(cache.get('msg-1')).toEqual(['sk-sim-A'])
expect(cache.get('req-1')).toEqual(['sk-sim-A'])
})
it('extends but never shrinks the captured list across calls', () => {
const cache = new Map<string, string[]>()
captureRevealedSimKeys(
cache,
['msg-1'],
`${credential('sk-sim-A')} ${credential('sk-sim-B')}`
)
captureRevealedSimKeys(cache, ['msg-1'], credential('sk-sim-A'))
expect(cache.get('msg-1')).toEqual(['sk-sim-A', 'sk-sim-B'])
})
it('skips undefined keys without throwing', () => {
const cache = new Map<string, string[]>()
captureRevealedSimKeys(cache, ['msg-1', undefined], credential('sk-sim-A'))
expect(cache.get('msg-1')).toEqual(['sk-sim-A'])
expect(cache.size).toBe(1)
})
it('ignores content with no credential tag', () => {
const cache = new Map<string, string[]>()
captureRevealedSimKeys(cache, ['msg-1'], 'plain assistant text')
expect(cache.has('msg-1')).toBe(false)
})
})
describe('restoreRevealedSimKeysForMessage', () => {
it('substitutes the live key back into a redacted message', () => {
const cache = new Map<string, string[]>([['msg-1', ['sk-sim-A']]])
const msg: ChatMessage = {
id: 'msg-1',
role: 'assistant',
content: `Here is your key: ${redacted} save it.`,
contentBlocks: [{ type: 'text', content: `Here is your key: ${redacted} save it.` }],
}
const restored = restoreRevealedSimKeysForMessage(msg, cache)
expect(restored.content).toContain('"sk-sim-A"')
expect(restored.content).not.toContain('"redacted":true')
expect(restored.contentBlocks?.[0].content).toContain('"sk-sim-A"')
})
it('substitutes multiple keys in stream order', () => {
const cache = new Map<string, string[]>([['msg-1', ['sk-sim-A', 'sk-sim-B']]])
const msg: ChatMessage = {
id: 'msg-1',
role: 'assistant',
content: `first ${redacted} second ${redacted}`,
}
const restored = restoreRevealedSimKeysForMessage(msg, cache)
expect(restored.content).toBe(
`first ${credential('sk-sim-A')} second ${credential('sk-sim-B')}`
)
})
it('leaves a redacted tag in place if no live value is captured for that slot', () => {
const cache = new Map<string, string[]>([['msg-1', ['sk-sim-A']]])
const msg: ChatMessage = {
id: 'msg-1',
role: 'assistant',
content: `first ${redacted} second ${redacted}`,
}
const restored = restoreRevealedSimKeysForMessage(msg, cache)
expect(restored.content).toBe(`first ${credential('sk-sim-A')} second ${redacted}`)
})
it('returns the same message reference when nothing to restore', () => {
const cache = new Map<string, string[]>()
const msg: ChatMessage = {
id: 'msg-1',
role: 'assistant',
content: 'no credentials here',
}
expect(restoreRevealedSimKeysForMessage(msg, cache)).toBe(msg)
})
it('does nothing for user messages', () => {
const cache = new Map<string, string[]>([['msg-1', ['sk-sim-A']]])
const msg: ChatMessage = {
id: 'msg-1',
role: 'user',
content: redacted,
}
expect(restoreRevealedSimKeysForMessage(msg, cache)).toBe(msg)
})
it('threads the cursor across separate content blocks so each block gets its matching key', () => {
const cache = new Map<string, string[]>([['msg-1', ['sk-sim-A', 'sk-sim-B']]])
const msg: ChatMessage = {
id: 'msg-1',
role: 'assistant',
content: `first ${redacted} (tool ran) second ${redacted}`,
contentBlocks: [
{ type: 'text', content: `first ${redacted}` },
{ type: 'tool_call', content: '' },
{ type: 'text', content: `second ${redacted}` },
],
}
const restored = restoreRevealedSimKeysForMessage(msg, cache)
expect(restored.contentBlocks?.[0].content).toContain('"sk-sim-A"')
expect(restored.contentBlocks?.[0].content).not.toContain('"sk-sim-B"')
expect(restored.contentBlocks?.[2].content).toContain('"sk-sim-B"')
expect(restored.contentBlocks?.[2].content).not.toContain('"sk-sim-A"')
})
it('isolates revealed values by message id (multiple keys across messages)', () => {
const cache = new Map<string, string[]>([
['msg-1', ['sk-sim-A']],
['msg-2', ['sk-sim-B']],
])
const msg1: ChatMessage = { id: 'msg-1', role: 'assistant', content: redacted }
const msg2: ChatMessage = { id: 'msg-2', role: 'assistant', content: redacted }
expect(restoreRevealedSimKeysForMessage(msg1, cache).content).toContain('sk-sim-A')
expect(restoreRevealedSimKeysForMessage(msg2, cache).content).toContain('sk-sim-B')
expect(restoreRevealedSimKeysForMessage(msg1, cache).content).not.toContain('sk-sim-B')
})
})
})
@@ -0,0 +1,263 @@
import type { PersistedContentBlock } from '@/lib/copilot/chat/persisted-message'
import {
MothershipStreamV1EventType,
MothershipStreamV1TextChannel,
} from '@/lib/copilot/generated/mothership-stream-v1'
import { GenerateApiKey } from '@/lib/copilot/generated/tool-catalog-v1'
import { REDACTED_MARKER } from '@/lib/core/security/redaction'
import type { ChatMessage, ContentBlock } from '@/app/workspace/[workspaceId]/home/types'
/**
* Two-sided handling of `sim_key` API keys in the Mothership chat:
*
* - **Write side** (server, runs in `buildPersistedAssistantMessage`):
* strip every revealed `<credential type="sim_key">` value before the row
* hits Postgres. Reloading a chat days later — or pulling the row from the
* DB directly — never re-exposes the key.
*
* - **Read side** (client, runs in `useChat`'s message selector): an in-memory
* page-session cache captures revealed values during the live SSE stream.
* When the post-stream refetch returns the redacted persisted message, the
* selector re-injects the captured values so the user can still copy the
* key they just generated. Cache is dropped on page unload.
*/
const CREDENTIAL_TAG_PATTERN = /<credential>([\s\S]*?)<\/credential>/g
const REDACTED_TAG_PATTERN = /<credential>[^<]*"redacted"\s*:\s*true[^<]*<\/credential>/
const SIM_KEY_TYPE = 'sim_key'
const REDACTED_SIM_KEY_TAG = `<credential>${JSON.stringify({
type: SIM_KEY_TYPE,
redacted: true,
})}</credential>`
interface CredentialTagBody {
type?: unknown
value?: unknown
redacted?: unknown
}
function parseCredentialBody(body: string): CredentialTagBody | null {
try {
return JSON.parse(body) as CredentialTagBody
} catch {
return null
}
}
function hasRedactedSimKeyTag(content: string | undefined): boolean {
return typeof content === 'string' && REDACTED_TAG_PATTERN.test(content)
}
// Write side ---------------------------------------------------------------
/**
* Replace every revealed `<credential type="sim_key">` tag in `content` with a
* placeholder marked `redacted: true`. Other credential types (e.g. OAuth
* `link`) and malformed bodies pass through unchanged.
*/
export function redactSensitiveContent<T extends string | undefined>(content: T): T {
if (typeof content !== 'string' || !content.includes('<credential>')) return content
return content.replace(CREDENTIAL_TAG_PATTERN, (match, body: string) => {
const parsed = parseCredentialBody(body)
return parsed?.type === SIM_KEY_TYPE ? REDACTED_SIM_KEY_TAG : match
}) as T
}
/**
* Replace the raw `key` field in a `generate_api_key` tool result with the
* shared redaction marker. The persisted tool result still records the
* call's outcome and metadata; only the secret is stripped.
*/
export function redactToolCallResult(
toolName: string | undefined,
result: { success: boolean; output?: unknown; error?: string } | undefined
): { success: boolean; output?: unknown; error?: string } | undefined {
if (!result || toolName !== GenerateApiKey.id) return result
const output = result.output
if (!output || typeof output !== 'object') return result
const record = output as Record<string, unknown>
if (typeof record.key !== 'string') return result
return {
...result,
output: { ...record, key: REDACTED_MARKER, redacted: true },
}
}
function isMergeableAssistantTextBlock(block: PersistedContentBlock): boolean {
return (
block.type === MothershipStreamV1EventType.text &&
block.channel === MothershipStreamV1TextChannel.assistant &&
block.toolCall === undefined
)
}
/**
* Streaming produces one assistant-text block per token chunk, which means a
* `<credential>...</credential>` tag can straddle dozens of blocks. Per-block
* redaction can't see across that boundary and would persist the secret. So
* coalesce consecutive same-lane assistant-text blocks into a single block,
* then redact the merged content.
*
* Block timestamps for assistant text aren't user-visible (only `thinking`
* blocks drive the "Thought for Ns" chip), so collapsing the run is safe.
*/
export function mergeAndRedactPersistedBlocks(
blocks: PersistedContentBlock[]
): PersistedContentBlock[] {
const out: PersistedContentBlock[] = []
let runStart = -1
let runLane: PersistedContentBlock['lane']
const flushRun = (endExclusive: number) => {
if (runStart < 0) return
const run = blocks.slice(runStart, endExclusive)
runStart = -1
if (run.length === 0) return
if (run.length === 1) {
const single = run[0]
out.push({ ...single, content: redactSensitiveContent(single.content) })
return
}
const head = run[0]
const tail = run[run.length - 1]
out.push({
...head,
content: redactSensitiveContent(run.map((b) => b.content ?? '').join('')),
...(tail.endedAt !== undefined ? { endedAt: tail.endedAt } : {}),
})
}
for (let i = 0; i < blocks.length; i++) {
const block = blocks[i]
const sameRun = runStart >= 0 && isMergeableAssistantTextBlock(block) && runLane === block.lane
if (sameRun) continue
flushRun(i)
if (isMergeableAssistantTextBlock(block)) {
runStart = i
runLane = block.lane
} else {
out.push(block)
}
}
flushRun(blocks.length)
return out
}
// Read side ----------------------------------------------------------------
/**
* Page-session cache of `sim_key` credential values revealed during the live
* SSE stream, keyed by either the synthetic live-assistant id (used while
* streaming) or the persisted message's `requestId` (used after refetch).
* Lives in a `useRef`; never persisted; dropped on unload.
*/
export type RevealedSimKeysByMessage = Map<string, string[]>
/**
* Scan an assembled assistant message for `<credential type="sim_key">` tags
* and return their values in stream order, skipping anything already redacted.
*/
export function extractRevealedSimKeys(content: string): string[] {
if (!content || !content.includes('<credential>')) return []
const values: string[] = []
for (const match of content.matchAll(CREDENTIAL_TAG_PATTERN)) {
const parsed = parseCredentialBody(match[1])
if (parsed?.type === SIM_KEY_TYPE && !parsed.redacted && typeof parsed.value === 'string') {
values.push(parsed.value)
}
}
return values
}
/**
* Extend the cache entries for the given keys with any newly-revealed values.
* Each key in `keys` is written the same array — passing both the live-stream
* id and the persisted `requestId` lets the post-finalize refetch hit the
* cache after the message is renamed to its real UUID. The longest captured
* list wins so a rerun that surfaces fewer values can't shrink the entry.
*/
export function captureRevealedSimKeys(
cache: RevealedSimKeysByMessage,
keys: ReadonlyArray<string | undefined>,
content: string
): void {
if (!content.includes('<credential>')) return
const next = extractRevealedSimKeys(content)
if (next.length === 0) return
for (const key of keys) {
if (!key) continue
const existing = cache.get(key)
if (!existing || next.length > existing.length) cache.set(key, next)
}
}
function restoreInString(
content: string,
revealedValues: string[],
startCursor: number
): {
next: string
changed: boolean
cursor: number
} {
if (!content.includes('<credential>') || revealedValues.length === 0) {
return { next: content, changed: false, cursor: startCursor }
}
let cursor = startCursor
let changed = false
const next = content.replace(CREDENTIAL_TAG_PATTERN, (match, body: string) => {
const parsed = parseCredentialBody(body)
if (parsed?.type === SIM_KEY_TYPE && parsed.redacted === true) {
const value = revealedValues[cursor]
cursor += 1
if (typeof value === 'string') {
changed = true
return `<credential>${JSON.stringify({ value, type: SIM_KEY_TYPE })}</credential>`
}
}
return match
})
return { next, changed, cursor }
}
/**
* Replace redacted `sim_key` tags in a single message with the live values
* captured for that message. Returns the original message reference unchanged
* when there's nothing to substitute, so memoized children keep their identity.
*/
export function restoreRevealedSimKeysForMessage(
message: ChatMessage,
cache: RevealedSimKeysByMessage
): ChatMessage {
if (message.role !== 'assistant') return message
const revealed =
cache.get(message.id) ?? (message.requestId ? cache.get(message.requestId) : undefined)
if (!revealed || revealed.length === 0) return message
if (
!hasRedactedSimKeyTag(message.content) &&
!message.contentBlocks?.some((b) => hasRedactedSimKeyTag(b.content))
) {
return message
}
const restoredContent = restoreInString(message.content, revealed, 0)
let blocksChanged = false
let blockCursor = 0
const nextBlocks: ContentBlock[] | undefined = message.contentBlocks?.map((block) => {
if (!hasRedactedSimKeyTag(block.content)) return block
const restored = restoreInString(block.content as string, revealed, blockCursor)
blockCursor = restored.cursor
if (!restored.changed) return block
blocksChanged = true
return { ...block, content: restored.next }
})
if (!restoredContent.changed && !blocksChanged) return message
return {
...message,
content: restoredContent.next,
...(nextBlocks ? { contentBlocks: nextBlocks } : {}),
}
}