From 9200724c5a9d3f21297a0c286f3035274ed02ee5 Mon Sep 17 00:00:00 2001 From: Marcus Chandra Date: Tue, 4 Aug 2026 19:58:48 -0700 Subject: [PATCH 1/3] refactor(logs): extract the legacy workflow-input reader into a shared module Moves extractLegacyWorkflowInput out of execution-state.ts so both the functional re-run reader and the log display projection can use it without a circular import (execution-state already imports trace-store). Adds two display-only helpers alongside it: hasPersistedBlockStates, and recoverLegacyWorkflowInputForDisplay, which narrows the raw trigger block output to the shape workflowInput originally held. The functional reader keeps first-match behavior; only the display path refuses to guess. No behavior change to re-execution. --- .../logs/execution/legacy-workflow-input.ts | 138 ++++++++++++++++++ .../lib/workflows/executor/execution-state.ts | 20 +-- 2 files changed, 139 insertions(+), 19 deletions(-) create mode 100644 apps/sim/lib/logs/execution/legacy-workflow-input.ts diff --git a/apps/sim/lib/logs/execution/legacy-workflow-input.ts b/apps/sim/lib/logs/execution/legacy-workflow-input.ts new file mode 100644 index 00000000000..e629eebe117 --- /dev/null +++ b/apps/sim/lib/logs/execution/legacy-workflow-input.ts @@ -0,0 +1,138 @@ +import { isRecordLike, omit } from '@sim/utils/object' + +/** + * The shape the executor records for a trigger block: never executed, zero + * duration, populated output (`executor.ts` `setBlockState` after + * `buildStartBlockOutput`). + * + * The shape is not unique to the trigger. A human-in-the-loop pause writes a + * placeholder block state with the same three properties (`block-executor.ts`, + * `{ url, resumeEndpoint }` output), so a run that paused has more than one + * match. Callers that cannot tolerate the wrong block must disambiguate - see + * {@link recoverLegacyWorkflowInputForDisplay}. + */ +function isLegacyTriggerBlockState(state: unknown): state is { output: unknown } { + return ( + isRecordLike(state) && + state.executed === false && + state.executionTime === 0 && + state.output != null + ) +} + +function collectLegacyTriggerOutputs(executionData: Record): unknown[] { + if (!isRecordLike(executionData.executionState)) return [] + const { blockStates } = executionData.executionState + if (!isRecordLike(blockStates)) return [] + + const outputs: unknown[] = [] + for (const state of Object.values(blockStates)) { + if (isLegacyTriggerBlockState(state)) outputs.push(state.output) + } + return outputs +} + +/** + * Recovers the inbound trigger payload from execution data written before + * `workflowInput` was persisted as a top-level field. + * + * Returns the first matching block state, preserving the long-standing + * behavior of the functional re-run reader. Display callers must use + * {@link recoverLegacyWorkflowInputForDisplay}, which refuses to guess. + */ +export function extractLegacyWorkflowInput( + executionData: Record +): unknown | undefined { + return collectLegacyTriggerOutputs(executionData)[0] +} + +/** + * Whether the persisted state carries block states at all. + * + * Callers pair this with an absent provenance key. The trace registry is + * attached before the executor runs (`execution-core.ts` installs it ahead of + * `safeStart`), so any execution that produced block states also stamped + * provenance. An absent key together with populated block states therefore + * identifies pre-stamping data, and never a post-stamping run that failed + * early enough to miss the stamp - those carry no block states to recover from. + */ +export function hasPersistedBlockStates(executionData: Record): boolean { + if (!isRecordLike(executionData.executionState)) return false + const { blockStates } = executionData.executionState + return isRecordLike(blockStates) && Object.keys(blockStates).length > 0 +} + +/** + * Keys that the trigger block hoists next to a nested `input` payload. Blob + * forensics on pre-persistence executions show the block output is a strict + * superset of the original `workflowInput` in this shape, so the recovered + * value is projected back down to `{ input }`. + */ +const NESTED_INPUT_KEY = 'input' + +/** + * Whether the nested `input` is merely a clone of its sibling keys. + * + * `buildApiOrInputOutput` records an object input as + * `{ ...finalInput, input: { ...finalInput } }`, so the original + * `workflowInput` was the FLAT object and the nested copy is redundant. + * Narrowing that shape to `{ input }` would display something the run never + * received. The superset shape the narrowing targets is distinguishable: its + * nested `input` carries keys the siblings do not. + */ +function isNestedInputSiblingClone(recovered: Record): boolean { + const nested = recovered[NESTED_INPUT_KEY] + if (!isRecordLike(nested)) return false + + const siblings = omit(recovered, [NESTED_INPUT_KEY]) + const siblingKeys = Object.keys(siblings) + if (siblingKeys.length === 0 || siblingKeys.length !== Object.keys(nested).length) return false + + return siblingKeys.every( + (key) => + Object.hasOwn(nested, key) && JSON.stringify(siblings[key]) === JSON.stringify(nested[key]) + ) +} + +/** + * A Slack verification token echoed into the trigger block output. It is the + * only key that diverges from the original `workflowInput` in the Slack + * envelope shape, and it is secret-shaped, so it is dropped rather than + * displayed with a value that is both wrong and sensitive. + */ +const DROPPED_RECOVERED_KEYS = ['token'] as const + +/** + * Recovers `workflowInput` for the log display projection, narrowing the raw + * trigger block output to the shape the field originally held. Functional + * readers must keep using {@link extractLegacyWorkflowInput} directly - this + * narrowing is display-only and intentionally lossy. + * + * Callers must restrict this to executions written before resolved-secret + * provenance was stamped. Block state is gated content, and this routes it to + * the ungated workflow-boundary envelope; that is only sound while the matched + * block is the trigger, which holds the payload captured before any secret was + * resolved. + * + * Recovery is therefore refused when more than one block state matches the + * trigger shape - a paused run also carries a resume placeholder with the same + * shape, and showing its `{ url, resumeEndpoint }` output labelled as the + * workflow input would be both wrong and a capability-URL disclosure. An empty + * panel beats confidently wrong content. + */ +export function recoverLegacyWorkflowInputForDisplay( + executionData: Record +): unknown | undefined { + const candidates = collectLegacyTriggerOutputs(executionData) + if (candidates.length !== 1) return undefined + + const recovered = candidates[0] + if (!isRecordLike(recovered)) return recovered + + const narrowed: Record = + isRecordLike(recovered[NESTED_INPUT_KEY]) && !isNestedInputSiblingClone(recovered) + ? { [NESTED_INPUT_KEY]: recovered[NESTED_INPUT_KEY] } + : recovered + + return omit(narrowed, [...DROPPED_RECOVERED_KEYS]) +} diff --git a/apps/sim/lib/workflows/executor/execution-state.ts b/apps/sim/lib/workflows/executor/execution-state.ts index 4f0b8eacfbf..6b2039866a5 100644 --- a/apps/sim/lib/workflows/executor/execution-state.ts +++ b/apps/sim/lib/workflows/executor/execution-state.ts @@ -2,6 +2,7 @@ import { db } from '@sim/db' import { workflowExecutionLogs } from '@sim/db/schema' import { isRecordLike } from '@sim/utils/object' import { and, desc, eq, or, sql } from 'drizzle-orm' +import { extractLegacyWorkflowInput } from '@/lib/logs/execution/legacy-workflow-input' import { materializeExecutionData, TRACE_STORE_REF_KEY } from '@/lib/logs/execution/trace-store' import type { SerializableExecutionState } from '@/executor/execution/types' import { @@ -35,25 +36,6 @@ function extractExecutionState(executionData: unknown): SerializableExecutionSta return isSerializableExecutionState(state) ? state : null } -function extractLegacyWorkflowInput(executionData: Record): unknown | undefined { - if (!isRecordLike(executionData.executionState)) return undefined - const { blockStates } = executionData.executionState - if (!isRecordLike(blockStates)) return undefined - - for (const state of Object.values(blockStates)) { - if ( - isRecordLike(state) && - state.executed === false && - state.executionTime === 0 && - state.output != null - ) { - return state.output - } - } - - return undefined -} - interface ExecutionStateRow { executionId: string workflowId: string | null From a4cd4538eff11becaab64f8162ca216b1b7525e5 Mon Sep 17 00:00:00 2001 From: Marcus Chandra Date: Tue, 4 Aug 2026 19:59:11 -0700 Subject: [PATCH 2/3] fix(logs): restore workflow input on log details for runs without secret provenance PR #6000 added a resolved-secret gate to the log display projection: when a run's persisted resolvedSecretTraceProvenance is absent or incomplete, projectTraceSpansForSecrets returns structural-only spans, which stripped the whole display envelope. That silently blanked the Input and Output panels for every run written before #6000, and for any run whose registry goes incomplete. Splits the display envelope in two. Per-block content (finalOutput, blockInput, blockExecutions, errors, trace spans) keeps the existing gate and fails closed. Only workflowInput is exempted, via projectWorkflowBoundarySpansForSecrets: with complete provenance it is byte-identical to the current matcher path, and with absent or incomplete provenance the content survives instead of collapsing. workflowInput is exempt because an inbound trigger payload is captured before any secret is resolved. That premise does NOT hold for nested runs: a workflow or custom_block execution is handed workflowInput built from its parent's already-resolved block outputs, so those keep the gated treatment. The check is a denylist of nested trigger types, because webhook runs record the provider (zoho_desk, slack) as the trigger type and an allowlist would fail closed on exactly the population this fixes. Runs written before workflowInput was persisted recover it from the trigger block state, gated on an absent provenance key plus present block states, which together identify pre-stamping data without dating the row. Recovery refuses when more than one block state matches the trigger shape, since a paused run carries a resume placeholder with the same shape and its capability URL must never render as the workflow input. Verified against 137 executions on a live instance: the only display key that differs from the pre-fix baseline is workflowInput, on 72 runs, none lost. --- .../logs/execution/logging-session.test.ts | 22 ++ .../execution/trace-secret-projection.test.ts | 54 +++ .../logs/execution/trace-secret-projection.ts | 31 ++ .../lib/logs/execution/trace-store.test.ts | 309 +++++++++++++++++- apps/sim/lib/logs/execution/trace-store.ts | 187 +++++++++-- 5 files changed, 564 insertions(+), 39 deletions(-) diff --git a/apps/sim/lib/logs/execution/logging-session.test.ts b/apps/sim/lib/logs/execution/logging-session.test.ts index eb1616e0869..ba3b420f765 100644 --- a/apps/sim/lib/logs/execution/logging-session.test.ts +++ b/apps/sim/lib/logs/execution/logging-session.test.ts @@ -157,6 +157,28 @@ describe('LoggingSession terminal provenance', () => { ) }) + it('stamps provenance on finalization when no registry was installed explicitly', async () => { + startWorkflowExecutionMock.mockResolvedValue({}) + loadWorkflowStateForExecutionMock.mockResolvedValue({ + blocks: {}, + edges: [], + loops: {}, + parallels: {}, + }) + const session = new LoggingSession('workflow-1', 'execution-implicit-registry', 'webhook') + + await session.start({ userId: 'user-1', workspaceId: 'workspace-1' }) + await session.completeWithError({ error: { message: 'failed' } }) + + expect(completeWorkflowExecutionMock).toHaveBeenCalledWith( + expect.objectContaining({ + executionState: expect.objectContaining({ + resolvedSecretTraceProvenance: expect.objectContaining({ version: 1 }), + }), + }) + ) + }) + it.each(['cancellation', 'pause'] as const)( 'preserves raw execution state on %s finalization', async (finalization) => { diff --git a/apps/sim/lib/logs/execution/trace-secret-projection.test.ts b/apps/sim/lib/logs/execution/trace-secret-projection.test.ts index 835896e36d0..8b9d6f94f1d 100644 --- a/apps/sim/lib/logs/execution/trace-secret-projection.test.ts +++ b/apps/sim/lib/logs/execution/trace-secret-projection.test.ts @@ -17,6 +17,7 @@ vi.mock('@/lib/execution/payloads/store', () => ({ import { enforceTraceSpanSecretInvariant, projectTraceSpansForSecrets, + projectWorkflowBoundarySpansForSecrets, } from '@/lib/logs/execution/trace-secret-projection' import type { TraceSpan } from '@/lib/logs/types' import { @@ -1109,3 +1110,56 @@ describe('projectTraceSpansForSecrets', () => { expect(cursor?.children).toEqual([]) }) }) + +describe('projectWorkflowBoundarySpansForSecrets', () => { + it('retains boundary content when provenance is absent', async () => { + const [result] = await projectWorkflowBoundarySpansForSecrets( + [createSpan({ output: { workflowInput: { channel: 'C123' } } })], + { store: STORE } + ) + + expect(result.output).toEqual({ workflowInput: { channel: 'C123' } }) + }) + + it('retains boundary content when provenance is incomplete', async () => { + const [result] = await projectWorkflowBoundarySpansForSecrets( + [createSpan({ output: { workflowInput: { channel: 'C123' } } })], + { registry: createRegistry([], false), store: STORE } + ) + + expect(result.output).toEqual({ workflowInput: { channel: 'C123' } }) + }) + + it('redacts resolved secrets when provenance is complete', async () => { + const [result] = await projectWorkflowBoundarySpansForSecrets( + [createSpan({ output: { workflowInput: { key: 'raw-secret' } } })], + { + registry: createRegistry([{ plaintext: 'raw-secret', replacement: '{{API_SECRET}}' }]), + store: STORE, + } + ) + + expect(result.output).toEqual({ workflowInput: { key: '{{API_SECRET}}' } }) + }) +}) + +describe('projectWorkflowBoundarySpansForSecrets structural fallback', () => { + it('falls back to structure when the bounded clone exceeds projection limits', async () => { + let source = createSpan({ id: 'depth-150', output: { workflowInput: { channel: 'C123' } } }) + for (let depth = 149; depth >= 0; depth -= 1) { + source = createSpan({ id: `depth-${depth}`, children: [source] }) + } + + const [result] = await projectWorkflowBoundarySpansForSecrets([source], { store: STORE }) + + let projectedDepth = 0 + let cursor: TraceSpan | undefined = result + while (cursor?.children?.[0]) { + expect(cursor).not.toHaveProperty('output') + projectedDepth += 1 + cursor = cursor.children[0] + } + expect(projectedDepth).toBe(100) + expect(cursor).not.toHaveProperty('output') + }) +}) diff --git a/apps/sim/lib/logs/execution/trace-secret-projection.ts b/apps/sim/lib/logs/execution/trace-secret-projection.ts index 9164002569d..90a4f7c4175 100644 --- a/apps/sim/lib/logs/execution/trace-secret-projection.ts +++ b/apps/sim/lib/logs/execution/trace-secret-projection.ts @@ -1520,6 +1520,37 @@ export async function enforceTraceSpanSecretInvariant( } } +/** + * Projects workflow-boundary spans - content captured before the executor + * resolves any secret, so it structurally cannot be a resolved-secret sink. + * Collapsing it to structure whenever provenance is unavailable leaves the log + * with nothing readable at all. With a complete registry this is identical to + * {@link projectTraceSpansForSecrets}; with absent or incomplete provenance the + * content is retained through the same clone used for a complete registry that + * holds no secrets. Anything downstream of block execution - including the + * workflow's final output - must not be routed here. + * + * That clone bounds span STRUCTURE (node count and depth) but not the payload + * inside `output`, which it keeps by reference. A caller must therefore only + * route content here that it is willing to return verbatim - the same exposure + * the no-secrets path already carries for every span it clones. + */ +export async function projectWorkflowBoundarySpansForSecrets( + traceSpans: TraceSpan[], + options: ProjectTraceSpansForSecretsOptions +): Promise { + if (options.registry?.isComplete()) return projectTraceSpansForSecrets(traceSpans, options) + + try { + return cloneTraceSpansForProjection(traceSpans) + } catch { + logger.warn( + 'Workflow-boundary projection exceeded structural limits; retaining structural spans only' + ) + return structuralOnlyTraceSpans(traceSpans) + } +} + /** * Produces the only Secrets-feature-safe representation of execution TraceSpans. * Runtime logs and outputs remain untouched; only schema-defined trace content is copied. diff --git a/apps/sim/lib/logs/execution/trace-store.test.ts b/apps/sim/lib/logs/execution/trace-store.test.ts index 5655bbe877b..997db1d676e 100644 --- a/apps/sim/lib/logs/execution/trace-store.test.ts +++ b/apps/sim/lib/logs/execution/trace-store.test.ts @@ -140,12 +140,14 @@ describe('projectExecutionDataForDisplay', () => { expect(JSON.stringify(displayData)).not.toContain('1234') }) - it('omits log content and keeps only structural traces without trusted provenance', async () => { + it('keeps the trigger payload but omits post-execution content without trusted provenance', async () => { const displayData = await projectExecutionDataForDisplay( { finalOutput: { result: 'unknown-secret' }, - workflowInput: { token: 'unknown-secret' }, + workflowInput: { channel: 'C123' }, completionFailure: 'unknown-secret', + blockInput: { apiKey: 'unknown-secret' }, + blockExecutions: [{ output: 'unknown-secret' }], traceSpans: [ { id: 'span-1', @@ -161,14 +163,315 @@ describe('projectExecutionDataForDisplay', () => { CONTEXT ) + expect(displayData.workflowInput).toEqual({ channel: 'C123' }) expect(displayData).not.toHaveProperty('finalOutput') - expect(displayData).not.toHaveProperty('workflowInput') expect(displayData).not.toHaveProperty('completionFailure') + expect(displayData).not.toHaveProperty('blockInput') + expect(displayData).not.toHaveProperty('blockExecutions') expect(displayData.traceSpans).toEqual([ expect.not.objectContaining({ output: expect.anything() }), ]) }) + it('keeps the trigger payload but omits the final output when provenance is incomplete', async () => { + const displayData = await projectExecutionDataForDisplay( + { + finalOutput: { result: 'unknown-secret' }, + workflowInput: { channel: 'C123' }, + blockInput: { apiKey: 'unknown-secret' }, + executionState: { + resolvedSecretTraceProvenance: { + version: 1, + complete: false, + entries: [], + scope: { userId: 'user-1', workspaceId: 'workspace-1' }, + }, + }, + }, + CONTEXT + ) + + expect(displayData.workflowInput).toEqual({ channel: 'C123' }) + expect(displayData).not.toHaveProperty('finalOutput') + expect(displayData).not.toHaveProperty('blockInput') + }) + + it('recovers workflowInput from the trigger block state on legacy execution data', async () => { + const displayData = await projectExecutionDataForDisplay( + { + executionState: { + blockStates: { + 'trigger-1': { + executed: false, + executionTime: 0, + output: { team_id: 'T1', event: { text: 'hello' }, token: 'slack-verification' }, + }, + 'function-1': { executed: true, executionTime: 12, output: { result: 'ok' } }, + }, + }, + }, + CONTEXT + ) + + expect(displayData.workflowInput).toEqual({ team_id: 'T1', event: { text: 'hello' } }) + }) + + it.each([ + ['incomplete', false], + ['complete', true], + ])( + 'does not recover from block state once provenance is stamped (%s)', + async (_name, complete) => { + const displayData = await projectExecutionDataForDisplay( + { + executionState: { + blockStates: { + 'trigger-1': { + executed: false, + executionTime: 0, + output: { apiKey: 'BLOCK-STATE-MUST-NOT-SURFACE' }, + }, + }, + resolvedSecretTraceProvenance: { + version: 1, + complete, + entries: [], + scope: { userId: 'user-1', workspaceId: 'workspace-1' }, + }, + }, + }, + CONTEXT + ) + + expect(displayData).not.toHaveProperty('workflowInput') + expect(JSON.stringify(displayData)).not.toContain('BLOCK-STATE-MUST-NOT-SURFACE') + } + ) + + it.each([ + ['empty block states', { blockStates: {} }], + ['no block states key', { executedBlocks: [] }], + ['non-record block states', { blockStates: [] }], + ])( + 'does not attempt recovery for a run that never reached the executor (%s)', + async (_name, executionState) => { + const displayData = await projectExecutionDataForDisplay({ executionState }, CONTEXT) + + expect(displayData).not.toHaveProperty('workflowInput') + } + ) + + /** + * The shape a run that failed before the executor started leaves behind: no + * `workflowInput` and no `executionState` at all. + */ + it('renders nothing for a failed run that persisted neither input nor state', async () => { + const displayData = await projectExecutionDataForDisplay( + { finalOutput: { error: 'failed before blocks' }, completionFailure: 'boom' }, + CONTEXT + ) + + expect(displayData).not.toHaveProperty('workflowInput') + expect(displayData).not.toHaveProperty('finalOutput') + expect(displayData).not.toHaveProperty('completionFailure') + }) + + /** + * A nested run receives `workflowInput` from its parent's already-resolved + * block outputs (workflow-handler.ts passes `workflowInput: childWorkflowInput`), + * so the boundary premise does not hold and it must fail closed. + */ + it.each([['workflow'], ['custom_block']])( + 'gates workflowInput for a nested %s execution without trusted provenance', + async (triggerType) => { + const displayData = await projectExecutionDataForDisplay( + { + trigger: { type: triggerType, source: triggerType }, + workflowInput: { apiKey: 'PARENT-RESOLVED-SECRET' }, + executionState: { + blockStates: { + 'trigger-1': { executed: false, executionTime: 0, output: { a: 1 } }, + }, + }, + }, + CONTEXT + ) + + expect(displayData).not.toHaveProperty('workflowInput') + expect(JSON.stringify(displayData)).not.toContain('PARENT-RESOLVED-SECRET') + } + ) + + it('still redacts and shows a nested execution input when provenance is complete', async () => { + const displayData = await projectExecutionDataForDisplay( + { + trigger: { type: 'workflow', source: 'workflow' }, + workflowInput: { apiKey: 1234 }, + executionState: { + resolvedSecretTraceProvenance: { + version: 1, + complete: true, + entries: [{ name: 'OPENAI_API_KEY', encryptedValue: 'ciphertext' }], + scope: { userId: 'user-1', workspaceId: 'workspace-1' }, + }, + }, + }, + CONTEXT + ) + + expect(displayData.workflowInput).toEqual({ apiKey: '{{OPENAI_API_KEY}}' }) + }) + + it('keeps the boundary exemption for a provider-named webhook trigger', async () => { + const displayData = await projectExecutionDataForDisplay( + { + trigger: { type: 'zoho_desk', source: 'zoho_desk' }, + workflowInput: { eventType: 'Ticket_Comment_Add' }, + }, + CONTEXT + ) + + expect(displayData.workflowInput).toEqual({ eventType: 'Ticket_Comment_Add' }) + }) + + /** + * A human-in-the-loop pause writes a placeholder block state with the same + * never-executed / zero-duration / populated-output shape as the trigger, so + * a paused legacy run has two matches. Guessing would show the resume + * capability URL labelled as the workflow input. + */ + it('refuses recovery when a resume placeholder shares the trigger shape', async () => { + const displayData = await projectExecutionDataForDisplay( + { + executionState: { + blockStates: { + 'hitl-1': { + executed: false, + executionTime: 0, + output: { + url: 'https://sim.example/resume/CAPABILITY-URL', + resumeEndpoint: 'https://sim.example/api/resume/CAPABILITY-URL', + }, + }, + 'trigger-1': { executed: false, executionTime: 0, output: { channel: 'C123' } }, + }, + }, + }, + CONTEXT + ) + + expect(displayData).not.toHaveProperty('workflowInput') + expect(JSON.stringify(displayData)).not.toContain('CAPABILITY-URL') + }) + + /** + * `buildApiOrInputOutput` writes `{...finalInput, input: {...finalInput}}` for + * an object input, so the original workflowInput was the FLAT object. Keep + * that shape instead of collapsing to the redundant nested clone. + */ + it('keeps the flat shape when the nested input is a clone of its siblings', async () => { + const displayData = await projectExecutionDataForDisplay( + { + executionState: { + blockStates: { + 'trigger-1': { + executed: false, + executionTime: 0, + output: { + leadId: 'lead-42', + source: 'crm', + input: { leadId: 'lead-42', source: 'crm' }, + }, + }, + }, + }, + }, + CONTEXT + ) + + expect(displayData.workflowInput).toEqual({ + leadId: 'lead-42', + source: 'crm', + input: { leadId: 'lead-42', source: 'crm' }, + }) + }) + + it('recovers a non-record trigger payload without narrowing it', async () => { + const displayData = await projectExecutionDataForDisplay( + { + executionState: { + blockStates: { + 'trigger-1': { executed: false, executionTime: 0, output: 'run calendar test' }, + }, + }, + }, + CONTEXT + ) + + expect(displayData.workflowInput).toBe('run calendar test') + }) + + it('does not recover over a persisted null workflowInput', async () => { + const displayData = await projectExecutionDataForDisplay( + { + workflowInput: null, + executionState: { + blockStates: { + 'trigger-1': { executed: false, executionTime: 0, output: { channel: 'C123' } }, + }, + }, + }, + CONTEXT + ) + + expect(displayData.workflowInput).toBeNull() + }) + + it('narrows a recovered nested input payload to the shape workflowInput held', async () => { + const displayData = await projectExecutionDataForDisplay( + { + executionState: { + blockStates: { + 'trigger-1': { + executed: false, + executionTime: 0, + output: { + input: { brandInfo: 'acme', adUnitName: 'derived' }, + brandInfo: 'acme', + referenceImageUrl: 'https://example.com/a.png', + }, + }, + }, + }, + }, + CONTEXT + ) + + expect(displayData.workflowInput).toEqual({ + input: { brandInfo: 'acme', adUnitName: 'derived' }, + }) + }) + + it('leaves a persisted workflowInput untouched by legacy recovery', async () => { + const displayData = await projectExecutionDataForDisplay( + { + workflowInput: { token: 'kept-as-persisted', input: { a: 1 }, sibling: 2 }, + executionState: { + blockStates: { + 'trigger-1': { executed: false, executionTime: 0, output: { other: 'ignored' } }, + }, + }, + }, + CONTEXT + ) + + expect(displayData.workflowInput).toEqual({ + token: 'kept-as-persisted', + input: { a: 1 }, + sibling: 2, + }) + }) + it('preserves direct literals when trusted provenance has no activated secrets', async () => { const displayData = await projectExecutionDataForDisplay( { diff --git a/apps/sim/lib/logs/execution/trace-store.ts b/apps/sim/lib/logs/execution/trace-store.ts index f429bade600..b8c13cc107b 100644 --- a/apps/sim/lib/logs/execution/trace-store.ts +++ b/apps/sim/lib/logs/execution/trace-store.ts @@ -3,7 +3,14 @@ import { toError } from '@sim/utils/errors' import { omit } from '@sim/utils/object' import { isLargeValueRef } from '@/lib/execution/payloads/large-value-ref' import { materializeLargeValueRef, storeLargeValue } from '@/lib/execution/payloads/store' -import { projectTraceSpansForSecrets } from '@/lib/logs/execution/trace-secret-projection' +import { + hasPersistedBlockStates, + recoverLegacyWorkflowInputForDisplay, +} from '@/lib/logs/execution/legacy-workflow-input' +import { + projectTraceSpansForSecrets, + projectWorkflowBoundarySpansForSecrets, +} from '@/lib/logs/execution/trace-secret-projection' import type { TraceSpan } from '@/lib/logs/types' import { isResolvedSecretTraceProvenanceV1, @@ -193,9 +200,21 @@ export async function materializeExecutionData( } } -const LOG_DISPLAY_CONTENT_KEYS = [ +/** + * Workflow-boundary content: the inbound trigger payload, captured before any + * secret is resolved. It structurally cannot hold a Sim-resolved secret, so it + * stays readable when provenance is missing. `finalOutput` is deliberately not + * here - it sits downstream of every block and can carry a resolved secret, so + * it fails closed with the rest of the execution content. + */ +const LOG_DISPLAY_BOUNDARY_KEYS = ['workflowInput'] as const + +/** + * Content that can hold a resolved secret. Only rendered when the run carries + * provable provenance for the secrets it resolved. + */ +const LOG_DISPLAY_GATED_KEYS = [ 'finalOutput', - 'workflowInput', 'blockInput', 'blockExecutions', 'error', @@ -204,7 +223,63 @@ const LOG_DISPLAY_CONTENT_KEYS = [ 'message', ] as const +const LOG_DISPLAY_CONTENT_KEYS = [...LOG_DISPLAY_BOUNDARY_KEYS, ...LOG_DISPLAY_GATED_KEYS] as const + const LOG_DISPLAY_PROJECTION_SPAN_ID = 'secret-safe-log-display-projection' +const LOG_DISPLAY_BOUNDARY_SPAN_ID = 'secret-safe-log-display-boundary' + +/** + * Trigger types whose `workflowInput` is NOT an inbound payload. + * + * A nested execution is handed its input by the PARENT run: + * `workflow-handler.ts` passes `workflowInput: childWorkflowInput`, assembled + * from `inputs.inputMapping` / `inputs.input` - already-resolved parent block + * outputs. So for these runs the boundary premise ("captured before any secret + * was resolved") does not hold, and `workflowInput` stays gated with the rest + * of the execution content. + * + * Deliberately a denylist, not an allowlist: webhook runs record the PROVIDER + * as the trigger type (`zoho_desk`, `slack`, ...), so an allowlist of known + * inbound types would fail closed on the exact population this exemption + * exists to serve. The nested set is small and enumerated in-repo + * (`CORE_TRIGGER_TYPES`), so a denylist is the bounded side. + */ +const NESTED_EXECUTION_TRIGGER_TYPES = new Set(['workflow', 'custom_block']) + +function isNestedExecution(executionData: Record): boolean { + const trigger = executionData.trigger + if (!trigger || typeof trigger !== 'object' || Array.isArray(trigger)) return false + const type = (trigger as Record).type + return typeof type === 'string' && NESTED_EXECUTION_TRIGGER_TYPES.has(type) +} + +/** + * Wraps display content in a span so it passes through the same secret + * projection the executor's trace spans do. + */ +function createDisplayEnvelopeSpan(id: string, envelope: Record): TraceSpan { + const now = new Date().toISOString() + return { + id, + name: 'Log Display Projection', + type: 'display', + duration: 0, + startTime: now, + endTime: now, + output: envelope, + } +} + +function copyProjectedEnvelope( + projected: unknown, + keys: readonly string[], + target: Record +): void { + if (!projected || typeof projected !== 'object') return + for (const key of keys) { + if (Object.hasOwn(projected, key)) target[key] = (projected as Record)[key] + } +} /** * Materializes trusted execution data and returns its log-facing projection. @@ -220,8 +295,10 @@ export async function materializeExecutionDataForDisplay( /** * Projects execution-log content with the encrypted provenance saved by the - * trusted executor. Missing or malformed provenance deliberately yields a - * structural-only log instead of returning content that cannot be proven safe. + * trusted executor. Missing or malformed provenance deliberately yields + * structural-only content instead of returning content that cannot be proven + * safe. The one exception is `workflowInput`, captured before any secret is + * resolved - see {@link projectWorkflowBoundarySpansForSecrets}. */ export async function projectExecutionDataForDisplay( executionData: Record, @@ -241,35 +318,71 @@ export async function projectExecutionDataForDisplay( await registry.importProvenance(provenance, { trusted: true }) } - const envelope: Record = {} - for (const key of LOG_DISPLAY_CONTENT_KEYS) { - if (Object.hasOwn(executionData, key)) envelope[key] = executionData[key] + /** + * A nested run's input came from its parent's resolved outputs, so it gets no + * boundary exemption - every content key is gated for it. + */ + const nested = isNestedExecution(executionData) + const gatedKeys: readonly string[] = nested ? LOG_DISPLAY_CONTENT_KEYS : LOG_DISPLAY_GATED_KEYS + const boundaryKeys: readonly string[] = nested ? [] : LOG_DISPLAY_BOUNDARY_KEYS + + const gatedEnvelope: Record = {} + for (const key of gatedKeys) { + if (Object.hasOwn(executionData, key)) gatedEnvelope[key] = executionData[key] } - const now = new Date().toISOString() - const syntheticSpan: TraceSpan = { - id: LOG_DISPLAY_PROJECTION_SPAN_ID, - name: 'Log Display Projection', - type: 'display', - duration: 0, - startTime: now, - endTime: now, - output: envelope, + const boundaryEnvelope: Record = {} + for (const key of boundaryKeys) { + if (Object.hasOwn(executionData, key)) boundaryEnvelope[key] = executionData[key] + } + /** + * Legacy recovery sources from block state, which is gated content, so it is + * restricted to executions that predate provenance stamping. Every terminal + * completion path now stamps `resolvedSecretTraceProvenance` - an incomplete + * registry still exports a present `{complete: false, entries: []}` - so an + * absent key identifies a pre-stamping run, without dating the row. Runs + * written after stamping keep only their persisted `workflowInput`. + * + * The block-state requirement closes the one gap in that signal: a run that + * fails before the registry is installed also lands with no key. Such a run + * never reached the executor, so requiring block states excludes it here + * rather than relying on the recovery happening to find nothing. + */ + if ( + !nested && + provenance === undefined && + boundaryEnvelope.workflowInput === undefined && + hasPersistedBlockStates(executionData) + ) { + const recovered = recoverLegacyWorkflowInputForDisplay(executionData) + if (recovered !== undefined) boundaryEnvelope.workflowInput = recovered + } + + const store = { + workspaceId: context.workspaceId ?? undefined, + workflowId: context.workflowId ?? undefined, + executionId: context.executionId, + userId: context.userId, + trackReference: false, } const sourceTraceSpans = Array.isArray(executionData.traceSpans) ? (executionData.traceSpans as TraceSpan[]) : [] - const projectedSpans = await projectTraceSpansForSecrets([syntheticSpan, ...sourceTraceSpans], { - registry, - allowLargeValueWrites: false, - store: { - workspaceId: context.workspaceId ?? undefined, - workflowId: context.workflowId ?? undefined, - executionId: context.executionId, - userId: context.userId, - trackReference: false, - }, - }) + const [projectedSpans, projectedBoundarySpans] = await Promise.all([ + projectTraceSpansForSecrets( + [ + createDisplayEnvelopeSpan(LOG_DISPLAY_PROJECTION_SPAN_ID, gatedEnvelope), + ...sourceTraceSpans, + ], + { registry, allowLargeValueWrites: false, store } + ), + Object.keys(boundaryEnvelope).length === 0 + ? [] + : projectWorkflowBoundarySpansForSecrets( + [createDisplayEnvelopeSpan(LOG_DISPLAY_BOUNDARY_SPAN_ID, boundaryEnvelope)], + { registry, allowLargeValueWrites: false, store } + ), + ]) const displayData = omit(executionData, [ ...LOG_DISPLAY_CONTENT_KEYS, @@ -277,14 +390,16 @@ export async function projectExecutionDataForDisplay( 'traceSpans', ]) as Record - const projectedEnvelope = projectedSpans.find( - (span) => span.id === LOG_DISPLAY_PROJECTION_SPAN_ID - )?.output - if (projectedEnvelope) { - for (const key of LOG_DISPLAY_CONTENT_KEYS) { - if (Object.hasOwn(projectedEnvelope, key)) displayData[key] = projectedEnvelope[key] - } - } + copyProjectedEnvelope( + projectedSpans.find((span) => span.id === LOG_DISPLAY_PROJECTION_SPAN_ID)?.output, + gatedKeys, + displayData + ) + copyProjectedEnvelope( + projectedBoundarySpans.find((span) => span.id === LOG_DISPLAY_BOUNDARY_SPAN_ID)?.output, + boundaryKeys, + displayData + ) if (Array.isArray(executionData.traceSpans)) { displayData.traceSpans = projectedSpans.filter( From 4717819d600aee36d42cb029a19dd050fa2116b6 Mon Sep 17 00:00:00 2001 From: Marcus Chandra Date: Tue, 4 Aug 2026 20:22:38 -0700 Subject: [PATCH 3/3] fix(logs): gate workflow input inherited from a prior execution Greptile review: the boundary exemption assumed a non-nested trigger implies an inbound payload. A re-run with inputFromExecutionId breaks that. It copies the source run's workflowInput verbatim, and the destination presents whatever trigger type its caller asked for, so a value resolved inside a custom_block parent could arrive in a manual run and be exempted. The destination's own provenance cannot describe the source's secret resolution, so no matcher covers it. The read path could not tell an inherited input from a fresh one, so the re-run now records its source: LoggingSession.setInputSourceExecutionId stamps it into the existing trigger data channel, and the display projection withholds the exemption whenever it is present. Nested runs and inherited-input runs share one predicate - neither is a pre-resolution inbound payload. Adds setInputSourceExecutionId to the shared @sim/testing LoggingSession mock so the execute-route suites exercise the real call. --- .../app/api/workflows/[id]/execute/route.ts | 9 +++++ .../sim/lib/logs/execution/logging-session.ts | 30 ++++++++++++++-- .../lib/logs/execution/trace-store.test.ts | 34 ++++++++++++++++++ apps/sim/lib/logs/execution/trace-store.ts | 35 +++++++++++++++---- .../testing/src/mocks/logging-session.mock.ts | 2 ++ 5 files changed, 101 insertions(+), 9 deletions(-) diff --git a/apps/sim/app/api/workflows/[id]/execute/route.ts b/apps/sim/app/api/workflows/[id]/execute/route.ts index 466aab44d48..6671e4d50e0 100644 --- a/apps/sim/app/api/workflows/[id]/execute/route.ts +++ b/apps/sim/app/api/workflows/[id]/execute/route.ts @@ -1174,6 +1174,15 @@ async function handleExecutePost( loggingTriggerType, requestId ) + /** + * Reusing a prior run's input copies that run's exposure with it, and this + * run's own provenance cannot describe a secret the source resolved. Record + * the source so the log display projection withholds the workflow-boundary + * exemption for this run. + */ + if (inputFromExecutionId) { + loggingSession.setInputSourceExecutionId(inputFromExecutionId) + } if (copilotToolCallId) { loggingSession.setTrustedExecutionCorrelation({ executionId, diff --git a/apps/sim/lib/logs/execution/logging-session.ts b/apps/sim/lib/logs/execution/logging-session.ts index bf96ef4022d..840fd90d121 100644 --- a/apps/sim/lib/logs/execution/logging-session.ts +++ b/apps/sim/lib/logs/execution/logging-session.ts @@ -201,6 +201,7 @@ export class LoggingSession { private postExecutionPromise: Promise | null = null private resolvedSecretTraceRegistry?: ResolvedSecretTraceRegistry private traceLargeValueAccess: LargeValueStoreContext = {} + private inputSourceExecutionId?: string constructor( workflowId: string, @@ -226,6 +227,20 @@ export class LoggingSession { this.resolvedSecretTraceRegistry = registry } + /** + * Records that this run's input was copied from a prior execution + * (`inputFromExecutionId`) rather than arriving with the trigger. + * + * The log display projection reads this to withhold the workflow-boundary + * exemption. An inherited input carries the SOURCE run's exposure - a value + * resolved inside a custom-block parent stays plaintext through the copy - + * and this run's own provenance cannot describe that resolution, so it must + * not be treated as a pre-resolution inbound payload. + */ + setInputSourceExecutionId(sourceExecutionId: string): void { + this.inputSourceExecutionId = sourceExecutionId + } + /** Adds server-validated lifecycle correlation without exposing it to executor metadata. */ setTrustedExecutionCorrelation( correlation: NonNullable['correlation']> @@ -626,9 +641,18 @@ export class LoggingSession { } try { - const effectiveTriggerData = this.trustedExecutionCorrelation - ? { ...triggerData, correlation: this.trustedExecutionCorrelation } - : triggerData + const derivedTriggerData = { + ...(this.trustedExecutionCorrelation + ? { correlation: this.trustedExecutionCorrelation } + : {}), + ...(this.inputSourceExecutionId + ? { inputSourceExecutionId: this.inputSourceExecutionId } + : {}), + } + const effectiveTriggerData = + Object.keys(derivedTriggerData).length > 0 || triggerData + ? { ...triggerData, ...derivedTriggerData } + : undefined this.trigger = createTriggerObject(this.triggerType, effectiveTriggerData) this.correlation = effectiveTriggerData?.correlation this.environment = createEnvironmentObject( diff --git a/apps/sim/lib/logs/execution/trace-store.test.ts b/apps/sim/lib/logs/execution/trace-store.test.ts index 997db1d676e..89980086804 100644 --- a/apps/sim/lib/logs/execution/trace-store.test.ts +++ b/apps/sim/lib/logs/execution/trace-store.test.ts @@ -302,6 +302,40 @@ describe('projectExecutionDataForDisplay', () => { } ) + /** + * A re-run presents whatever trigger type its caller asked for, so the value + * copied out of a nested run would otherwise look inbound. The stamped source + * id keeps it gated. + */ + it('gates workflowInput inherited from a prior execution regardless of trigger type', async () => { + const displayData = await projectExecutionDataForDisplay( + { + trigger: { + type: 'manual', + source: 'manual', + data: { inputSourceExecutionId: 'execution-source-1' }, + }, + workflowInput: { apiKey: 'INHERITED-FROM-CUSTOM-BLOCK-PARENT' }, + }, + CONTEXT + ) + + expect(displayData).not.toHaveProperty('workflowInput') + expect(JSON.stringify(displayData)).not.toContain('INHERITED-FROM-CUSTOM-BLOCK-PARENT') + }) + + it('keeps the exemption for a manual run whose trigger data carries no input source', async () => { + const displayData = await projectExecutionDataForDisplay( + { + trigger: { type: 'manual', source: 'manual', data: { correlation: { a: 1 } } }, + workflowInput: { question: 'typed by the user' }, + }, + CONTEXT + ) + + expect(displayData.workflowInput).toEqual({ question: 'typed by the user' }) + }) + it('still redacts and shows a nested execution input when provenance is complete', async () => { const displayData = await projectExecutionDataForDisplay( { diff --git a/apps/sim/lib/logs/execution/trace-store.ts b/apps/sim/lib/logs/execution/trace-store.ts index b8c13cc107b..1da762be0eb 100644 --- a/apps/sim/lib/logs/execution/trace-store.ts +++ b/apps/sim/lib/logs/execution/trace-store.ts @@ -246,13 +246,35 @@ const LOG_DISPLAY_BOUNDARY_SPAN_ID = 'secret-safe-log-display-boundary' */ const NESTED_EXECUTION_TRIGGER_TYPES = new Set(['workflow', 'custom_block']) -function isNestedExecution(executionData: Record): boolean { +function triggerRecord( + executionData: Record +): Record | undefined { const trigger = executionData.trigger - if (!trigger || typeof trigger !== 'object' || Array.isArray(trigger)) return false - const type = (trigger as Record).type + if (!trigger || typeof trigger !== 'object' || Array.isArray(trigger)) return undefined + return trigger as Record +} + +function isNestedExecution(executionData: Record): boolean { + const type = triggerRecord(executionData)?.type return typeof type === 'string' && NESTED_EXECUTION_TRIGGER_TYPES.has(type) } +/** + * Whether this run's input was copied from a prior execution + * (`inputFromExecutionId`), stamped by `LoggingSession.setInputSourceExecutionId`. + * + * A re-run presents whatever trigger type its caller asked for, so the nested + * check alone cannot see that the value originated in a nested run. Without + * this, a secret resolved inside a custom-block parent could be copied into a + * `manual` run and exempted. The inherited value carries the SOURCE run's + * exposure, which this run's provenance cannot describe, so it stays gated. + */ +function hasInheritedInput(executionData: Record): boolean { + const data = triggerRecord(executionData)?.data + if (!data || typeof data !== 'object' || Array.isArray(data)) return false + return typeof (data as Record).inputSourceExecutionId === 'string' +} + /** * Wraps display content in a span so it passes through the same secret * projection the executor's trace spans do. @@ -319,10 +341,11 @@ export async function projectExecutionDataForDisplay( } /** - * A nested run's input came from its parent's resolved outputs, so it gets no - * boundary exemption - every content key is gated for it. + * A nested run's input came from its parent's resolved outputs, and a re-run's + * came from another execution. Neither is a pre-resolution inbound payload, so + * both forgo the boundary exemption - every content key is gated for them. */ - const nested = isNestedExecution(executionData) + const nested = isNestedExecution(executionData) || hasInheritedInput(executionData) const gatedKeys: readonly string[] = nested ? LOG_DISPLAY_CONTENT_KEYS : LOG_DISPLAY_GATED_KEYS const boundaryKeys: readonly string[] = nested ? [] : LOG_DISPLAY_BOUNDARY_KEYS diff --git a/packages/testing/src/mocks/logging-session.mock.ts b/packages/testing/src/mocks/logging-session.mock.ts index 3cefc0eb2e0..bfc0865515b 100644 --- a/packages/testing/src/mocks/logging-session.mock.ts +++ b/packages/testing/src/mocks/logging-session.mock.ts @@ -26,6 +26,7 @@ export const loggingSessionMockFns = { mockWaitForCompletion: vi.fn().mockResolvedValue(undefined), mockWaitForPostExecution: vi.fn().mockResolvedValue(undefined), mockSetTrustedExecutionCorrelation: vi.fn(), + mockSetInputSourceExecutionId: vi.fn(), mockProjectBlockLogsForDisplay: vi.fn(async (logs: unknown) => logs), mockProjectDisplayContent: vi.fn(async (content: unknown) => content), mockProjectLiveDisplayText: vi.fn(async (_field: string, value: string) => ({ value })), @@ -53,6 +54,7 @@ function buildLoggingSessionInstance() { waitForCompletion: loggingSessionMockFns.mockWaitForCompletion, waitForPostExecution: loggingSessionMockFns.mockWaitForPostExecution, setTrustedExecutionCorrelation: loggingSessionMockFns.mockSetTrustedExecutionCorrelation, + setInputSourceExecutionId: loggingSessionMockFns.mockSetInputSourceExecutionId, projectBlockLogsForDisplay: loggingSessionMockFns.mockProjectBlockLogsForDisplay, projectDisplayContent: loggingSessionMockFns.mockProjectDisplayContent, projectLiveDisplayText: loggingSessionMockFns.mockProjectLiveDisplayText,