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 } : {}), + } +}