Skip to content

Commit 50e5dff

Browse files
fix(workflows): redact run and export secrets (#6591)
* fix(workflows): redact run and export secrets * fix(workflows): preserve redacted run outputs * fix(workflows): retain safe trace output fallback * fix(workflows): stop deriving outputs from traces
1 parent 9923faf commit 50e5dff

7 files changed

Lines changed: 429 additions & 41 deletions

File tree

apps/sim/lib/logs/execution/trace-store.test.ts

Lines changed: 165 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ vi.mock('@/lib/execution/payloads/store', () => ({
2121
import {
2222
externalizeExecutionData,
2323
materializeExecutionData,
24+
materializeExecutionDataForDisplayWithBlockOutputs,
2425
projectExecutionDataForDisplay,
2526
RESOLVED_SECRET_PROVENANCE_KEY,
2627
SECRET_PROJECTION_VERSION,
@@ -92,6 +93,170 @@ describe('execution data storage', () => {
9293
})
9394

9495
describe('projectExecutionDataForDisplay', () => {
96+
it('projects authoritative state-only block outputs without mutating execution state', async () => {
97+
const executionData = {
98+
secretProjectionVersion: SECRET_PROJECTION_VERSION,
99+
traceSpans: [],
100+
executionState: {
101+
resolvedSecretTraceProvenance: {
102+
version: 1 as const,
103+
complete: true,
104+
entries: [{ name: 'OPENAI_API_KEY', encryptedValue: 'ciphertext' }],
105+
scope: { userId: 'user-1', workspaceId: 'workspace-1' },
106+
},
107+
blockStates: {
108+
'function-1': {
109+
output: { token: 12345678, derived: 12345683 },
110+
resolvedSecretTraceProvenance: {
111+
version: 1 as const,
112+
complete: true,
113+
entries: [{ name: 'OPENAI_API_KEY', encryptedValue: 'ciphertext' }],
114+
scope: { userId: 'user-1', workspaceId: 'workspace-1' },
115+
},
116+
},
117+
},
118+
},
119+
}
120+
121+
const materialized = await materializeExecutionDataForDisplayWithBlockOutputs(
122+
executionData,
123+
CONTEXT,
124+
['function-1']
125+
)
126+
127+
expect(materialized.executionData).not.toHaveProperty('executionState')
128+
expect(materialized.blockOutputs).toEqual(
129+
new Map([['function-1', { token: '{{OPENAI_API_KEY}}', derived: 12345683 }]])
130+
)
131+
expect(executionData.executionState.blockStates['function-1'].output).toEqual({
132+
token: 12345678,
133+
derived: 12345683,
134+
})
135+
expect(JSON.stringify(materialized.executionData)).not.toContain('12345678')
136+
expect(JSON.stringify([...materialized.blockOutputs])).not.toContain('12345678')
137+
})
138+
139+
it('does not use trace output for a requested block missing from partial state', async () => {
140+
const emptyProvenance = {
141+
version: 1 as const,
142+
complete: true,
143+
entries: [],
144+
scope: { userId: 'user-1', workspaceId: 'workspace-1' },
145+
}
146+
const materialized = await materializeExecutionDataForDisplayWithBlockOutputs(
147+
{
148+
secretProjectionVersion: SECRET_PROJECTION_VERSION,
149+
traceSpans: [
150+
{
151+
id: 'span-1',
152+
blockId: 'trace-only',
153+
name: 'Trace-only block',
154+
type: 'function',
155+
duration: 1,
156+
startTime: '2026-08-11T00:00:00.000Z',
157+
endTime: '2026-08-11T00:00:00.001Z',
158+
output: { result: 'trace-output' },
159+
},
160+
],
161+
executionState: {
162+
resolvedSecretTraceProvenance: emptyProvenance,
163+
blockStates: {
164+
'state-only': {
165+
output: { result: 'state-output' },
166+
resolvedSecretTraceProvenance: emptyProvenance,
167+
},
168+
},
169+
},
170+
},
171+
CONTEXT,
172+
['state-only', 'trace-only']
173+
)
174+
175+
expect(materialized.blockOutputs).toEqual(new Map([['state-only', { result: 'state-output' }]]))
176+
})
177+
178+
it('does not derive block outputs from legacy trace spans', async () => {
179+
const materialized = await materializeExecutionDataForDisplayWithBlockOutputs(
180+
{
181+
traceSpans: [
182+
{
183+
id: 'span-1',
184+
blockId: 'function-1',
185+
name: 'Function 1',
186+
type: 'function',
187+
duration: 1,
188+
startTime: '2026-08-11T00:00:00.000Z',
189+
endTime: '2026-08-11T00:00:00.001Z',
190+
output: { token: 'raw-legacy-secret' },
191+
},
192+
],
193+
},
194+
CONTEXT,
195+
['function-1']
196+
)
197+
198+
expect(materialized.blockOutputs).toEqual(new Map())
199+
})
200+
201+
it('does not mix legacy trace output into partial execution state', async () => {
202+
const materialized = await materializeExecutionDataForDisplayWithBlockOutputs(
203+
{
204+
traceSpans: [
205+
{
206+
id: 'span-1',
207+
blockId: 'trace-only',
208+
name: 'Trace-only block',
209+
type: 'function',
210+
duration: 1,
211+
startTime: '2026-08-11T00:00:00.000Z',
212+
endTime: '2026-08-11T00:00:00.001Z',
213+
output: { token: 'raw-legacy-secret' },
214+
},
215+
],
216+
executionState: {
217+
blockStates: {
218+
'state-only': { output: { result: 'unproven-state-output' } },
219+
},
220+
},
221+
},
222+
CONTEXT,
223+
['state-only', 'trace-only']
224+
)
225+
226+
expect(materialized.blockOutputs).toEqual(new Map())
227+
expect(JSON.stringify([...materialized.blockOutputs])).not.toContain('raw-legacy-secret')
228+
})
229+
230+
it('omits state-only block outputs that lack usable secret provenance', async () => {
231+
const materialized = await materializeExecutionDataForDisplayWithBlockOutputs(
232+
{
233+
secretProjectionVersion: SECRET_PROJECTION_VERSION,
234+
traceSpans: [
235+
{
236+
id: 'span-1',
237+
blockId: 'function-1',
238+
name: 'Function 1',
239+
type: 'function',
240+
duration: 1,
241+
startTime: '2026-08-11T00:00:00.000Z',
242+
endTime: '2026-08-11T00:00:00.001Z',
243+
output: { token: 'trace-fallback' },
244+
},
245+
],
246+
executionState: {
247+
blockStates: {
248+
'function-1': { output: { token: 'unproven-secret' } },
249+
},
250+
},
251+
},
252+
CONTEXT,
253+
['function-1']
254+
)
255+
256+
expect(materialized.blockOutputs).toEqual(new Map())
257+
expect(JSON.stringify(materialized)).not.toContain('unproven-secret')
258+
})
259+
95260
it('retains run-global projection for legacy rows without exact value sidecars', async () => {
96261
const executionData = {
97262
finalOutput: { result: 12345678, derived: 12345683 },

apps/sim/lib/logs/execution/trace-store.ts

Lines changed: 103 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { toError } from '@sim/utils/errors'
33
import { omit } from '@sim/utils/object'
44
import { isLargeValueRef } from '@/lib/execution/payloads/large-value-ref'
55
import { materializeLargeValueRef, storeLargeValue } from '@/lib/execution/payloads/store'
6+
import { FunctionalOutputsUnavailableError } from '@/lib/logs/execution/functional-outputs'
67
import { projectTraceSpansForSecrets } from '@/lib/logs/execution/trace-secret-projection'
78
import type { TraceSpan } from '@/lib/logs/types'
89
import {
@@ -72,6 +73,11 @@ export interface TraceStoreReadContext {
7273
userId?: string
7374
}
7475

76+
export interface DisplayExecutionDataWithBlockOutputs {
77+
executionData: Record<string, unknown>
78+
blockOutputs: Map<string, unknown>
79+
}
80+
7581
/**
7682
* Write-path context. Requires the execution owner's `userId`: the externalized
7783
* object is tracked in `workspace_files`, whose `user_id` column is NOT NULL
@@ -269,6 +275,100 @@ export async function materializeExecutionDataForDisplay(
269275
return projectExecutionDataForDisplay(materialized, context)
270276
}
271277

278+
/**
279+
* Materializes one trusted row into its display envelope plus secret-safe functional outputs.
280+
* Only requested execution-state outputs are projected and returned; trace spans remain display
281+
* data and the raw execution state never crosses the display boundary.
282+
*/
283+
export async function materializeExecutionDataForDisplayWithBlockOutputs(
284+
executionData: Record<string, unknown> | null | undefined,
285+
context: TraceStoreReadContext,
286+
blockIds: readonly string[]
287+
): Promise<DisplayExecutionDataWithBlockOutputs> {
288+
const materialized = await materializeExecutionData(executionData, context)
289+
const displayData = await projectExecutionDataForDisplay(materialized, context)
290+
if (blockIds.length === 0) {
291+
return { executionData: displayData, blockOutputs: new Map() }
292+
}
293+
294+
const executionState = readRecord(materialized.executionState)
295+
const blockStates = readRecord(executionState?.blockStates)
296+
if (!blockStates) {
297+
if (materialized.executionDataTruncated === true) {
298+
throw new FunctionalOutputsUnavailableError()
299+
}
300+
return { executionData: displayData, blockOutputs: new Map() }
301+
}
302+
303+
const runRegistry = await importResolvedSecretTraceRegistry(
304+
materialized[RESOLVED_SECRET_PROVENANCE_KEY] ??
305+
executionState?.[RESOLVED_SECRET_PROVENANCE_KEY],
306+
'traceStore.blockOutputRunProvenance'
307+
)
308+
const blockOutputs = new Map<string, unknown>()
309+
const projectionStore = createReadOnlyProjectionStore(context)
310+
311+
for (const blockId of new Set(blockIds)) {
312+
const blockState = readRecord(blockStates[blockId])
313+
if (!blockState || blockState.output === undefined) continue
314+
315+
const hasExactProvenance = Object.hasOwn(blockState, RESOLVED_SECRET_PROVENANCE_KEY)
316+
const registry = hasExactProvenance
317+
? await importResolvedSecretTraceRegistry(
318+
blockState[RESOLVED_SECRET_PROVENANCE_KEY],
319+
'traceStore.blockOutputExactProvenance'
320+
)
321+
: runRegistry
322+
const now = new Date().toISOString()
323+
const [projected] = await projectTraceSpansForSecrets(
324+
[
325+
{
326+
id: `${LOG_DISPLAY_PROJECTION_SPAN_ID}-block-output`,
327+
name: 'Block Output Display Projection',
328+
type: 'display',
329+
duration: 0,
330+
startTime: now,
331+
endTime: now,
332+
output: { value: blockState.output },
333+
},
334+
],
335+
{ registry, allowLargeValueWrites: false, store: projectionStore }
336+
)
337+
if (projected?.output && Object.hasOwn(projected.output, 'value')) {
338+
blockOutputs.set(blockId, projected.output.value)
339+
}
340+
}
341+
342+
return { executionData: displayData, blockOutputs }
343+
}
344+
345+
function readRecord(value: unknown): Record<string, unknown> | undefined {
346+
return value && typeof value === 'object' && !Array.isArray(value)
347+
? (value as Record<string, unknown>)
348+
: undefined
349+
}
350+
351+
async function importResolvedSecretTraceRegistry(
352+
provenance: unknown,
353+
origin: string
354+
): Promise<ResolvedSecretTraceRegistry | undefined> {
355+
if (!isResolvedSecretTraceProvenanceV1(provenance)) return undefined
356+
357+
const registry = new ResolvedSecretTraceRegistry([], provenance.scope)
358+
await registry.importProvenance(provenance, { trusted: true, origin })
359+
return registry
360+
}
361+
362+
function createReadOnlyProjectionStore(context: TraceStoreReadContext) {
363+
return {
364+
workspaceId: context.workspaceId ?? undefined,
365+
workflowId: context.workflowId ?? undefined,
366+
executionId: context.executionId,
367+
userId: context.userId,
368+
trackReference: false,
369+
}
370+
}
371+
272372
/**
273373
* Projects execution-log content with the encrypted provenance saved by the
274374
* trusted executor. Current workflow input and final output values use their
@@ -284,12 +384,7 @@ export async function projectExecutionDataForDisplay(
284384
executionData: Record<string, unknown>,
285385
context: TraceStoreReadContext
286386
): Promise<Record<string, unknown>> {
287-
const executionState =
288-
executionData.executionState &&
289-
typeof executionData.executionState === 'object' &&
290-
!Array.isArray(executionData.executionState)
291-
? (executionData.executionState as Record<string, unknown>)
292-
: undefined
387+
const executionState = readRecord(executionData.executionState)
293388
const hasTopLevelProvenance = Object.hasOwn(executionData, RESOLVED_SECRET_PROVENANCE_KEY)
294389
const stateProvenance = executionState?.[RESOLVED_SECRET_PROVENANCE_KEY]
295390
const provenance = executionData[RESOLVED_SECRET_PROVENANCE_KEY] ?? stateProvenance
@@ -302,15 +397,7 @@ export async function projectExecutionDataForDisplay(
302397
return projectLegacyExecutionDataForDisplay(executionData)
303398
}
304399

305-
let registry: ResolvedSecretTraceRegistry | undefined
306-
307-
if (isResolvedSecretTraceProvenanceV1(provenance)) {
308-
registry = new ResolvedSecretTraceRegistry([], provenance.scope)
309-
await registry.importProvenance(provenance, {
310-
trusted: true,
311-
origin: 'traceStore.spanProvenance',
312-
})
313-
}
400+
const registry = await importResolvedSecretTraceRegistry(provenance, 'traceStore.spanProvenance')
314401

315402
/**
316403
* Compaction drops `executionState`, and with it the only copy of the
@@ -339,13 +426,7 @@ export async function projectExecutionDataForDisplay(
339426
})
340427
}
341428

342-
const projectionStore = {
343-
workspaceId: context.workspaceId ?? undefined,
344-
workflowId: context.workflowId ?? undefined,
345-
executionId: context.executionId,
346-
userId: context.userId,
347-
trackReference: false,
348-
}
429+
const projectionStore = createReadOnlyProjectionStore(context)
349430

350431
const exactValueProjections = new Map<string, unknown>()
351432
for (const [valueKey, provenanceKey] of Object.entries(EXACT_LOG_VALUE_PROVENANCE_KEYS)) {

0 commit comments

Comments
 (0)