Skip to content

Commit 0d9e256

Browse files
authored
fix(copilot): stop returning generate_api_key plaintext on the in-band tool route (#8042)
Tool results reach the model over two lanes, but the model-facing projection was applied on only one. The resume lane runs results through getToolCallTerminalData, which reduces generate_api_key to its status message. The in-band route (POST /api/copilot/tools/execute) returned the handler output verbatim, so the freshly minted plaintext workspace API key crossed to the model and into the turn transcript. The egress projection cannot cover this: its registry is a catalog of pre-existing environment and credential secrets, built once per turn, so a key minted mid-turn is invisible to it. Apply toolResultForModel at the route so both lanes return the same model-facing projection. It is an identity for every other tool.
1 parent c45e8b5 commit 0d9e256

2 files changed

Lines changed: 58 additions & 1 deletion

File tree

‎apps/sim/app/api/copilot/tools/execute/route.test.ts‎

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -125,4 +125,52 @@ describe('POST /api/copilot/tools/execute (in-band)', () => {
125125
)
126126
expect(mockPrepareEnvironmentContext).toHaveBeenCalledTimes(1)
127127
})
128+
129+
/**
130+
* The generated key is a browser-only artifact. This lane's response is what Go feeds the
131+
* model, so it must carry the same projection the resume lane produces — the status message
132+
* alone. A `key` field here would put the plaintext credential in model context.
133+
*/
134+
it('returns only the status message for generate_api_key, never the key', async () => {
135+
const message = 'API key "demo" created. You did NOT receive the key value'
136+
mockHandler.mockResolvedValue({
137+
success: true,
138+
output: { id: 'key-1', name: 'demo', key: 'sk_live_plaintext', workspaceId: 'ws-1', message },
139+
})
140+
141+
const res = await POST(
142+
makeRequest({
143+
...BASE_BODY,
144+
toolName: 'generate_api_key',
145+
params: { name: 'demo' },
146+
messageId: 'msg-api-key',
147+
}) as never
148+
)
149+
const body = await res.json()
150+
151+
expect(body).toEqual({ success: true, output: message })
152+
expect(JSON.stringify(body)).not.toContain('sk_live_plaintext')
153+
})
154+
155+
it('leaves a non-generate_api_key result carrying a key field untouched', async () => {
156+
mockHandler.mockResolvedValue({ success: true, output: { key: 'lookup-key', value: 42 } })
157+
const res = await POST(makeRequest({ ...BASE_BODY, messageId: 'msg-other-tool-key' }) as never)
158+
await expect(res.json()).resolves.toEqual({
159+
success: true,
160+
output: { key: 'lookup-key', value: 42 },
161+
})
162+
})
163+
164+
it('passes a failed generate_api_key call through with its error', async () => {
165+
mockHandler.mockResolvedValue({ success: false, error: 'name is required' })
166+
const res = await POST(
167+
makeRequest({
168+
...BASE_BODY,
169+
toolName: 'generate_api_key',
170+
params: {},
171+
messageId: 'msg-api-key-error',
172+
}) as never
173+
)
174+
await expect(res.json()).resolves.toEqual({ success: false, error: 'name is required' })
175+
})
128176
})

‎apps/sim/app/api/copilot/tools/execute/route.ts‎

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { getErrorMessage } from '@sim/utils/errors'
33
import { type NextRequest, NextResponse } from 'next/server'
44
import { copilotToolExecuteInternalBodySchema } from '@/lib/api/contracts/copilot'
55
import { validationErrorResponse } from '@/lib/api/server'
6+
import { toolResultForModel } from '@/lib/copilot/chat/sim-key-redaction'
67
import { prepareCopilotEnvironmentContext } from '@/lib/copilot/environment-context'
78
import { MothershipStreamV1ToolOutcome } from '@/lib/copilot/generated/mothership-stream-v1'
89
import { TraceAttr } from '@/lib/copilot/generated/trace-attributes-v1'
@@ -243,9 +244,17 @@ export const POST = withRouteHandler((request: NextRequest) =>
243244
})
244245
})
245246
}
247+
/**
248+
* The response IS the model-facing channel on this lane — Go relays it straight into
249+
* the turn — so it carries the same projection the resume lane's
250+
* `getToolCallTerminalData` produces, not the raw handler output. Without this,
251+
* `generate_api_key`'s freshly minted plaintext key crossed to the model here while
252+
* the redaction held on the other lane. Every other tool is returned unchanged.
253+
*/
254+
const modelOutput = toolResultForModel(toolName, projected.output)
246255
return NextResponse.json({
247256
success: projected.success,
248-
...(projected.output !== undefined ? { output: projected.output } : {}),
257+
...(modelOutput !== undefined ? { output: modelOutput } : {}),
249258
...(projected.error ? { error: projected.error } : {}),
250259
})
251260
} catch (err) {

0 commit comments

Comments
 (0)