Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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<string, string>) => `<credential>${JSON.stringify(item)}</credential>`
const tagData = (content: string) =>
JSON.parse(
content.slice('<credential>'.length, -'</credential>'.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(
`<credential>${JSON.stringify({ type: 'sim_key', workspaceId: 'other-workspace', value: 'sk-sim-saved' })}</credential>`
)
const { container, root } = renderCredentialLink(
JSON.parse(
persisted.slice('<credential>'.length, -'</credential>'.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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3327,7 +3327,8 @@ export function CredentialDisplay(props: Parameters<typeof CredentialDisplayCont
(item) =>
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))
Expand Down
114 changes: 84 additions & 30 deletions apps/sim/lib/mothership/chat/sim-key-redaction.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ import {
captureRevealedSimKeys,
extractRevealedSimKeys,
extractRevealedSimKeysFromBlocks,
type RevealedSimKey,
type RevealedSimKeysByMessage,
redactSensitiveContent,
restoreRevealedSimKeysForMessage,
toolResultForModel,
Expand All @@ -16,6 +18,8 @@ const redacted = `<credential>${JSON.stringify({ type: 'sim_key', redacted: true
const placeholder = `<credential>${JSON.stringify({ type: 'sim_key' })}</credential>`
const credentialBatch = (items: unknown[]) => `<credential>${JSON.stringify(items)}</credential>`

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 } } },
Expand All @@ -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 = `<credential>${JSON.stringify({ value: 'https://x', type: 'link', provider: 'slack' })}</credential>`
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', () => {
Expand All @@ -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'))
})
})

Expand All @@ -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 = `<credential>${JSON.stringify({ type: 'sim_key', workspaceId: 'ws-1', value: 'sk-sim-secret' })}</credential>`
expect(redactSensitiveContent(single)).toBe(
`<credential>${JSON.stringify({ type: 'sim_key', workspaceId: 'ws-1' })}</credential>`
)
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 = `<credential>${JSON.stringify({ type: 'sim_key', workspaceId, value: 'sk-sim-secret' })}</credential>`
expect(redactSensitiveContent(tag)).toBe(placeholder)
}
)
})

describe('toolResultForModel', () => {
Expand Down Expand Up @@ -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', () => {
Expand All @@ -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', () => {
Expand All @@ -103,55 +128,55 @@ describe('sim-key-redaction', () => {

describe('captureRevealedSimKeys', () => {
it('records new keys under each provided key', () => {
const cache = new Map<string, string[]>()
const cache = new Map<string, RevealedSimKey[]>()
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<string, string[]>()
const cache = new Map<string, RevealedSimKey[]>()
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<string, string[]>()
const cache = new Map<string, RevealedSimKey[]>()
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<string, string[]>()
const cache = new Map<string, RevealedSimKey[]>()
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<string, string[]>()
const cache = new Map<string, RevealedSimKey[]>()
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<string, string[]>()
const cache = new Map<string, RevealedSimKey[]>()
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<string, string[]>([['msg-1', ['sk-sim-A']]])
const cache = new Map<string, RevealedSimKey[]>([['msg-1', keys('sk-sim-A')]])
const msg: ChatMessage = {
id: 'msg-1',
role: 'assistant',
Expand All @@ -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<string, string[]>([['msg-1', ['sk-sim-A']]])
const cache = new Map<string, RevealedSimKey[]>([['msg-1', keys('sk-sim-A')]])
const msg: ChatMessage = {
id: 'msg-1',
role: 'assistant',
Expand All @@ -178,7 +203,7 @@ describe('sim-key-redaction', () => {
})

it('fills value-less and redacted placeholders positionally in one message', () => {
const cache = new Map<string, string[]>([['msg-1', ['sk-sim-A', 'sk-sim-B']]])
const cache = new Map<string, RevealedSimKey[]>([['msg-1', keys('sk-sim-A', 'sk-sim-B')]])
const msg: ChatMessage = {
id: 'msg-1',
role: 'assistant',
Expand All @@ -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<string, string[]>([['msg-1', ['sk-sim-A', 'sk-sim-B']]])
const cache = new Map<string, RevealedSimKey[]>([['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(
Expand All @@ -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 = `<credential>${JSON.stringify({ type: 'sim_key', workspaceId: 'ws-other' })}</credential>`
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(
`<credential>${JSON.stringify({ value: 'sk-sim-A', type: 'sim_key', workspaceId: 'ws-1' })}</credential>`
)
})

it('keeps the tag workspace when the creation result carries none', () => {
const cache: RevealedSimKeysByMessage = new Map()
const tag = `<credential>${JSON.stringify({ type: 'sim_key', workspaceId: 'ws-tag' })}</credential>`
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(
`<credential>${JSON.stringify({ value: 'sk-sim-A', type: 'sim_key', workspaceId: 'ws-tag' })}</credential>`
)
})

it('substitutes multiple keys in stream order', () => {
const cache = new Map<string, string[]>([['msg-1', ['sk-sim-A', 'sk-sim-B']]])
const cache = new Map<string, RevealedSimKey[]>([['msg-1', keys('sk-sim-A', 'sk-sim-B')]])
const msg: ChatMessage = {
id: 'msg-1',
role: 'assistant',
Expand All @@ -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<string, string[]>([['msg-1', ['sk-sim-A']]])
const cache = new Map<string, RevealedSimKey[]>([['msg-1', keys('sk-sim-A')]])
const msg: ChatMessage = {
id: 'msg-1',
role: 'assistant',
Expand All @@ -230,7 +284,7 @@ describe('sim-key-redaction', () => {
})

it('returns the same message reference when nothing to restore', () => {
const cache = new Map<string, string[]>()
const cache = new Map<string, RevealedSimKey[]>()
const msg: ChatMessage = {
id: 'msg-1',
role: 'assistant',
Expand All @@ -240,7 +294,7 @@ describe('sim-key-redaction', () => {
})

it('does nothing for user messages', () => {
const cache = new Map<string, string[]>([['msg-1', ['sk-sim-A']]])
const cache = new Map<string, RevealedSimKey[]>([['msg-1', keys('sk-sim-A')]])
const msg: ChatMessage = {
id: 'msg-1',
role: 'user',
Expand All @@ -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<string, string[]>([['msg-1', ['sk-sim-A', 'sk-sim-B']]])
const cache = new Map<string, RevealedSimKey[]>([['msg-1', keys('sk-sim-A', 'sk-sim-B')]])
const msg: ChatMessage = {
id: 'msg-1',
role: 'assistant',
Expand All @@ -269,9 +323,9 @@ describe('sim-key-redaction', () => {
})

it('isolates revealed values by message id (multiple keys across messages)', () => {
const cache = new Map<string, string[]>([
['msg-1', ['sk-sim-A']],
['msg-2', ['sk-sim-B']],
const cache = new Map<string, RevealedSimKey[]>([
['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 }
Expand Down
Loading
Loading