diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/special-tags/special-tags.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/special-tags/special-tags.tsx
index bfa032ee2bb..89a93999b61 100644
--- a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/special-tags/special-tags.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/special-tags/special-tags.tsx
@@ -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 (
+
+ {createElement(Icon, { className: 'h-[16px] w-[16px] shrink-0' })}
+
+ Connect {data.provider}
+
+
+
+ )
+ }
- const Icon = getCredentialIcon(data.provider) ?? LockIcon
+ if (data.type === 'sim_key') {
+ return
+ }
- return (
-
- {createElement(Icon, { className: 'h-[16px] w-[16px] shrink-0' })}
-
- Connect {data.provider}
-
-
-
- )
+ return null
}
function MothershipErrorDisplay({ data }: { data: MothershipErrorTagData }) {
diff --git a/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.ts b/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.ts
index b4dccd4d4df..0b9f110691c 100644
--- a/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.ts
+++ b/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.ts
@@ -12,6 +12,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 {
@@ -1062,6 +1067,7 @@ export function useChat(
)
const [genericResourceData, setGenericResourceData] = useState(null)
const onResourceEventRef = useRef(options?.onResourceEvent)
+ const revealedSimKeysRef = useRef(new Map())
onResourceEventRef.current = options?.onResourceEvent
const apiPathRef = useRef(options?.apiPath ?? MOTHERSHIP_CHAT_API_PATH)
apiPathRef.current = options?.apiPath ?? MOTHERSHIP_CHAT_API_PATH
@@ -1383,10 +1389,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
@@ -1875,6 +1881,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 = {
diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/api-keys/components/create-api-key-modal/create-api-key-modal.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/api-keys/components/create-api-key-modal/create-api-key-modal.tsx
index 269c883ebb0..36731fe7962 100644
--- a/apps/sim/app/workspace/[workspaceId]/settings/components/api-keys/components/create-api-key-modal/create-api-key-modal.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/settings/components/api-keys/components/create-api-key-modal/create-api-key-modal.tsx
@@ -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(null)
const [newKey, setNewKey] = useState(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({
- {newKey && (
-
-
-
- {newKey.key}
-
-
-
-
- )}
+ {newKey && }
diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/copilot/copilot.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/copilot/copilot.tsx
index ac4eda18f41..f867d9beee9 100644
--- a/apps/sim/app/workspace/[workspaceId]/settings/components/copilot/copilot.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/settings/components/copilot/copilot.tsx
@@ -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(null)
const [showNewKeyDialog, setShowNewKeyDialog] = useState(false)
- const [copySuccess, setCopySuccess] = useState(false)
const [deleteKey, setDeleteKey] = useState(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() {
- {newKey && (
-
-
-
- {newKey}
-
-
-
-
- )}
+ {newKey && }
diff --git a/apps/sim/components/emcn/components/code/copy-code-button.tsx b/apps/sim/components/emcn/components/code/copy-code-button.tsx
index 5ef81dfb8a3..93ace78bf57 100644
--- a/apps/sim/components/emcn/components/code/copy-code-button.tsx
+++ b/apps/sim/components/emcn/components/code/copy-code-button.tsx
@@ -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 | 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 (
)
}
diff --git a/apps/sim/components/emcn/components/index.ts b/apps/sim/components/emcn/components/index.ts
index 15b6cefd77f..0f30eeb09ac 100644
--- a/apps/sim/components/emcn/components/index.ts
+++ b/apps/sim/components/emcn/components/index.ts
@@ -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'
diff --git a/apps/sim/components/emcn/components/secret-reveal/secret-reveal.tsx b/apps/sim/components/emcn/components/secret-reveal/secret-reveal.tsx
new file mode 100644
index 00000000000..1357ebfc3aa
--- /dev/null
+++ b/apps/sim/components/emcn/components/secret-reveal/secret-reveal.tsx
@@ -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'
+ *
+ *
+ *
+ * ```
+ */
+'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 (
+
+
+
+ {isHidden ? REDACTED_DOTS : value}
+
+
+ {!isHidden && (
+
+ )}
+
+ )
+}
diff --git a/apps/sim/hooks/use-copy-to-clipboard.ts b/apps/sim/hooks/use-copy-to-clipboard.ts
new file mode 100644
index 00000000000..751a94cf76c
--- /dev/null
+++ b/apps/sim/hooks/use-copy-to-clipboard.ts
@@ -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
+}
+
+/**
+ * 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()
+ *
+ */
+export function useCopyToClipboard(
+ options: UseCopyToClipboardOptions = {}
+): UseCopyToClipboardReturn {
+ const { resetMs = 2000 } = options
+ const [copied, setCopied] = useState(false)
+ const timerRef = useRef | null>(null)
+
+ const copy = useCallback(
+ async (text: string): Promise => {
+ 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 }
+}
diff --git a/apps/sim/lib/copilot/chat/persisted-message.test.ts b/apps/sim/lib/copilot/chat/persisted-message.test.ts
index 377a8c2b0b5..de701394235 100644
--- a/apps/sim/lib/copilot/chat/persisted-message.test.ts
+++ b/apps/sim/lib/copilot/chat/persisted-message.test.ts
@@ -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: ${JSON.stringify({ value: 'sk-sim-secret-123', type: 'sim_key' })} 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{"value": "sk-',
+ 'sim-secret',
+ '-12345',
+ '", "type":',
+ ' "sim_key"}',
+ '\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 | 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 = `${JSON.stringify({ value: 'https://oauth.example/connect', type: 'link', provider: 'slack' })}`
+ 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',
diff --git a/apps/sim/lib/copilot/chat/persisted-message.ts b/apps/sim/lib/copilot/chat/persisted-message.ts
index 3c34fb4901f..e249ecef43f 100644
--- a/apps/sim/lib/copilot/chat/persisted-message.ts
+++ b/apps/sim/lib/copilot/chat/persisted-message.ts
@@ -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
diff --git a/apps/sim/lib/copilot/chat/sim-key-redaction.test.ts b/apps/sim/lib/copilot/chat/sim-key-redaction.test.ts
new file mode 100644
index 00000000000..70fe637e6d2
--- /dev/null
+++ b/apps/sim/lib/copilot/chat/sim-key-redaction.test.ts
@@ -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) =>
+ `${JSON.stringify({ value, type: 'sim_key' })}`
+const redacted = `${JSON.stringify({ type: 'sim_key', redacted: true })}`
+
+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 = `${JSON.stringify({ value: 'https://x', type: 'link', provider: 'slack' })}`
+ 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()
+ 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()
+ 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()
+ 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()
+ 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([['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([['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([['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()
+ 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([['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([['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([
+ ['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')
+ })
+ })
+})
diff --git a/apps/sim/lib/copilot/chat/sim-key-redaction.ts b/apps/sim/lib/copilot/chat/sim-key-redaction.ts
new file mode 100644
index 00000000000..d5aba0f302d
--- /dev/null
+++ b/apps/sim/lib/copilot/chat/sim-key-redaction.ts
@@ -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 `` 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 = /([\s\S]*?)<\/credential>/g
+const REDACTED_TAG_PATTERN = /[^<]*"redacted"\s*:\s*true[^<]*<\/credential>/
+const SIM_KEY_TYPE = 'sim_key'
+const REDACTED_SIM_KEY_TAG = `${JSON.stringify({
+ type: SIM_KEY_TYPE,
+ redacted: true,
+})}`
+
+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 `` 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(content: T): T {
+ if (typeof content !== 'string' || !content.includes('')) 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
+ 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
+ * `...` 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
+
+/**
+ * Scan an assembled assistant message for `` tags
+ * and return their values in stream order, skipping anything already redacted.
+ */
+export function extractRevealedSimKeys(content: string): string[] {
+ if (!content || !content.includes('')) 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,
+ content: string
+): void {
+ if (!content.includes('')) 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('') || 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 `${JSON.stringify({ value, type: SIM_KEY_TYPE })}`
+ }
+ }
+ 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 } : {}),
+ }
+}