From 04a6f9d0a48ed940fd25d4bfed13933c42e4d97c Mon Sep 17 00:00:00 2001 From: Siddharth Ganesan Date: Thu, 22 Jan 2026 12:30:53 -0800 Subject: [PATCH] Credential masking --- .../copilot-message/copilot-message.tsx | 10 +- .../components/tool-call/tool-call.tsx | 21 +++- apps/sim/stores/panel/copilot/store.ts | 96 ++++++++++++++++++- apps/sim/stores/panel/copilot/types.ts | 7 ++ 4 files changed, 126 insertions(+), 8 deletions(-) diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/copilot-message/copilot-message.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/copilot-message/copilot-message.tsx index ea780add2..acbb30ff2 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/copilot-message/copilot-message.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/copilot-message/copilot-message.tsx @@ -78,6 +78,7 @@ const CopilotMessage: FC = memo( mode, setMode, isAborting, + maskCredentialValue, } = useCopilotStore() const messageCheckpoints = isUser ? allMessageCheckpoints[message.id] || [] : [] @@ -210,7 +211,10 @@ const CopilotMessage: FC = memo( const isLastTextBlock = index === message.contentBlocks!.length - 1 && block.type === 'text' const parsed = parseSpecialTags(block.content) - const cleanBlockContent = parsed.cleanContent.replace(/\n{3,}/g, '\n\n') + // Mask credential IDs in the displayed content + const cleanBlockContent = maskCredentialValue( + parsed.cleanContent.replace(/\n{3,}/g, '\n\n') + ) if (!cleanBlockContent.trim()) return null @@ -238,7 +242,7 @@ const CopilotMessage: FC = memo( return (
= memo( } return null }) - }, [message.contentBlocks, isActivelyStreaming, parsedTags, isLastMessage]) + }, [message.contentBlocks, isActivelyStreaming, parsedTags, isLastMessage, maskCredentialValue]) if (isUser) { return ( diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/tool-call/tool-call.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/tool-call/tool-call.tsx index 1468d750a..c97025aee 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/tool-call/tool-call.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/tool-call/tool-call.tsx @@ -782,6 +782,7 @@ const SubagentContentRenderer = memo(function SubagentContentRenderer({ const [isExpanded, setIsExpanded] = useState(true) const [duration, setDuration] = useState(0) const startTimeRef = useRef(Date.now()) + const maskCredentialValue = useCopilotStore((s) => s.maskCredentialValue) const wasStreamingRef = useRef(false) // Only show streaming animations for current message @@ -816,14 +817,16 @@ const SubagentContentRenderer = memo(function SubagentContentRenderer({ currentText += parsed.cleanContent } else if (block.type === 'subagent_tool_call' && block.toolCall) { if (currentText.trim()) { - segments.push({ type: 'text', content: currentText }) + // Mask any credential IDs in the accumulated text before displaying + segments.push({ type: 'text', content: maskCredentialValue(currentText) }) currentText = '' } segments.push({ type: 'tool', block }) } } if (currentText.trim()) { - segments.push({ type: 'text', content: currentText }) + // Mask any credential IDs in the accumulated text before displaying + segments.push({ type: 'text', content: maskCredentialValue(currentText) }) } const allParsed = parseSpecialTags(allRawText) @@ -952,6 +955,7 @@ const WorkflowEditSummary = memo(function WorkflowEditSummary({ toolCall: CopilotToolCall }) { const blocks = useWorkflowStore((s) => s.blocks) + const maskCredentialValue = useCopilotStore((s) => s.maskCredentialValue) const cachedBlockInfoRef = useRef>({}) @@ -983,6 +987,7 @@ const WorkflowEditSummary = memo(function WorkflowEditSummary({ title: string value: any isPassword?: boolean + isCredential?: boolean } interface BlockChange { @@ -1091,6 +1096,7 @@ const WorkflowEditSummary = memo(function WorkflowEditSummary({ title: subBlockConfig.title ?? subBlockConfig.id, value, isPassword: subBlockConfig.password === true, + isCredential: subBlockConfig.type === 'oauth-input', }) } } @@ -1172,8 +1178,15 @@ const WorkflowEditSummary = memo(function WorkflowEditSummary({ {subBlocksToShow && subBlocksToShow.length > 0 && (
{subBlocksToShow.map((sb) => { - // Mask password fields like the canvas does - const displayValue = sb.isPassword ? '•••' : getDisplayValue(sb.value) + // Mask password fields and credential IDs + let displayValue: string + if (sb.isPassword) { + displayValue = '•••' + } else { + // Get display value first, then mask any credential IDs that might be in it + const rawValue = getDisplayValue(sb.value) + displayValue = maskCredentialValue(rawValue) + } return (
(obj: T): T { } } +/** + * Recursively masks credential IDs in any value (string, object, or array). + * Used during serialization to ensure sensitive IDs are never persisted. + */ +function maskCredentialIdsInValue(value: any, credentialIds: Set): any { + if (!value || credentialIds.size === 0) return value + + if (typeof value === 'string') { + let masked = value + // Sort by length descending to mask longer IDs first + const sortedIds = Array.from(credentialIds).sort((a, b) => b.length - a.length) + for (const id of sortedIds) { + if (id && masked.includes(id)) { + masked = masked.split(id).join('••••••••') + } + } + return masked + } + + if (Array.isArray(value)) { + return value.map((item) => maskCredentialIdsInValue(item, credentialIds)) + } + + if (typeof value === 'object') { + const masked: any = {} + for (const key of Object.keys(value)) { + masked[key] = maskCredentialIdsInValue(value[key], credentialIds) + } + return masked + } + + return value +} + /** * Serializes messages for database storage. * Deep clones all fields to ensure proper JSON serialization. + * Masks sensitive credential IDs before persisting. * This ensures they render identically when loaded back. */ function serializeMessagesForDB(messages: CopilotMessage[]): any[] { + // Get credential IDs to mask + const credentialIds = useCopilotStore.getState().sensitiveCredentialIds + const result = messages .map((msg) => { // Deep clone the entire message to ensure all nested data is serializable @@ -824,7 +862,8 @@ function serializeMessagesForDB(messages: CopilotMessage[]): any[] { serialized.errorType = msg.errorType } - return serialized + // Mask credential IDs in the serialized message before persisting + return maskCredentialIdsInValue(serialized, credentialIds) }) .filter((msg) => { // Filter out empty assistant messages @@ -2294,6 +2333,7 @@ const initialState = { autoAllowedTools: [] as string[], messageQueue: [] as import('./types').QueuedMessage[], suppressAbortContinueOption: false, + sensitiveCredentialIds: new Set(), } export const useCopilotStore = create()( @@ -2676,6 +2716,9 @@ export const useCopilotStore = create()( })) } + // Load sensitive credential IDs for masking before streaming starts + await get().loadSensitiveCredentialIds() + let newMessages: CopilotMessage[] if (revertState) { const currentMessages = get().messages @@ -3968,6 +4011,57 @@ export const useCopilotStore = create()( return autoAllowedTools.includes(toolId) }, + // Credential masking + loadSensitiveCredentialIds: async () => { + try { + const res = await fetch('/api/copilot/execute-copilot-server-tool', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ toolName: 'get_credentials', payload: {} }), + }) + if (!res.ok) { + logger.warn('[loadSensitiveCredentialIds] Failed to fetch credentials', { + status: res.status, + }) + return + } + const json = await res.json() + // Credentials are at result.oauth.connected.credentials + const credentials = json?.result?.oauth?.connected?.credentials || [] + logger.info('[loadSensitiveCredentialIds] Response', { + hasResult: !!json?.result, + credentialCount: credentials.length, + }) + const ids = new Set() + for (const cred of credentials) { + if (cred?.id) { + ids.add(cred.id) + } + } + set({ sensitiveCredentialIds: ids }) + logger.info('[loadSensitiveCredentialIds] Loaded credential IDs', { + count: ids.size, + }) + } catch (err) { + logger.warn('[loadSensitiveCredentialIds] Error loading credentials', err) + } + }, + + maskCredentialValue: (value: string) => { + const { sensitiveCredentialIds } = get() + if (!value || sensitiveCredentialIds.size === 0) return value + + let masked = value + // Sort by length descending to mask longer IDs first + const sortedIds = Array.from(sensitiveCredentialIds).sort((a, b) => b.length - a.length) + for (const id of sortedIds) { + if (id && masked.includes(id)) { + masked = masked.split(id).join('••••••••') + } + } + return masked + }, + // Message queue actions addToQueue: (message, options) => { const queuedMessage: import('./types').QueuedMessage = { diff --git a/apps/sim/stores/panel/copilot/types.ts b/apps/sim/stores/panel/copilot/types.ts index 477275c3a..49b76bd62 100644 --- a/apps/sim/stores/panel/copilot/types.ts +++ b/apps/sim/stores/panel/copilot/types.ts @@ -156,6 +156,9 @@ export interface CopilotState { // Message queue for messages sent while another is in progress messageQueue: QueuedMessage[] + + // Credential IDs to mask in UI (for sensitive data protection) + sensitiveCredentialIds: Set } export interface CopilotActions { @@ -235,6 +238,10 @@ export interface CopilotActions { removeAutoAllowedTool: (toolId: string) => Promise isToolAutoAllowed: (toolId: string) => boolean + // Credential masking + loadSensitiveCredentialIds: () => Promise + maskCredentialValue: (value: string) => string + // Message queue actions addToQueue: ( message: string,