Skip to content

Commit 3fe2f4f

Browse files
authored
fix(provenance): report why a projection was refused, at the point of refusal (#6483)
* fix(provenance): report why a projection was refused, at the point of refusal A refusal fails closed and reaches the user as one fixed sentence. The guard that caused it may have tripped many frames — or a whole process — earlier, and the incompleteness latch is one-way, so by then the causing call has long returned. #6478 recorded the reason when the guard tripped, but marking early-returns once a registry is already incomplete, so a run that inherits an incomplete registry refused with nothing recorded anywhere. That is the case production actually hits. Retain reasons on the registry and report them where the refusal happens. The reason is recorded before the already-incomplete return so a causal chain accumulates, and before the silence checks so a by-design origin that logs nothing when marked is still nameable at refusal. Propagation inherits through markIncomplete's source argument, and copying incomplete input paths carries their reasons, so a fork cannot latch without its cause. Route all 68 refusal sites through one choke point that logs boundary, cause, input path and workspace before throwing. It returns never, so callers still narrow; messages and thrown types are unchanged at every site. Records carry a cause discriminator, since a latched registry and a caller-side cross-check of the projection's own output both arrive here and only the former has reasons. No behaviour change. * fix(provenance): stop registry-less refusals deduplicating across requests A refusal with no registry aborts the request rather than iterating, so it reaches the reporter at most once per request. Remembering it for the process silenced every later request, including the one being investigated. * improvement(provenance): inherit copied-path reasons once per copy Semantics are unchanged — reasons are still inherited only when at least one incomplete path was actually copied — but the source set is walked once rather than once per copied path.
1 parent 4b2412b commit 3fe2f4f

15 files changed

Lines changed: 918 additions & 93 deletions

File tree

apps/sim/app/api/mcp/serve/[serverId]/route.ts

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,7 @@ import {
7373
import { getMeaningfulWorkflowDescription } from '@/lib/mcp/workflow-tool-schema'
7474
import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils'
7575
import { projectResolvedSecretModelContent } from '@/executor/utils/resolved-secret-content-projection'
76+
import { refuseResolvedSecretProjection } from '@/executor/utils/resolved-secret-projection-refusal'
7677
import { ResolvedSecretTraceRegistry } from '@/executor/utils/resolved-secret-trace-registry'
7778

7879
const logger = createLogger('WorkflowMcpServeAPI')
@@ -293,7 +294,13 @@ async function projectWorkflowMcpModelContent(
293294
throw new Error('MCP workflow execution provenance is invalid')
294295
}
295296
const projection = projectResolvedSecretModelContent(value, registry)
296-
if (!projection.safe) throw new Error('MCP workflow output could not be safely projected')
297+
if (!projection.safe) {
298+
refuseResolvedSecretProjection({
299+
site: 'mcpServe.workflowOutput',
300+
message: 'MCP workflow output could not be safely projected',
301+
registry,
302+
})
303+
}
297304
return projection.value
298305
}
299306

@@ -939,7 +946,10 @@ async function handleToolsCall(
939946
})
940947
: rawErrorMessage
941948
if (typeof errorMessage !== 'string') {
942-
throw new Error('MCP workflow execution error could not be safely projected')
949+
refuseResolvedSecretProjection({
950+
site: 'mcpServe.executionError',
951+
message: 'MCP workflow execution error could not be safely projected',
952+
})
943953
}
944954
const status = getWorkflowErrorStatus(response.status)
945955
const responseHeaders: Record<string, string> = {}

apps/sim/executor/handlers/agent/agent-handler.ts

Lines changed: 255 additions & 44 deletions
Large diffs are not rendered by default.

apps/sim/executor/handlers/agent/memory.ts

Lines changed: 78 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -24,11 +24,14 @@ import {
2424
projectResolvedSecretModelContent,
2525
projectResolvedSecretModelJsonStrings,
2626
} from '@/executor/utils/resolved-secret-content-projection'
27+
import { refuseResolvedSecretProjection } from '@/executor/utils/resolved-secret-projection-refusal'
2728
import { ResolvedSecretTraceRegistry } from '@/executor/utils/resolved-secret-trace-registry'
2829
import { PROVIDER_DEFINITIONS } from '@/providers/models'
2930

3031
const logger = createLogger('Memory')
3132

33+
const MEMORY_CONTENT_REFUSAL = 'Memory content could not be safely projected'
34+
3235
export class Memory {
3336
async fetchMemoryMessages(ctx: ExecutionContext, inputs: AgentInputs): Promise<Message[]> {
3437
if (!inputs.memoryType || inputs.memoryType === 'none') {
@@ -82,7 +85,12 @@ export class Memory {
8285
messages
8386
)))
8487
) {
85-
throw new Error('Memory content could not be safely projected')
88+
refuseResolvedSecretProjection({
89+
site: 'memory.storedProvenanceImport',
90+
message: MEMORY_CONTENT_REFUSAL,
91+
registry: ctx.resolvedSecretTraceRegistry,
92+
inputPath: 'messages',
93+
})
8694
}
8795

8896
return Promise.all(
@@ -95,7 +103,12 @@ export class Memory {
95103
ctx.resolvedSecretTraceRegistry?.exportProvenance().scope
96104
)
97105
if (!(await importDurableSecretProvenance(modelRegistry, messageProvenance, message))) {
98-
throw new Error('Memory content could not be safely projected')
106+
refuseResolvedSecretProjection({
107+
site: 'memory.messageProvenanceImport',
108+
message: MEMORY_CONTENT_REFUSAL,
109+
registry: modelRegistry,
110+
inputPath: 'messages',
111+
})
99112
}
100113
return this.projectMessageForModel(modelRegistry, message)
101114
})
@@ -213,12 +226,21 @@ export class Memory {
213226
}
214227

215228
private projectMessageForModel(registry: ResolvedSecretTraceRegistry, message: Message): Message {
216-
const functionArguments = this.readFunctionCallArguments(message.function_call)
229+
const functionArguments = this.readFunctionCallArguments(
230+
message.function_call,
231+
registry,
232+
'function_call'
233+
)
217234
const toolArguments = message.tool_calls?.map((toolCall) => {
218235
if (!isPlainRecord(toolCall)) {
219-
throw new Error('Memory content could not be safely projected')
236+
refuseResolvedSecretProjection({
237+
site: 'memory.toolCallShape',
238+
message: MEMORY_CONTENT_REFUSAL,
239+
registry,
240+
inputPath: 'tool_calls',
241+
})
220242
}
221-
return this.readFunctionCallArguments(toolCall.function)
243+
return this.readFunctionCallArguments(toolCall.function, registry, 'tool_calls.function')
222244
})
223245
const contentProjection = projectResolvedSecretModelContent(message.content, registry)
224246
const argumentProjection = projectResolvedSecretModelJsonStrings(
@@ -232,7 +254,12 @@ export class Memory {
232254
!Array.isArray(argumentProjection.value) ||
233255
argumentProjection.value.length !== 1 + (toolArguments?.length ?? 0)
234256
) {
235-
throw new Error('Memory content could not be safely projected')
257+
refuseResolvedSecretProjection({
258+
site: 'memory.messageContentProjection',
259+
message: MEMORY_CONTENT_REFUSAL,
260+
registry,
261+
inputPath: 'content,function_call,tool_calls',
262+
})
236263
}
237264

238265
const content = contentProjection.value
@@ -241,25 +268,45 @@ export class Memory {
241268
(functionArguments !== undefined && typeof projectedFunctionArguments !== 'string') ||
242269
(functionArguments === undefined && projectedFunctionArguments !== undefined)
243270
) {
244-
throw new Error('Memory content could not be safely projected')
271+
refuseResolvedSecretProjection({
272+
site: 'memory.functionCallArgumentProjection',
273+
message: MEMORY_CONTENT_REFUSAL,
274+
registry,
275+
inputPath: 'function_call.arguments',
276+
})
245277
}
246278
if (
247279
(toolArguments !== undefined && projectedToolArguments.length !== toolArguments.length) ||
248280
(toolArguments === undefined && projectedToolArguments.length !== 0)
249281
) {
250-
throw new Error('Memory content could not be safely projected')
282+
refuseResolvedSecretProjection({
283+
site: 'memory.toolCallArgumentArity',
284+
message: MEMORY_CONTENT_REFUSAL,
285+
registry,
286+
inputPath: 'tool_calls.function.arguments',
287+
})
251288
}
252289

253290
const projectedToolCalls = message.tool_calls?.map((toolCall, index) => {
254291
const argument = (projectedToolArguments as unknown[])[index]
255292
const originalFunction = isPlainRecord(toolCall) ? toolCall.function : undefined
256293
if (originalFunction === undefined || originalFunction === null) return toolCall
257294
if (!isPlainRecord(originalFunction)) {
258-
throw new Error('Memory content could not be safely projected')
295+
refuseResolvedSecretProjection({
296+
site: 'memory.toolCallFunctionShape',
297+
message: MEMORY_CONTENT_REFUSAL,
298+
registry,
299+
inputPath: 'tool_calls.function',
300+
})
259301
}
260302
if (!Object.hasOwn(originalFunction, 'arguments')) return toolCall
261303
if (typeof argument !== 'string') {
262-
throw new Error('Memory content could not be safely projected')
304+
refuseResolvedSecretProjection({
305+
site: 'memory.toolCallArgumentType',
306+
message: MEMORY_CONTENT_REFUSAL,
307+
registry,
308+
inputPath: 'tool_calls.function.arguments',
309+
})
263310
}
264311
return {
265312
...toolCall,
@@ -283,14 +330,32 @@ export class Memory {
283330
}
284331
}
285332

286-
private readFunctionCallArguments(functionCall: unknown): string | undefined {
333+
/**
334+
* Takes the registry and path from its caller so a refusal here reports the run that failed.
335+
* Without them the refusal would deduplicate process-wide and name no cause.
336+
*/
337+
private readFunctionCallArguments(
338+
functionCall: unknown,
339+
registry: ResolvedSecretTraceRegistry,
340+
inputPath: string
341+
): string | undefined {
287342
if (functionCall === undefined || functionCall === null) return undefined
288343
if (!isPlainRecord(functionCall)) {
289-
throw new Error('Memory content could not be safely projected')
344+
refuseResolvedSecretProjection({
345+
site: 'memory.functionCallShape',
346+
message: MEMORY_CONTENT_REFUSAL,
347+
registry,
348+
inputPath,
349+
})
290350
}
291351
if (!Object.hasOwn(functionCall, 'arguments')) return undefined
292352
if (typeof functionCall.arguments !== 'string') {
293-
throw new Error('Memory content could not be safely projected')
353+
refuseResolvedSecretProjection({
354+
site: 'memory.functionCallArgumentType',
355+
message: MEMORY_CONTENT_REFUSAL,
356+
registry,
357+
inputPath,
358+
})
294359
}
295360
return functionCall.arguments
296361
}

apps/sim/executor/handlers/evaluator/evaluator-handler.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import type { BlockHandler, ExecutionContext } from '@/executor/types'
1818
import { buildAPIUrl, buildAuthHeaders, extractAPIErrorMessage } from '@/executor/utils/http'
1919
import { isJSONString, parseJSON, stringifyJSON } from '@/executor/utils/json'
2020
import { projectResolvedSecretDiagnosticError } from '@/executor/utils/resolved-secret-content-projection'
21+
import { refuseResolvedSecretProjection } from '@/executor/utils/resolved-secret-projection-refusal'
2122
import type {
2223
ResolvedSecretInputPath,
2324
ResolvedSecretTraceRegistry,
@@ -81,7 +82,12 @@ export class EvaluatorBlockHandler implements BlockHandler {
8182
modelInputPaths
8283
)
8384
if (!modelInputProjection.complete) {
84-
throw new Error('Evaluator model input could not be safely projected')
85+
refuseResolvedSecretProjection({
86+
site: 'evaluator.contentMetricsModelInput',
87+
message: 'Evaluator model input could not be safely projected',
88+
registry: ctx.resolvedSecretTraceRegistry,
89+
inputPath: 'content,metrics',
90+
})
8591
}
8692
const processedContent = this.processContent(modelInputProjection.value.content)
8793
const projectedMetrics = Array.isArray(modelInputProjection.value.metrics)

apps/sim/executor/handlers/mothership/mothership-handler.ts

Lines changed: 54 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -43,13 +43,18 @@ import type {
4343
StreamingExecution,
4444
} from '@/executor/types'
4545
import { buildAPIUrl, buildAuthHeaders, extractAPIErrorMessage } from '@/executor/utils/http'
46+
import { refuseResolvedSecretProjection } from '@/executor/utils/resolved-secret-projection-refusal'
4647
import type {
4748
ResolvedSecretInputPath,
4849
ResolvedSecretTraceRegistry,
4950
} from '@/executor/utils/resolved-secret-trace-registry'
5051
import type { SerializedBlock } from '@/serializer/types'
5152

5253
const logger = createLogger('MothershipBlockHandler')
54+
55+
const MOTHERSHIP_INPUT_REFUSAL = 'Mothership input could not be safely projected'
56+
const MOTHERSHIP_SKILL_SELECTOR_REFUSAL =
57+
'Mothership skill selector could not be safely projected for display'
5358
const CANCELLATION_CHECK_INTERVAL_MS = 500
5459
const MAX_MOTHERSHIP_ATTACHMENT_BYTES = 10 * 1024 * 1024
5560
const MOTHERSHIP_EXECUTE_STREAM_HEADER = 'X-Mothership-Execute-Stream'
@@ -234,11 +239,15 @@ function projectPrivateMothershipSkillSelectorsForDisplay(
234239
privateSelectorInputPaths: readonly ResolvedSecretInputPath[]
235240
): unknown {
236241
if (!Array.isArray(skills) || privateSelectorIndexes.size === 0) return skills
237-
const projection = registry
238-
.forkForInputPaths(privateSelectorInputPaths)
239-
.projectResolvedInputSelection({ skills })
242+
const selectorRegistry = registry.forkForInputPaths(privateSelectorInputPaths)
243+
const projection = selectorRegistry.projectResolvedInputSelection({ skills })
240244
if (!projection.complete || !Array.isArray(projection.value.skills)) {
241-
throw new Error('Mothership skill selector could not be safely projected for display')
245+
refuseResolvedSecretProjection({
246+
site: 'mothership.skillSelectorDisplay',
247+
message: MOTHERSHIP_SKILL_SELECTOR_REFUSAL,
248+
registry: selectorRegistry,
249+
inputPath: 'skills',
250+
})
242251
}
243252
for (const inputIndex of privateSelectorIndexes) {
244253
const source = skills[inputIndex]
@@ -249,7 +258,12 @@ function projectPrivateMothershipSkillSelectorsForDisplay(
249258
typeof source.skillId !== 'string' ||
250259
typeof projected.skillId !== 'string'
251260
) {
252-
throw new Error('Mothership skill selector could not be safely projected for display')
261+
refuseResolvedSecretProjection({
262+
site: 'mothership.skillSelectorDisplayEntry',
263+
message: MOTHERSHIP_SKILL_SELECTOR_REFUSAL,
264+
registry: selectorRegistry,
265+
inputPath: 'skills.skillId',
266+
})
253267
}
254268
}
255269
return projection.value.skills
@@ -293,19 +307,34 @@ function assertMothershipToolSchemaProjectionsAreSafe(
293307
if (!Array.isArray(tools)) return
294308
const projection = registry.projectResolvedInputSelection({ tools })
295309
if (!projection.complete || !Array.isArray(projection.value.tools)) {
296-
throw new Error('Mothership input could not be safely projected')
310+
refuseResolvedSecretProjection({
311+
site: 'mothership.toolSchemaProjection',
312+
message: MOTHERSHIP_INPUT_REFUSAL,
313+
registry,
314+
inputPath: 'tools',
315+
})
297316
}
298317

299318
for (const { inputIndex, selection } of selectIndexedMothershipMcpTools(tools)) {
300319
if (!selection.schema) continue
301320
const projectedCandidate = projection.value.tools[inputIndex]
302321
if (!isPlainRecord(projectedCandidate)) {
303-
throw new Error('Mothership input could not be safely projected')
322+
refuseResolvedSecretProjection({
323+
site: 'mothership.toolSchemaProjectedEntry',
324+
message: MOTHERSHIP_INPUT_REFUSAL,
325+
registry,
326+
inputPath: 'tools.schema',
327+
})
304328
}
305329
const projectedSchema = projectedCandidate.schema ?? selection.schema
306330
const schemaProjection = projectModelSchemaAnnotations(selection.schema, projectedSchema)
307331
if (!schemaProjection.safe) {
308-
throw new Error('Mothership input could not be safely projected')
332+
refuseResolvedSecretProjection({
333+
site: 'mothership.toolSchemaAnnotations',
334+
message: MOTHERSHIP_INPUT_REFUSAL,
335+
registry,
336+
inputPath: 'tools.schema',
337+
})
309338
}
310339
}
311340
}
@@ -316,7 +345,11 @@ function assertMothershipStructuralInputsDoNotResolveSecrets(
316345
): void {
317346
const provenance = registry.exportCommittedProvenanceForInputPaths(inputPaths)
318347
if (!provenance.complete) {
319-
throw new Error('Mothership input could not be safely projected')
348+
refuseResolvedSecretProjection({
349+
site: 'mothership.structuralInputProvenance',
350+
message: MOTHERSHIP_INPUT_REFUSAL,
351+
registry,
352+
})
320353
}
321354
if (provenance.entries.length > 0) {
322355
throw new Error('Mothership structural model inputs cannot contain secret references')
@@ -640,7 +673,12 @@ async function buildMothershipFileAttachments(
640673
}
641674
const projectedFiles = normalizeFileInput(projectedFilesInput)
642675
if (!projectedFiles || projectedFiles.length !== files.length) {
643-
throw new Error('Mothership input could not be safely projected')
676+
refuseResolvedSecretProjection({
677+
site: 'mothership.fileAttachmentArity',
678+
message: MOTHERSHIP_INPUT_REFUSAL,
679+
registry: ctx.resolvedSecretTraceRegistry,
680+
inputPath: 'files',
681+
})
644682
}
645683

646684
const userFiles = files.map((file) =>
@@ -767,7 +805,12 @@ export class MothershipBlockHandler implements BlockHandler {
767805
modelInputPaths
768806
)
769807
if (!modelInputProjection.complete || typeof modelInputProjection.value.prompt !== 'string') {
770-
throw new Error('Mothership input could not be safely projected')
808+
refuseResolvedSecretProjection({
809+
site: 'mothership.modelInput',
810+
message: MOTHERSHIP_INPUT_REFUSAL,
811+
registry: sourceRegistry,
812+
inputPath: 'prompt,files,tools,skills',
813+
})
771814
}
772815
const messages = [
773816
{

apps/sim/executor/handlers/pi/pi-handler.ts

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,7 @@ import type {
5353
NormalizedBlockOutput,
5454
StreamingExecution,
5555
} from '@/executor/types'
56+
import { refuseResolvedSecretProjection } from '@/executor/utils/resolved-secret-projection-refusal'
5657
import { isPiSupportedProvider, resolvePiModelId } from '@/providers/pi-providers'
5758
import { getProviderFromModel } from '@/providers/utils'
5859
import type { SerializedBlock } from '@/serializer/types'
@@ -172,7 +173,12 @@ export class PiBlockHandler implements BlockHandler {
172173
[['task']]
173174
)
174175
if (!taskProjection.complete || typeof taskProjection.value.task !== 'string') {
175-
throw new Error('Pi input could not be safely projected')
176+
refuseResolvedSecretProjection({
177+
site: 'pi.taskModelInput',
178+
message: 'Pi input could not be safely projected',
179+
registry: ctx.resolvedSecretTraceRegistry,
180+
inputPath: 'task',
181+
})
176182
}
177183
const task = taskProjection.value.task
178184
const model = asOptString(inputs.model) ?? DEFAULT_MODEL
@@ -431,7 +437,12 @@ export class PiBlockHandler implements BlockHandler {
431437
[['searchApiKey']]
432438
)
433439
if (!searchInputProjection.complete) {
434-
throw new Error('Pi search input could not be safely projected')
440+
refuseResolvedSecretProjection({
441+
site: 'pi.searchApiKeyInput',
442+
message: 'Pi search input could not be safely projected',
443+
registry: ctx.resolvedSecretTraceRegistry,
444+
inputPath: 'searchApiKey',
445+
})
435446
}
436447
const projectedApiKey = Object.is(searchInputProjection.value.searchApiKey, rawSearchApiKey)
437448
? apiKey

0 commit comments

Comments
 (0)