diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/special-tags/special-tags.test.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/special-tags/special-tags.test.tsx index 4c36f4083ae..9bc5cda0d38 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/special-tags/special-tags.test.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/special-tags/special-tags.test.tsx @@ -107,6 +107,12 @@ vi.mock('@/lib/browser-agent/transport', () => ({ import { toast } from '@sim/emcn' import type { GenericSecretSource } from '@/lib/api/contracts/organization-secrets' +import { + captureRevealedSimKeys, + type RevealedSimKeysByMessage, + redactSensitiveContent, + restoreRevealedSimKeysForMessage, +} from '@/lib/mothership/chat/sim-key-redaction' import type { CredentialItemData } from '@/app/workspace/[workspaceId]/home/components/message-content/components/special-tags/special-tags' import { SpecialTags } from '@/app/workspace/[workspaceId]/home/components/message-content/components/special-tags/special-tags' import { organizationSecretKeys } from '@/hooks/queries/organization-secrets' @@ -379,6 +385,64 @@ describe('CredentialDisplay link tag', () => { act(() => root.unmount()) }) + it('reveals a created key in organization chat after the live fill and after persistence', async () => { + mockParams.mockReturnValue({ organizationId: 'org' } as never) + const tag = (item: Record) => `${JSON.stringify(item)}` + const tagData = (content: string) => + JSON.parse( + content.slice(''.length, -''.length) + ) as CredentialItemData + const cache: RevealedSimKeysByMessage = new Map() + captureRevealedSimKeys(cache, ['msg-1'], tag({ type: 'sim_key' }), [ + { + toolCall: { + name: 'generate_api_key', + result: { success: true, output: { key: 'sk-sim-live', workspaceId: 'ws-1' } }, + }, + }, + ]) + const live = restoreRevealedSimKeysForMessage( + { id: 'msg-1', role: 'assistant', content: tag({ type: 'sim_key' }) }, + cache + ).content + + const writeText = vi.fn(async () => undefined) + Object.defineProperty(navigator, 'clipboard', { configurable: true, value: { writeText } }) + const copyButton = (container: HTMLElement) => + Array.from(container.querySelectorAll('button')).find( + (button) => button.textContent === 'Copy to clipboard' + ) + + const liveView = renderCredentialLink(tagData(live)) + await act(async () => copyButton(liveView.container)?.click()) + expect(writeText).toHaveBeenCalledWith('sk-sim-live') + act(() => liveView.root.unmount()) + + const persisted = redactSensitiveContent( + tag({ type: 'sim_key', workspaceId: 'ws-1', value: 'sk-sim-live' }) + ) + const persistedView = renderCredentialLink(tagData(persisted)) + expect(persistedView.container.querySelector('code')).not.toBeNull() + expect(copyButton(persistedView.container)).toBeUndefined() + expect(persistedView.container.textContent).not.toContain('sk-sim-live') + act(() => persistedView.root.unmount()) + }) + + it('renders a saved key masked in workspace chat even when its tag names another workspace', () => { + const persisted = redactSensitiveContent( + `${JSON.stringify({ type: 'sim_key', workspaceId: 'other-workspace', value: 'sk-sim-saved' })}` + ) + const { container, root } = renderCredentialLink( + JSON.parse( + persisted.slice(''.length, -''.length) + ) as CredentialItemData + ) + expect(container.querySelector('[role="status"]')).toBeNull() + expect(container.querySelector('code')).not.toBeNull() + expect(container.querySelector('button')).toBeNull() + act(() => root.unmount()) + }) + it('does not mount credential inputs when the target host denies access', () => { mockParams.mockReturnValue({ organizationId: 'org' } as never) mockCredentialHost.mockReturnValue(null) 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 fd03be2d91a..95d5c5fc74f 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 @@ -3327,7 +3327,8 @@ export function CredentialDisplay(props: Parameters item.type === 'link' || item.type === 'service_account' || - item.type === 'sim_key' || + // A masked key has nothing to copy, so only a revealed one needs its workspace. + (item.type === 'sim_key' && item.value !== undefined) || (item.type === 'secret_input' && (!item.scope || item.scope === 'workspace')) ) const targets = new Set(targeted.map((item) => item.workspaceId).filter(Boolean)) diff --git a/apps/sim/lib/mothership/chat/sim-key-redaction.test.ts b/apps/sim/lib/mothership/chat/sim-key-redaction.test.ts index 4090f8c52a7..d3208bdbe04 100644 --- a/apps/sim/lib/mothership/chat/sim-key-redaction.test.ts +++ b/apps/sim/lib/mothership/chat/sim-key-redaction.test.ts @@ -4,6 +4,8 @@ import { captureRevealedSimKeys, extractRevealedSimKeys, extractRevealedSimKeysFromBlocks, + type RevealedSimKey, + type RevealedSimKeysByMessage, redactSensitiveContent, restoreRevealedSimKeysForMessage, toolResultForModel, @@ -16,6 +18,8 @@ const redacted = `${JSON.stringify({ type: 'sim_key', redacted: true const placeholder = `${JSON.stringify({ type: 'sim_key' })}` const credentialBatch = (items: unknown[]) => `${JSON.stringify(items)}` +const keys = (...values: string[]): RevealedSimKey[] => values.map((value) => ({ value })) + const apiKeyBlock = (key: string) => ({ type: 'tool_call' as const, toolCall: { name: 'generate_api_key', result: { success: true, output: { id: 'k1', key } } }, @@ -25,13 +29,13 @@ 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']) + expect(extractRevealedSimKeys(text)).toEqual(keys('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']) + expect(extractRevealedSimKeys(text)).toEqual(keys('sk-sim-A')) }) it('returns sim_key values from a mixed credential-card array', () => { @@ -40,7 +44,7 @@ describe('sim-key-redaction', () => { { type: 'sim_key', value: 'sk-sim-A' }, { type: 'sim_key', value: 'sk-sim-B' }, ]) - expect(extractRevealedSimKeys(batch)).toEqual(['sk-sim-A', 'sk-sim-B']) + expect(extractRevealedSimKeys(batch)).toEqual(keys('sk-sim-A', 'sk-sim-B')) }) }) @@ -50,6 +54,27 @@ describe('sim-key-redaction', () => { const batch = credentialBatch([link, { type: 'sim_key', value: 'sk-sim-secret' }]) expect(redactSensitiveContent(batch)).toBe(credentialBatch([link, { type: 'sim_key' }])) }) + + it('keeps the workspace target an organization chat needs and strips only the value', () => { + const single = `${JSON.stringify({ type: 'sim_key', workspaceId: 'ws-1', value: 'sk-sim-secret' })}` + expect(redactSensitiveContent(single)).toBe( + `${JSON.stringify({ type: 'sim_key', workspaceId: 'ws-1' })}` + ) + const batch = credentialBatch([ + { type: 'sim_key', workspaceId: 'ws-1', value: 'sk-sim-secret', extra: 'dropped' }, + ]) + expect(redactSensitiveContent(batch)).toBe( + credentialBatch([{ type: 'sim_key', workspaceId: 'ws-1' }]) + ) + }) + + it.each([' ws-1', 'w'.repeat(257)])( + 'saves no workspace target the renderer would reject: %j', + (workspaceId) => { + const tag = `${JSON.stringify({ type: 'sim_key', workspaceId, value: 'sk-sim-secret' })}` + expect(redactSensitiveContent(tag)).toBe(placeholder) + } + ) }) describe('toolResultForModel', () => { @@ -80,7 +105,7 @@ describe('sim-key-redaction', () => { it('pulls generate_api_key output keys in block order', () => { expect( extractRevealedSimKeysFromBlocks([apiKeyBlock('sk-sim-A'), apiKeyBlock('sk-sim-B')]) - ).toEqual(['sk-sim-A', 'sk-sim-B']) + ).toEqual(keys('sk-sim-A', 'sk-sim-B')) }) it('skips redacted markers and unrelated tools', () => { @@ -92,7 +117,7 @@ describe('sim-key-redaction', () => { }, apiKeyBlock('sk-sim-A'), ] - expect(extractRevealedSimKeysFromBlocks(blocks)).toEqual(['sk-sim-A']) + expect(extractRevealedSimKeysFromBlocks(blocks)).toEqual(keys('sk-sim-A')) }) it('returns nothing for empty/undefined block lists', () => { @@ -103,55 +128,55 @@ describe('sim-key-redaction', () => { describe('captureRevealedSimKeys', () => { it('records new keys under each provided key', () => { - const cache = new Map() + 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']) + expect(cache.get('msg-1')).toEqual(keys('sk-sim-A')) + expect(cache.get('req-1')).toEqual(keys('sk-sim-A')) }) it('extends but never shrinks the captured list across calls', () => { - const cache = new Map() + 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']) + expect(cache.get('msg-1')).toEqual(keys('sk-sim-A', 'sk-sim-B')) }) it('skips undefined keys without throwing', () => { - const cache = new Map() + const cache = new Map() captureRevealedSimKeys(cache, ['msg-1', undefined], credential('sk-sim-A')) - expect(cache.get('msg-1')).toEqual(['sk-sim-A']) + expect(cache.get('msg-1')).toEqual(keys('sk-sim-A')) expect(cache.size).toBe(1) }) it('ignores content with no credential tag', () => { - const cache = new Map() + const cache = new Map() captureRevealedSimKeys(cache, ['msg-1'], 'plain assistant text') expect(cache.has('msg-1')).toBe(false) }) it('sources the key from the generate_api_key tool result (model text is a redacted placeholder)', () => { - const cache = new Map() + const cache = new Map() captureRevealedSimKeys(cache, ['msg-1', 'req-1'], `Here is your key: ${redacted}`, [ apiKeyBlock('sk-sim-fromtool'), ]) - expect(cache.get('msg-1')).toEqual(['sk-sim-fromtool']) - expect(cache.get('req-1')).toEqual(['sk-sim-fromtool']) + expect(cache.get('msg-1')).toEqual(keys('sk-sim-fromtool')) + expect(cache.get('req-1')).toEqual(keys('sk-sim-fromtool')) }) it('prefers tool-result keys over any inline content values', () => { - const cache = new Map() + const cache = new Map() captureRevealedSimKeys(cache, ['msg-1'], credential('sk-content'), [apiKeyBlock('sk-tool')]) - expect(cache.get('msg-1')).toEqual(['sk-tool']) + expect(cache.get('msg-1')).toEqual(keys('sk-tool')) }) }) describe('restoreRevealedSimKeysForMessage', () => { it('substitutes the live key back into a redacted message', () => { - const cache = new Map([['msg-1', ['sk-sim-A']]]) + const cache = new Map([['msg-1', keys('sk-sim-A')]]) const msg: ChatMessage = { id: 'msg-1', role: 'assistant', @@ -165,7 +190,7 @@ describe('sim-key-redaction', () => { }) it('fills a value-less {"type":"sim_key"} placeholder (no redacted flag needed)', () => { - const cache = new Map([['msg-1', ['sk-sim-A']]]) + const cache = new Map([['msg-1', keys('sk-sim-A')]]) const msg: ChatMessage = { id: 'msg-1', role: 'assistant', @@ -178,7 +203,7 @@ describe('sim-key-redaction', () => { }) it('fills value-less and redacted placeholders positionally in one message', () => { - const cache = new Map([['msg-1', ['sk-sim-A', 'sk-sim-B']]]) + const cache = new Map([['msg-1', keys('sk-sim-A', 'sk-sim-B')]]) const msg: ChatMessage = { id: 'msg-1', role: 'assistant', @@ -193,7 +218,7 @@ describe('sim-key-redaction', () => { it('fills sim_key rows positionally inside a mixed credential-card array', () => { const link = { type: 'link', value: 'https://x', provider: 'slack' } const batch = credentialBatch([link, { type: 'sim_key' }, { type: 'sim_key' }]) - const cache = new Map([['msg-1', ['sk-sim-A', 'sk-sim-B']]]) + const cache = new Map([['msg-1', keys('sk-sim-A', 'sk-sim-B')]]) const msg: ChatMessage = { id: 'msg-1', role: 'assistant', content: batch } expect(restoreRevealedSimKeysForMessage(msg, cache).content).toBe( @@ -205,8 +230,37 @@ describe('sim-key-redaction', () => { ) }) + it('binds the filled key to the workspace it was created in, not the tag target', () => { + const cache: RevealedSimKeysByMessage = new Map() + const tag = `${JSON.stringify({ type: 'sim_key', workspaceId: 'ws-other' })}` + captureRevealedSimKeys(cache, ['msg-1'], tag, [ + { + toolCall: { + name: 'generate_api_key', + result: { success: true, output: { key: 'sk-sim-A', workspaceId: 'ws-1' } }, + }, + }, + ]) + const msg: ChatMessage = { id: 'msg-1', role: 'assistant', content: tag } + + expect(restoreRevealedSimKeysForMessage(msg, cache).content).toBe( + `${JSON.stringify({ value: 'sk-sim-A', type: 'sim_key', workspaceId: 'ws-1' })}` + ) + }) + + it('keeps the tag workspace when the creation result carries none', () => { + const cache: RevealedSimKeysByMessage = new Map() + const tag = `${JSON.stringify({ type: 'sim_key', workspaceId: 'ws-tag' })}` + captureRevealedSimKeys(cache, ['msg-1'], tag, [apiKeyBlock('sk-sim-A')]) + const msg: ChatMessage = { id: 'msg-1', role: 'assistant', content: tag } + + expect(restoreRevealedSimKeysForMessage(msg, cache).content).toBe( + `${JSON.stringify({ value: 'sk-sim-A', type: 'sim_key', workspaceId: 'ws-tag' })}` + ) + }) + it('substitutes multiple keys in stream order', () => { - const cache = new Map([['msg-1', ['sk-sim-A', 'sk-sim-B']]]) + const cache = new Map([['msg-1', keys('sk-sim-A', 'sk-sim-B')]]) const msg: ChatMessage = { id: 'msg-1', role: 'assistant', @@ -219,7 +273,7 @@ describe('sim-key-redaction', () => { }) 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 cache = new Map([['msg-1', keys('sk-sim-A')]]) const msg: ChatMessage = { id: 'msg-1', role: 'assistant', @@ -230,7 +284,7 @@ describe('sim-key-redaction', () => { }) it('returns the same message reference when nothing to restore', () => { - const cache = new Map() + const cache = new Map() const msg: ChatMessage = { id: 'msg-1', role: 'assistant', @@ -240,7 +294,7 @@ describe('sim-key-redaction', () => { }) it('does nothing for user messages', () => { - const cache = new Map([['msg-1', ['sk-sim-A']]]) + const cache = new Map([['msg-1', keys('sk-sim-A')]]) const msg: ChatMessage = { id: 'msg-1', role: 'user', @@ -250,7 +304,7 @@ describe('sim-key-redaction', () => { }) 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 cache = new Map([['msg-1', keys('sk-sim-A', 'sk-sim-B')]]) const msg: ChatMessage = { id: 'msg-1', role: 'assistant', @@ -269,9 +323,9 @@ describe('sim-key-redaction', () => { }) 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 cache = new Map([ + ['msg-1', keys('sk-sim-A')], + ['msg-2', keys('sk-sim-B')], ]) const msg1: ChatMessage = { id: 'msg-1', role: 'assistant', content: redacted } const msg2: ChatMessage = { id: 'msg-2', role: 'assistant', content: redacted } diff --git a/apps/sim/lib/mothership/chat/sim-key-redaction.ts b/apps/sim/lib/mothership/chat/sim-key-redaction.ts index 4e459308ba4..0c9e046f4ef 100644 --- a/apps/sim/lib/mothership/chat/sim-key-redaction.ts +++ b/apps/sim/lib/mothership/chat/sim-key-redaction.ts @@ -25,14 +25,43 @@ import type { ChatMessage, ContentBlock } from '@/app/workspace/[workspaceId]/ho const CREDENTIAL_TAG_PATTERN = /([\s\S]*?)<\/credential>/g const SIM_KEY_TYPE = 'sim_key' -// The persisted / secret-stripped form of a sim_key tag: value-less, which is -// exactly how the UI renders the masked state. No `redacted` flag needed — a -// sim_key chip is masked iff it has no value. -const VALUELESS_SIM_KEY_TAG = `${JSON.stringify({ type: SIM_KEY_TYPE })}` interface CredentialTagBody { type?: unknown value?: unknown + workspaceId?: unknown +} + +/** A key revealed on the live stream, with the workspace it was created in. */ +export interface RevealedSimKey { + value: string + workspaceId?: string +} + +/** + * Rebuilds a sim_key row from its known fields. Organization chat requires the + * row's `workspaceId` to render it, so redaction keeps it. The live fill takes + * the workspace from the key's creation result, which outranks the tag's. + * Without a revealed key the row is the persisted, masked form. + */ +function simKeyItem(item: CredentialTagBody, revealed?: RevealedSimKey): Record { + const workspaceId = + revealed?.workspaceId ?? (isWorkspaceTarget(item.workspaceId) ? item.workspaceId : undefined) + return { + ...(revealed ? { value: revealed.value } : {}), + type: SIM_KEY_TYPE, + ...(workspaceId ? { workspaceId } : {}), + } +} + +function isWorkspaceTarget(value: unknown): value is string { + return ( + typeof value === 'string' && value.length > 0 && value.length <= 256 && value === value.trim() + ) +} + +function optionalWorkspaceId(value: unknown): { workspaceId?: string } { + return isWorkspaceTarget(value) ? { workspaceId: value } : {} } function parseCredentialBody(body: string): unknown | null { @@ -80,14 +109,15 @@ export function redactSensitiveContent(content: T) if (typeof content !== 'string' || !content.includes('')) return content return content.replace(CREDENTIAL_TAG_PATTERN, (match, body: string) => { const parsed = parseCredentialBody(body) - if (isSimKeyBody(parsed)) return VALUELESS_SIM_KEY_TAG + if (isSimKeyBody(parsed)) + return `${JSON.stringify(simKeyItem(parsed))}` if (!Array.isArray(parsed)) return match let changed = false const next = parsed.map((item) => { if (!isSimKeyBody(item)) return item changed = true - return { type: SIM_KEY_TYPE } + return simKeyItem(item) }) return changed ? `${JSON.stringify(next)}` : match }) as T @@ -212,21 +242,21 @@ export function mergeAndRedactPersistedBlocks( * streaming) or the persisted message's `requestId` (used after refetch). * Lives in a `useRef`; never persisted; dropped on unload. */ -export type RevealedSimKeysByMessage = Map +export type RevealedSimKeysByMessage = Map /** * Scan an assembled assistant message for `` tags * and return their values in stream order; value-less (masked/placeholder) tags * carry no string value and are skipped. */ -export function extractRevealedSimKeys(content: string): string[] { +export function extractRevealedSimKeys(content: string): RevealedSimKey[] { if (!content || !content.includes('')) return [] - const values: string[] = [] + const values: RevealedSimKey[] = [] for (const match of content.matchAll(CREDENTIAL_TAG_PATTERN)) { const parsed = parseCredentialBody(match[1]) for (const item of credentialBodies(parsed)) { if (isSimKeyBody(item) && typeof item.value === 'string') { - values.push(item.value) + values.push({ value: item.value, ...optionalWorkspaceId(item.workspaceId) }) } } } @@ -248,9 +278,9 @@ interface ToolResultBlockLike { */ export function extractRevealedSimKeysFromBlocks( blocks: ReadonlyArray | undefined -): string[] { +): RevealedSimKey[] { if (!blocks?.length) return [] - const values: string[] = [] + const values: RevealedSimKey[] = [] for (const block of blocks) { const toolCall = block.toolCall if (!toolCall || toolCall.name !== GenerateApiKey.id) continue @@ -260,7 +290,7 @@ export function extractRevealedSimKeysFromBlocks( if (!isRecordLike(output)) continue const key = output.key if (typeof key === 'string' && key.length > 0 && key !== REDACTED_MARKER) { - values.push(key) + values.push({ value: key, ...optionalWorkspaceId(output.workspaceId) }) } } return values @@ -298,7 +328,7 @@ export function captureRevealedSimKeys( function restoreInString( content: string, - revealedValues: string[], + revealedValues: RevealedSimKey[], startCursor: number ): { next: string @@ -315,12 +345,12 @@ function restoreInString( let tagChanged = false const restoreItem = (item: unknown): unknown => { if (!isSimKeyBody(item) || item.value !== undefined) return item - const value = revealedValues[cursor] + const revealed = revealedValues[cursor] cursor += 1 - if (typeof value === 'string') { + if (revealed) { changed = true tagChanged = true - return { value, type: SIM_KEY_TYPE } + return simKeyItem(item, revealed) } return item }