mirror of
https://github.com/simstudioai/sim.git
synced 2026-01-23 05:47:59 -05:00
Credential masking
This commit is contained in:
@@ -78,6 +78,7 @@ const CopilotMessage: FC<CopilotMessageProps> = memo(
|
||||
mode,
|
||||
setMode,
|
||||
isAborting,
|
||||
maskCredentialValue,
|
||||
} = useCopilotStore()
|
||||
|
||||
const messageCheckpoints = isUser ? allMessageCheckpoints[message.id] || [] : []
|
||||
@@ -210,7 +211,10 @@ const CopilotMessage: FC<CopilotMessageProps> = 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<CopilotMessageProps> = memo(
|
||||
return (
|
||||
<div key={blockKey} className='w-full'>
|
||||
<ThinkingBlock
|
||||
content={block.content}
|
||||
content={maskCredentialValue(block.content)}
|
||||
isStreaming={isActivelyStreaming}
|
||||
hasFollowingContent={hasFollowingContent}
|
||||
hasSpecialTags={hasSpecialTags}
|
||||
@@ -261,7 +265,7 @@ const CopilotMessage: FC<CopilotMessageProps> = memo(
|
||||
}
|
||||
return null
|
||||
})
|
||||
}, [message.contentBlocks, isActivelyStreaming, parsedTags, isLastMessage])
|
||||
}, [message.contentBlocks, isActivelyStreaming, parsedTags, isLastMessage, maskCredentialValue])
|
||||
|
||||
if (isUser) {
|
||||
return (
|
||||
|
||||
@@ -782,6 +782,7 @@ const SubagentContentRenderer = memo(function SubagentContentRenderer({
|
||||
const [isExpanded, setIsExpanded] = useState(true)
|
||||
const [duration, setDuration] = useState(0)
|
||||
const startTimeRef = useRef<number>(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<Record<string, { name: string; type: string }>>({})
|
||||
|
||||
@@ -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 && (
|
||||
<div className='border-[var(--border-1)] border-t px-2.5 py-1.5'>
|
||||
{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 (
|
||||
<div key={sb.id} className='flex items-start gap-1.5 py-0.5 text-[11px]'>
|
||||
<span
|
||||
|
||||
@@ -771,12 +771,50 @@ function deepClone<T>(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<string>): 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<string>(),
|
||||
}
|
||||
|
||||
export const useCopilotStore = create<CopilotStore>()(
|
||||
@@ -2676,6 +2716,9 @@ export const useCopilotStore = create<CopilotStore>()(
|
||||
}))
|
||||
}
|
||||
|
||||
// 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<CopilotStore>()(
|
||||
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<string>()
|
||||
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 = {
|
||||
|
||||
@@ -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<string>
|
||||
}
|
||||
|
||||
export interface CopilotActions {
|
||||
@@ -235,6 +238,10 @@ export interface CopilotActions {
|
||||
removeAutoAllowedTool: (toolId: string) => Promise<void>
|
||||
isToolAutoAllowed: (toolId: string) => boolean
|
||||
|
||||
// Credential masking
|
||||
loadSensitiveCredentialIds: () => Promise<void>
|
||||
maskCredentialValue: (value: string) => string
|
||||
|
||||
// Message queue actions
|
||||
addToQueue: (
|
||||
message: string,
|
||||
|
||||
Reference in New Issue
Block a user