Skip to content

Commit a996b75

Browse files
fix(copilot): redact sim_key API keys from persisted Mothership chat messages
1 parent bdaf112 commit a996b75

12 files changed

Lines changed: 675 additions & 113 deletions

File tree

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

Lines changed: 34 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
import { createElement, useMemo, useState } from 'react'
44
import { useParams } from 'next/navigation'
55
import { ArrowRight, ChevronDown, Expandable, ExpandableContent } from '@/components/emcn'
6+
import { ApiKeyReveal } from '@/components/ui'
67
import { cn } from '@/lib/core/utils/cn'
78
import { OAUTH_PROVIDERS } from '@/lib/oauth/oauth'
89
import { ContextMentionIcon } from '@/app/workspace/[workspaceId]/home/components/context-mention-icon'
@@ -47,9 +48,10 @@ export const CREDENTIAL_TAG_TYPES = [
4748
export type CredentialTagType = (typeof CREDENTIAL_TAG_TYPES)[number]
4849

4950
export interface CredentialTagData {
50-
value: string
51+
value?: string
5152
type: CredentialTagType
5253
provider?: string
54+
redacted?: boolean
5355
}
5456

5557
export interface MothershipErrorTagData {
@@ -140,12 +142,15 @@ function isUsageUpgradeTagData(value: unknown): value is UsageUpgradeTagData {
140142

141143
function isCredentialTagData(value: unknown): value is CredentialTagData {
142144
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-
)
145+
if (
146+
typeof value.type !== 'string' ||
147+
!(CREDENTIAL_TAG_TYPES as readonly string[]).includes(value.type)
148+
) {
149+
return false
150+
}
151+
if (value.provider !== undefined && typeof value.provider !== 'string') return false
152+
if (value.redacted === true) return value.value === undefined || typeof value.value === 'string'
153+
return typeof value.value === 'string'
149154
}
150155

151156
function isMothershipErrorTagData(value: unknown): value is MothershipErrorTagData {
@@ -595,24 +600,30 @@ const LockIcon = (props: { className?: string }) => (
595600
)
596601

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

600-
const Icon = getCredentialIcon(data.provider) ?? LockIcon
622+
if (data.type === 'sim_key') {
623+
return <ApiKeyReveal value={data.value} redacted={data.redacted || !data.value} />
624+
}
601625

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-
)
626+
return null
616627
}
617628

618629
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
@@ -12,6 +12,11 @@ import type {
1212
PersistedMessage,
1313
} from '@/lib/copilot/chat/persisted-message'
1414
import { normalizeMessage, withBlockTiming } from '@/lib/copilot/chat/persisted-message'
15+
import {
16+
captureRevealedSimKeys,
17+
type RevealedSimKeysByMessage,
18+
restoreRevealedSimKeysForMessage,
19+
} from '@/lib/copilot/chat/sim-key-redaction'
1520
import { resolveStreamToolOutcome } from '@/lib/copilot/chat/stream-tool-outcome'
1621
import { MOTHERSHIP_CHAT_API_PATH, STREAM_STORAGE_KEY } from '@/lib/copilot/constants'
1722
import type {
@@ -1062,6 +1067,7 @@ export function useChat(
10621067
)
10631068
const [genericResourceData, setGenericResourceData] = useState<GenericResourceData | null>(null)
10641069
const onResourceEventRef = useRef(options?.onResourceEvent)
1070+
const revealedSimKeysRef = useRef<RevealedSimKeysByMessage>(new Map())
10651071
onResourceEventRef.current = options?.onResourceEvent
10661072
const apiPathRef = useRef(options?.apiPath ?? MOTHERSHIP_CHAT_API_PATH)
10671073
apiPathRef.current = options?.apiPath ?? MOTHERSHIP_CHAT_API_PATH
@@ -1383,10 +1389,10 @@ export function useChat(
13831389
}, [clearActiveTurn, clearQueueDispatchState, resetEphemeralPreviewState, setTransportIdle])
13841390

13851391
const { data: chatHistory } = useChatHistory(resolvedChatId)
1386-
const messages = useMemo(
1387-
() => chatHistory?.messages.map(toDisplayMessage) ?? pendingMessages,
1388-
[chatHistory, pendingMessages]
1389-
)
1392+
const messages = useMemo(() => {
1393+
const source = chatHistory?.messages.map(toDisplayMessage) ?? pendingMessages
1394+
return source.map((m) => restoreRevealedSimKeysForMessage(m, revealedSimKeysRef.current))
1395+
}, [chatHistory, pendingMessages])
13901396
const addResource = useCallback((resource: MothershipResource): boolean => {
13911397
if (resourcesRef.current.some((r) => r.type === resource.type && r.id === resource.id)) {
13921398
return false
@@ -1875,6 +1881,11 @@ export function useChat(
18751881
const flush = () => {
18761882
if (isStale()) return
18771883
streamingBlocksRef.current = [...blocks]
1884+
captureRevealedSimKeys(
1885+
revealedSimKeysRef.current,
1886+
[assistantId, streamRequestId],
1887+
runningText
1888+
)
18781889
const activeChatId = chatIdRef.current
18791890
if (!activeChatId) {
18801891
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,
@@ -14,6 +13,7 @@ import {
1413
ModalFooter,
1514
ModalHeader,
1615
} from '@/components/emcn'
16+
import { ApiKeyReveal } from '@/components/ui'
1717
import { type ApiKey, useCreateApiKey } from '@/hooks/queries/api-keys'
1818

1919
const logger = createLogger('CreateApiKeyModal')
@@ -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 && <ApiKeyReveal 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 & 31 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,
@@ -15,7 +15,7 @@ import {
1515
ModalHeader,
1616
// Switch,
1717
} from '@/components/emcn'
18-
import { Input } from '@/components/ui'
18+
import { ApiKeyReveal, Input } from '@/components/ui'
1919
// import { useMcpServers, useUpdateMcpServer } from '@/hooks/queries/mcp'
2020
import { CopilotKeySkeleton } from '@/app/workspace/[workspaceId]/settings/components/copilot/copilot-skeleton'
2121
import {
@@ -58,7 +58,6 @@ export function Copilot() {
5858
const [newKeyName, setNewKeyName] = useState('')
5959
const [newKey, setNewKey] = useState<string | null>(null)
6060
const [showNewKeyDialog, setShowNewKeyDialog] = useState(false)
61-
const [copySuccess, setCopySuccess] = useState(false)
6261
const [deleteKey, setDeleteKey] = useState<CopilotKey | null>(null)
6362
const [showDeleteDialog, setShowDeleteDialog] = useState(false)
6463
const [searchTerm, setSearchTerm] = useState('')
@@ -115,12 +114,6 @@ export function Copilot() {
115114
}
116115
}
117116

118-
const copyToClipboard = (key: string) => {
119-
navigator.clipboard.writeText(key)
120-
setCopySuccess(true)
121-
setTimeout(() => setCopySuccess(false), 2000)
122-
}
123-
124117
const handleDeleteKey = async () => {
125118
if (!deleteKey) return
126119
try {
@@ -316,7 +309,6 @@ export function Copilot() {
316309
setShowNewKeyDialog(open)
317310
if (!open) {
318311
setNewKey(null)
319-
setCopySuccess(false)
320312
}
321313
}}
322314
>
@@ -330,27 +322,7 @@ export function Copilot() {
330322
</span>
331323
</p>
332324

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-
)}
325+
{newKey && <ApiKeyReveal value={newKey} className='mt-2.5' />}
354326
</ModalBody>
355327
</ModalContent>
356328
</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
}
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
'use client'
2+
3+
import { Button, Check, Copy } from '@/components/emcn'
4+
import { cn } from '@/lib/core/utils/cn'
5+
import { useCopyToClipboard } from '@/hooks/use-copy-to-clipboard'
6+
7+
const REDACTED_DOTS = '••••••••••••••••••••••••••••••••'
8+
9+
interface ApiKeyRevealProps {
10+
value?: string
11+
className?: string
12+
redacted?: boolean
13+
}
14+
15+
export function ApiKeyReveal({ value, className, redacted = false }: ApiKeyRevealProps) {
16+
const { copied, copy } = useCopyToClipboard()
17+
const isHidden = redacted || !value
18+
19+
const handleCopy = () => {
20+
if (isHidden || !value) return
21+
copy(value)
22+
}
23+
24+
return (
25+
<div className={cn('relative', className)}>
26+
<div
27+
className={cn(
28+
'flex h-9 items-center rounded-md border bg-[var(--surface-1)] px-2.5',
29+
!isHidden && 'pr-10'
30+
)}
31+
>
32+
<code
33+
className={cn(
34+
'flex-1 truncate font-mono text-sm',
35+
isHidden ? 'text-[var(--text-muted)]' : 'text-[var(--text-primary)]'
36+
)}
37+
>
38+
{isHidden ? REDACTED_DOTS : value}
39+
</code>
40+
</div>
41+
{!isHidden && (
42+
<Button
43+
variant='ghost'
44+
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)]'
45+
onClick={handleCopy}
46+
>
47+
{copied ? (
48+
<Check className='h-[14px] w-[14px]' />
49+
) : (
50+
<Copy className='h-[14px] w-[14px]' />
51+
)}
52+
<span className='sr-only'>Copy to clipboard</span>
53+
</Button>
54+
)}
55+
</div>
56+
)
57+
}

apps/sim/components/ui/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
export { ApiKeyReveal } from './api-key-reveal'
12
export { Button, buttonVariants } from './button'
23
export { Input } from './input'
34
export { Label } from './label'

0 commit comments

Comments
 (0)