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/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/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/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-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..89980086804 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,349 @@ 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') + } + ) + + /** + * 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( + { + 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..1da762be0eb 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,85 @@ 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 triggerRecord( + executionData: Record +): Record | undefined { + const trigger = executionData.trigger + 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. + */ +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 +317,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 +340,72 @@ 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, 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) || hasInheritedInput(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 +413,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( 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 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,