Skip to content

Commit ae20d1c

Browse files
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
1 parent 2f90e41 commit ae20d1c

12 files changed

Lines changed: 724 additions & 113 deletions

File tree

apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/special-tags/special-tags.tsx

Lines changed: 40 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,13 @@
22

33
import { createElement, useMemo, useState } from 'react'
44
import { useParams } from 'next/navigation'
5-
import { ArrowRight, ChevronDown, Expandable, ExpandableContent } from '@/components/emcn'
5+
import {
6+
ArrowRight,
7+
ChevronDown,
8+
Expandable,
9+
ExpandableContent,
10+
SecretReveal,
11+
} from '@/components/emcn'
612
import { cn } from '@/lib/core/utils/cn'
713
import { OAUTH_PROVIDERS } from '@/lib/oauth/oauth'
814
import { ContextMentionIcon } from '@/app/workspace/[workspaceId]/home/components/context-mention-icon'
@@ -47,9 +53,10 @@ export const CREDENTIAL_TAG_TYPES = [
4753
export type CredentialTagType = (typeof CREDENTIAL_TAG_TYPES)[number]
4854

4955
export interface CredentialTagData {
50-
value: string
56+
value?: string
5157
type: CredentialTagType
5258
provider?: string
59+
redacted?: boolean
5360
}
5461

5562
export interface MothershipErrorTagData {
@@ -140,12 +147,15 @@ function isUsageUpgradeTagData(value: unknown): value is UsageUpgradeTagData {
140147

141148
function isCredentialTagData(value: unknown): value is CredentialTagData {
142149
if (!isRecord(value)) return false
143-
return (
144-
typeof value.value === 'string' &&
145-
typeof value.type === 'string' &&
146-
(CREDENTIAL_TAG_TYPES as readonly string[]).includes(value.type) &&
147-
(value.provider === undefined || typeof value.provider === 'string')
148-
)
150+
if (
151+
typeof value.type !== 'string' ||
152+
!(CREDENTIAL_TAG_TYPES as readonly string[]).includes(value.type)
153+
) {
154+
return false
155+
}
156+
if (value.provider !== undefined && typeof value.provider !== 'string') return false
157+
if (value.redacted === true) return value.value === undefined || typeof value.value === 'string'
158+
return typeof value.value === 'string'
149159
}
150160

151161
function isMothershipErrorTagData(value: unknown): value is MothershipErrorTagData {
@@ -595,24 +605,30 @@ const LockIcon = (props: { className?: string }) => (
595605
)
596606

597607
function CredentialDisplay({ data }: { data: CredentialTagData }) {
598-
if (data.type !== 'link' || !data.provider) return null
608+
if (data.type === 'link') {
609+
if (!data.provider) return null
610+
const Icon = getCredentialIcon(data.provider) ?? LockIcon
611+
return (
612+
<a
613+
href={data.value}
614+
target='_blank'
615+
rel='noopener noreferrer'
616+
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)]'
617+
>
618+
{createElement(Icon, { className: 'h-[16px] w-[16px] shrink-0' })}
619+
<span className='flex-1 font-base text-[var(--text-body)] text-sm'>
620+
Connect {data.provider}
621+
</span>
622+
<ArrowRight className='h-[16px] w-[16px] shrink-0 text-[var(--text-icon)]' />
623+
</a>
624+
)
625+
}
599626

600-
const Icon = getCredentialIcon(data.provider) ?? LockIcon
627+
if (data.type === 'sim_key') {
628+
return <SecretReveal value={data.value} redacted={data.redacted || !data.value} />
629+
}
601630

602-
return (
603-
<a
604-
href={data.value}
605-
target='_blank'
606-
rel='noopener noreferrer'
607-
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)]'
608-
>
609-
{createElement(Icon, { className: 'h-[16px] w-[16px] shrink-0' })}
610-
<span className='flex-1 font-base text-[var(--text-body)] text-sm'>
611-
Connect {data.provider}
612-
</span>
613-
<ArrowRight className='h-[16px] w-[16px] shrink-0 text-[var(--text-icon)]' />
614-
</a>
615-
)
631+
return null
616632
}
617633

618634
function MothershipErrorDisplay({ data }: { data: MothershipErrorTagData }) {

apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.ts

Lines changed: 15 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,11 @@ import type {
1313
PersistedMessage,
1414
} from '@/lib/copilot/chat/persisted-message'
1515
import { normalizeMessage, withBlockTiming } from '@/lib/copilot/chat/persisted-message'
16+
import {
17+
captureRevealedSimKeys,
18+
type RevealedSimKeysByMessage,
19+
restoreRevealedSimKeysForMessage,
20+
} from '@/lib/copilot/chat/sim-key-redaction'
1621
import { resolveStreamToolOutcome } from '@/lib/copilot/chat/stream-tool-outcome'
1722
import { MOTHERSHIP_CHAT_API_PATH, STREAM_STORAGE_KEY } from '@/lib/copilot/constants'
1823
import type {
@@ -1063,6 +1068,7 @@ export function useChat(
10631068
)
10641069
const [genericResourceData, setGenericResourceData] = useState<GenericResourceData | null>(null)
10651070
const onResourceEventRef = useRef(options?.onResourceEvent)
1071+
const revealedSimKeysRef = useRef<RevealedSimKeysByMessage>(new Map())
10661072
onResourceEventRef.current = options?.onResourceEvent
10671073
const apiPathRef = useRef(options?.apiPath ?? MOTHERSHIP_CHAT_API_PATH)
10681074
apiPathRef.current = options?.apiPath ?? MOTHERSHIP_CHAT_API_PATH
@@ -1384,10 +1390,10 @@ export function useChat(
13841390
}, [clearActiveTurn, clearQueueDispatchState, resetEphemeralPreviewState, setTransportIdle])
13851391

13861392
const { data: chatHistory } = useChatHistory(resolvedChatId)
1387-
const messages = useMemo(
1388-
() => chatHistory?.messages.map(toDisplayMessage) ?? pendingMessages,
1389-
[chatHistory, pendingMessages]
1390-
)
1393+
const messages = useMemo(() => {
1394+
const source = chatHistory?.messages.map(toDisplayMessage) ?? pendingMessages
1395+
return source.map((m) => restoreRevealedSimKeysForMessage(m, revealedSimKeysRef.current))
1396+
}, [chatHistory, pendingMessages])
13911397
const addResource = useCallback((resource: MothershipResource): boolean => {
13921398
if (resourcesRef.current.some((r) => r.type === resource.type && r.id === resource.id)) {
13931399
return false
@@ -1888,6 +1894,11 @@ export function useChat(
18881894
const flush = () => {
18891895
if (isStale()) return
18901896
streamingBlocksRef.current = [...blocks]
1897+
captureRevealedSimKeys(
1898+
revealedSimKeysRef.current,
1899+
[assistantId, streamRequestId],
1900+
runningText
1901+
)
18911902
const activeChatId = chatIdRef.current
18921903
if (!activeChatId) {
18931904
const snapshot: Partial<ChatMessage> = {

apps/sim/app/workspace/[workspaceId]/settings/components/api-keys/components/create-api-key-modal/create-api-key-modal.tsx

Lines changed: 2 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,6 @@
22

33
import { useState } from 'react'
44
import { createLogger } from '@sim/logger'
5-
import { Check, Copy } from 'lucide-react'
65
import {
76
Button,
87
ButtonGroup,
@@ -13,6 +12,7 @@ import {
1312
ModalContent,
1413
ModalFooter,
1514
ModalHeader,
15+
SecretReveal,
1616
} from '@/components/emcn'
1717
import { type ApiKey, useCreateApiKey } from '@/hooks/queries/api-keys'
1818

@@ -50,8 +50,6 @@ export function CreateApiKeyModal({
5050
const [createError, setCreateError] = useState<string | null>(null)
5151
const [newKey, setNewKey] = useState<ApiKey | null>(null)
5252
const [showNewKeyDialog, setShowNewKeyDialog] = useState(false)
53-
const [copySuccess, setCopySuccess] = useState(false)
54-
5553
const createApiKeyMutation = useCreateApiKey()
5654

5755
const handleCreateKey = async () => {
@@ -105,12 +103,6 @@ export function CreateApiKeyModal({
105103
setCreateError(null)
106104
}
107105

108-
const copyToClipboard = (key: string) => {
109-
navigator.clipboard.writeText(key)
110-
setCopySuccess(true)
111-
setTimeout(() => setCopySuccess(false), 2000)
112-
}
113-
114106
return (
115107
<>
116108
{/* Create API Key Dialog */}
@@ -209,7 +201,6 @@ export function CreateApiKeyModal({
209201
setShowNewKeyDialog(dialogOpen)
210202
if (!dialogOpen) {
211203
setNewKey(null)
212-
setCopySuccess(false)
213204
}
214205
}}
215206
>
@@ -223,27 +214,7 @@ export function CreateApiKeyModal({
223214
</span>
224215
</p>
225216

226-
{newKey && (
227-
<div className='relative mt-2.5'>
228-
<div className='flex h-9 items-center rounded-md border bg-[var(--surface-1)] px-2.5 pr-10'>
229-
<code className='flex-1 truncate font-mono text-[var(--text-primary)] text-sm'>
230-
{newKey.key}
231-
</code>
232-
</div>
233-
<Button
234-
variant='ghost'
235-
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)]'
236-
onClick={() => copyToClipboard(newKey.key)}
237-
>
238-
{copySuccess ? (
239-
<Check className='h-[14px] w-[14px]' />
240-
) : (
241-
<Copy className='h-[14px] w-[14px]' />
242-
)}
243-
<span className='sr-only'>Copy to clipboard</span>
244-
</Button>
245-
</div>
246-
)}
217+
{newKey && <SecretReveal value={newKey.key} className='mt-2.5' />}
247218
</ModalBody>
248219
</ModalContent>
249220
</Modal>

apps/sim/app/workspace/[workspaceId]/settings/components/copilot/copilot.tsx

Lines changed: 3 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import { useMemo, useState } from 'react'
44
// import { useParams } from 'next/navigation'
55
import { createLogger } from '@sim/logger'
66
import { formatDate } from '@sim/utils/formatting'
7-
import { Check, Copy, Plus, Search } from 'lucide-react'
7+
import { Plus, Search } from 'lucide-react'
88
import {
99
Button,
1010
Input as EmcnInput,
@@ -13,6 +13,7 @@ import {
1313
ModalContent,
1414
ModalFooter,
1515
ModalHeader,
16+
SecretReveal,
1617
// Switch,
1718
} from '@/components/emcn'
1819
import { Input } from '@/components/ui'
@@ -58,7 +59,6 @@ export function Copilot() {
5859
const [newKeyName, setNewKeyName] = useState('')
5960
const [newKey, setNewKey] = useState<string | null>(null)
6061
const [showNewKeyDialog, setShowNewKeyDialog] = useState(false)
61-
const [copySuccess, setCopySuccess] = useState(false)
6262
const [deleteKey, setDeleteKey] = useState<CopilotKey | null>(null)
6363
const [showDeleteDialog, setShowDeleteDialog] = useState(false)
6464
const [searchTerm, setSearchTerm] = useState('')
@@ -115,12 +115,6 @@ export function Copilot() {
115115
}
116116
}
117117

118-
const copyToClipboard = (key: string) => {
119-
navigator.clipboard.writeText(key)
120-
setCopySuccess(true)
121-
setTimeout(() => setCopySuccess(false), 2000)
122-
}
123-
124118
const handleDeleteKey = async () => {
125119
if (!deleteKey) return
126120
try {
@@ -316,7 +310,6 @@ export function Copilot() {
316310
setShowNewKeyDialog(open)
317311
if (!open) {
318312
setNewKey(null)
319-
setCopySuccess(false)
320313
}
321314
}}
322315
>
@@ -330,27 +323,7 @@ export function Copilot() {
330323
</span>
331324
</p>
332325

333-
{newKey && (
334-
<div className='relative mt-2.5'>
335-
<div className='flex h-9 items-center rounded-md border bg-[var(--surface-1)] px-2.5 pr-10'>
336-
<code className='flex-1 truncate font-mono text-[var(--text-primary)] text-sm'>
337-
{newKey}
338-
</code>
339-
</div>
340-
<Button
341-
variant='ghost'
342-
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)]'
343-
onClick={() => copyToClipboard(newKey)}
344-
>
345-
{copySuccess ? (
346-
<Check className='h-[14px] w-[14px]' />
347-
) : (
348-
<Copy className='h-[14px] w-[14px]' />
349-
)}
350-
<span className='sr-only'>Copy to clipboard</span>
351-
</Button>
352-
</div>
353-
)}
326+
{newKey && <SecretReveal value={newKey} className='mt-2.5' />}
354327
</ModalBody>
355328
</ModalContent>
356329
</Modal>
Lines changed: 4 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -1,42 +1,25 @@
11
'use client'
22

3-
import { useCallback, useEffect, useRef, useState } from 'react'
43
import { Button, Check, Copy } from '@/components/emcn'
54
import { cn } from '@/lib/core/utils/cn'
5+
import { useCopyToClipboard } from '@/hooks/use-copy-to-clipboard'
66

77
interface CopyCodeButtonProps {
88
code: string
99
className?: string
1010
}
1111

1212
export function CopyCodeButton({ code, className }: CopyCodeButtonProps) {
13-
const [copied, setCopied] = useState(false)
14-
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
15-
16-
const handleCopy = useCallback(async () => {
17-
try {
18-
await navigator.clipboard.writeText(code)
19-
setCopied(true)
20-
if (timerRef.current) clearTimeout(timerRef.current)
21-
timerRef.current = setTimeout(() => setCopied(false), 2000)
22-
} catch {}
23-
}, [code])
24-
25-
useEffect(
26-
() => () => {
27-
if (timerRef.current) clearTimeout(timerRef.current)
28-
},
29-
[]
30-
)
13+
const { copied, copy } = useCopyToClipboard()
3114

3215
return (
3316
<Button
3417
type='button'
3518
variant='ghost'
36-
onClick={handleCopy}
19+
onClick={() => copy(code)}
3720
className={cn('flex items-center gap-1 rounded px-1.5 py-0.5 text-xs', className)}
3821
>
39-
{copied ? <Check className='size-3.5' /> : <Copy className='size-3.5' />}
22+
{copied ? <Check className='h-[14px] w-[14px]' /> : <Copy className='h-[14px] w-[14px]' />}
4023
</Button>
4124
)
4225
}

apps/sim/components/emcn/components/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -126,6 +126,7 @@ export {
126126
SModalTrigger,
127127
} from './s-modal/s-modal'
128128
export { SecretInput, type SecretInputProps } from './secret-input/secret-input'
129+
export { SecretReveal, type SecretRevealProps } from './secret-reveal/secret-reveal'
129130
export { Skeleton } from './skeleton/skeleton'
130131
export { Slider, type SliderProps } from './slider/slider'
131132
export { Switch } from './switch/switch'

0 commit comments

Comments
 (0)