Skip to content

Commit f97899d

Browse files
committed
feat(secrets): make output of generate api key a secret
1 parent 8ed6453 commit f97899d

12 files changed

Lines changed: 408 additions & 135 deletions

File tree

apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/question/question.tsx

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,10 +5,10 @@ import {
55
ArrowRight,
66
Button,
77
Check,
8-
checkboxIconVariants,
9-
checkboxVariants,
108
ChevronLeft,
119
ChevronRight,
10+
checkboxIconVariants,
11+
checkboxVariants,
1212
cn,
1313
X,
1414
} from '@sim/emcn'
@@ -371,7 +371,6 @@ export function QuestionDisplay({
371371
type='text'
372372
value={freeText}
373373
disabled={disabled}
374-
autoFocus
375374
onChange={(e) => setFreeText(e.target.value)}
376375
onBlur={() => {
377376
if (freeText.trim().length === 0) {

apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/special-tags/special-tags.test.tsx

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,10 @@ vi.mock('next/navigation', () => ({
1818
}))
1919

2020
import type { CredentialTagData } from '@/app/workspace/[workspaceId]/home/components/message-content/components/special-tags/special-tags'
21-
import { SpecialTags } from '@/app/workspace/[workspaceId]/home/components/message-content/components/special-tags/special-tags'
21+
import {
22+
parseSpecialTags,
23+
SpecialTags,
24+
} from '@/app/workspace/[workspaceId]/home/components/message-content/components/special-tags/special-tags'
2225

2326
/**
2427
* Minimal dependency-free render harness (the repo has no `@testing-library/react`). Mounts the
@@ -89,3 +92,24 @@ describe('CredentialDisplay link tag', () => {
8992
act(() => root.unmount())
9093
})
9194
})
95+
96+
describe('parseSpecialTags sim_key placeholder', () => {
97+
it('accepts a value-less {"type":"sim_key"} tag as a credential segment', () => {
98+
const { segments } = parseSpecialTags('<credential>{"type":"sim_key"}</credential>', false)
99+
const credential = segments.find((s) => s.type === 'credential')
100+
expect(credential).toEqual({ type: 'credential', data: { type: 'sim_key' } })
101+
})
102+
103+
it('still accepts the legacy {"redacted":true} form as a value-less sim_key placeholder', () => {
104+
const { segments } = parseSpecialTags(
105+
'<credential>{"type":"sim_key","redacted":true}</credential>',
106+
false
107+
)
108+
const credential = segments.find((s) => s.type === 'credential')
109+
expect(credential?.type).toBe('credential')
110+
if (credential?.type === 'credential') {
111+
expect(credential.data.type).toBe('sim_key')
112+
expect(credential.data.value).toBeUndefined()
113+
}
114+
})
115+
})

apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/special-tags/special-tags.tsx

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -74,7 +74,6 @@ export interface CredentialTagData {
7474
value?: string
7575
type: CredentialTagType
7676
provider?: string
77-
redacted?: boolean
7877
/**
7978
* Env-var key name to save the pasted secret under (secret_input only),
8079
* e.g. "OPENAI_API_KEY".
@@ -219,7 +218,11 @@ function isCredentialTagData(value: unknown): value is CredentialTagData {
219218
}
220219
return typeof value.name === 'string' && value.name.trim().length > 0
221220
}
222-
if (value.redacted === true) return value.value === undefined || typeof value.value === 'string'
221+
// A sim_key chip is platform-filled: the model only marks where the workspace
222+
// API key belongs (it never holds the value) and Sim injects it from the tool
223+
// result, so the tag is valid with or without a `value`. Every other rendered
224+
// type (e.g. link) needs a string value to render.
225+
if (value.type === 'sim_key') return true
223226
return typeof value.value === 'string'
224227
}
225228

@@ -893,7 +896,10 @@ function CredentialDisplay({ data }: { data: CredentialTagData }) {
893896
}
894897

895898
if (data.type === 'sim_key') {
896-
return <SecretReveal value={data.value} redacted={data.redacted || !data.value} />
899+
// SecretReveal masks itself when there's no value, so a value-less tag (the
900+
// model's placeholder / persisted form) renders masked and a Sim-filled tag
901+
// reveals the key + copy button — no separate "redacted" flag needed.
902+
return <SecretReveal value={data.value} />
897903
}
898904

899905
return null

apps/sim/app/workspace/[workspaceId]/home/hooks/stream/stream-context.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -187,7 +187,8 @@ export function createStreamLoopContext(deps: StreamLoopDeps): StreamLoopContext
187187
captureRevealedSimKeys(
188188
deps.revealedSimKeysRef.current,
189189
[deps.assistantId, state.streamRequestId],
190-
modelContent
190+
modelContent,
191+
modelBlocks
191192
)
192193
const activeChatId = deps.options.targetChatId ?? deps.chatIdRef.current
193194
if (!activeChatId) {

apps/sim/lib/copilot/chat/persisted-message.test.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -90,10 +90,10 @@ describe('persisted-message', () => {
9090
const persisted = buildPersistedAssistantMessage(result)
9191

9292
expect(persisted.content).not.toContain('sk-sim-secret-123')
93-
expect(persisted.content).toContain('"redacted":true')
93+
expect(persisted.content).toContain('{"type":"sim_key"}')
9494
const textBlock = persisted.contentBlocks?.find((b) => b.type === 'text')
9595
expect(textBlock?.content).not.toContain('sk-sim-secret-123')
96-
expect(textBlock?.content).toContain('"redacted":true')
96+
expect(textBlock?.content).toContain('{"type":"sim_key"}')
9797
})
9898

9999
it('redacts sim_key credential tags split across streamed text chunks', () => {
@@ -119,7 +119,7 @@ describe('persisted-message', () => {
119119
expect(persisted.contentBlocks).toBeDefined()
120120
const joined = (persisted.contentBlocks ?? []).map((b) => b.content ?? '').join('')
121121
expect(joined).not.toContain('sk-sim-secret-12345')
122-
expect(joined).toContain('"redacted":true')
122+
expect(joined).toContain('{"type":"sim_key"}')
123123
})
124124

125125
it('redacts the api key from a persisted generate_api_key tool result output', () => {

apps/sim/lib/copilot/chat/sim-key-redaction.test.ts

Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,12 +7,21 @@ import type { ChatMessage } from '@/app/workspace/[workspaceId]/home/types'
77
import {
88
captureRevealedSimKeys,
99
extractRevealedSimKeys,
10+
extractRevealedSimKeysFromBlocks,
1011
restoreRevealedSimKeysForMessage,
12+
toolResultForModel,
1113
} from './sim-key-redaction'
1214

1315
const credential = (value: string) =>
1416
`<credential>${JSON.stringify({ value, type: 'sim_key' })}</credential>`
1517
const redacted = `<credential>${JSON.stringify({ type: 'sim_key', redacted: true })}</credential>`
18+
// The value-less placeholder the model now emits (no `redacted` flag).
19+
const placeholder = `<credential>${JSON.stringify({ type: 'sim_key' })}</credential>`
20+
21+
const apiKeyBlock = (key: string) => ({
22+
type: 'tool_call' as const,
23+
toolCall: { name: 'generate_api_key', result: { success: true, output: { id: 'k1', key } } },
24+
})
1625

1726
describe('sim-key-redaction', () => {
1827
describe('extractRevealedSimKeys', () => {
@@ -28,6 +37,55 @@ describe('sim-key-redaction', () => {
2837
})
2938
})
3039

40+
describe('toolResultForModel', () => {
41+
it('reduces a successful generate_api_key result to only its status message', () => {
42+
const data = {
43+
id: 'k1',
44+
name: 'prod',
45+
key: 'sk-sim-secret',
46+
workspaceId: 'ws-1',
47+
message: 'API key "prod" created.',
48+
}
49+
expect(toolResultForModel('generate_api_key', data)).toBe('API key "prod" created.')
50+
})
51+
52+
it('leaves other tools untouched', () => {
53+
const data = { key: 'not-a-secret', ok: true }
54+
expect(toolResultForModel('read', data)).toBe(data)
55+
})
56+
57+
it('passes generate_api_key errors through (no key to withhold)', () => {
58+
const data = { error: 'name is required' }
59+
expect(toolResultForModel('generate_api_key', data)).toBe(data)
60+
expect(toolResultForModel('generate_api_key', undefined)).toBe(undefined)
61+
})
62+
})
63+
64+
describe('extractRevealedSimKeysFromBlocks', () => {
65+
it('pulls generate_api_key output keys in block order', () => {
66+
expect(
67+
extractRevealedSimKeysFromBlocks([apiKeyBlock('sk-sim-A'), apiKeyBlock('sk-sim-B')])
68+
).toEqual(['sk-sim-A', 'sk-sim-B'])
69+
})
70+
71+
it('skips redacted markers and unrelated tools', () => {
72+
const blocks = [
73+
apiKeyBlock('[REDACTED]'),
74+
{
75+
type: 'tool_call' as const,
76+
toolCall: { name: 'read', result: { success: true, output: { key: 'sk-x' } } },
77+
},
78+
apiKeyBlock('sk-sim-A'),
79+
]
80+
expect(extractRevealedSimKeysFromBlocks(blocks)).toEqual(['sk-sim-A'])
81+
})
82+
83+
it('returns nothing for empty/undefined block lists', () => {
84+
expect(extractRevealedSimKeysFromBlocks(undefined)).toEqual([])
85+
expect(extractRevealedSimKeysFromBlocks([])).toEqual([])
86+
})
87+
})
88+
3189
describe('captureRevealedSimKeys', () => {
3290
it('records new keys under each provided key', () => {
3391
const cache = new Map<string, string[]>()
@@ -59,6 +117,21 @@ describe('sim-key-redaction', () => {
59117
captureRevealedSimKeys(cache, ['msg-1'], 'plain assistant text')
60118
expect(cache.has('msg-1')).toBe(false)
61119
})
120+
121+
it('sources the key from the generate_api_key tool result (model text is a redacted placeholder)', () => {
122+
const cache = new Map<string, string[]>()
123+
captureRevealedSimKeys(cache, ['msg-1', 'req-1'], `Here is your key: ${redacted}`, [
124+
apiKeyBlock('sk-sim-fromtool'),
125+
])
126+
expect(cache.get('msg-1')).toEqual(['sk-sim-fromtool'])
127+
expect(cache.get('req-1')).toEqual(['sk-sim-fromtool'])
128+
})
129+
130+
it('prefers tool-result keys over any inline content values', () => {
131+
const cache = new Map<string, string[]>()
132+
captureRevealedSimKeys(cache, ['msg-1'], credential('sk-content'), [apiKeyBlock('sk-tool')])
133+
expect(cache.get('msg-1')).toEqual(['sk-tool'])
134+
})
62135
})
63136

64137
describe('restoreRevealedSimKeysForMessage', () => {
@@ -76,6 +149,32 @@ describe('sim-key-redaction', () => {
76149
expect(restored.contentBlocks?.[0].content).toContain('"sk-sim-A"')
77150
})
78151

152+
it('fills a value-less {"type":"sim_key"} placeholder (no redacted flag needed)', () => {
153+
const cache = new Map<string, string[]>([['msg-1', ['sk-sim-A']]])
154+
const msg: ChatMessage = {
155+
id: 'msg-1',
156+
role: 'assistant',
157+
content: `Here is your key: ${placeholder} save it.`,
158+
contentBlocks: [{ type: 'text', content: `Here is your key: ${placeholder} save it.` }],
159+
}
160+
const restored = restoreRevealedSimKeysForMessage(msg, cache)
161+
expect(restored.content).toContain('"sk-sim-A"')
162+
expect(restored.contentBlocks?.[0].content).toContain('"sk-sim-A"')
163+
})
164+
165+
it('fills value-less and redacted placeholders positionally in one message', () => {
166+
const cache = new Map<string, string[]>([['msg-1', ['sk-sim-A', 'sk-sim-B']]])
167+
const msg: ChatMessage = {
168+
id: 'msg-1',
169+
role: 'assistant',
170+
content: `first ${placeholder} second ${redacted}`,
171+
}
172+
const restored = restoreRevealedSimKeysForMessage(msg, cache)
173+
expect(restored.content).toBe(
174+
`first ${credential('sk-sim-A')} second ${credential('sk-sim-B')}`
175+
)
176+
})
177+
79178
it('substitutes multiple keys in stream order', () => {
80179
const cache = new Map<string, string[]>([['msg-1', ['sk-sim-A', 'sk-sim-B']]])
81180
const msg: ChatMessage = {

0 commit comments

Comments
 (0)