From ffa1635e5a6ee2b1e9b03199ac96d37e3ef323d6 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Sun, 9 Aug 2026 19:50:48 -0700 Subject: [PATCH 1/3] fix(provenance): report why a projection was refused, at the point of refusal MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../sim/app/api/mcp/serve/[serverId]/route.ts | 14 +- .../executor/handlers/agent/agent-handler.ts | 299 +++++++++++++++--- apps/sim/executor/handlers/agent/memory.ts | 91 +++++- .../handlers/evaluator/evaluator-handler.ts | 8 +- .../handlers/mothership/mothership-handler.ts | 65 +++- apps/sim/executor/handlers/pi/pi-handler.ts | 15 +- .../handlers/router/router-handler.ts | 22 +- ...resolved-secret-projection-refusal.test.ts | 205 ++++++++++++ .../resolved-secret-projection-refusal.ts | 101 ++++++ .../resolved-secret-trace-registry.test.ts | 45 +++ .../utils/resolved-secret-trace-registry.ts | 70 +++- apps/sim/lib/copilot/request/lifecycle/run.ts | 21 +- .../lib/guardrails/validate_hallucination.ts | 8 +- .../lib/knowledge/model-input-provenance.ts | 28 +- apps/sim/tools/request-transport.ts | 7 +- 15 files changed, 906 insertions(+), 93 deletions(-) create mode 100644 apps/sim/executor/utils/resolved-secret-projection-refusal.test.ts create mode 100644 apps/sim/executor/utils/resolved-secret-projection-refusal.ts diff --git a/apps/sim/app/api/mcp/serve/[serverId]/route.ts b/apps/sim/app/api/mcp/serve/[serverId]/route.ts index 5f185a5d6ec..211edf1305b 100644 --- a/apps/sim/app/api/mcp/serve/[serverId]/route.ts +++ b/apps/sim/app/api/mcp/serve/[serverId]/route.ts @@ -73,6 +73,7 @@ import { import { getMeaningfulWorkflowDescription } from '@/lib/mcp/workflow-tool-schema' import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils' import { projectResolvedSecretModelContent } from '@/executor/utils/resolved-secret-content-projection' +import { refuseResolvedSecretProjection } from '@/executor/utils/resolved-secret-projection-refusal' import { ResolvedSecretTraceRegistry } from '@/executor/utils/resolved-secret-trace-registry' const logger = createLogger('WorkflowMcpServeAPI') @@ -293,7 +294,13 @@ async function projectWorkflowMcpModelContent( throw new Error('MCP workflow execution provenance is invalid') } const projection = projectResolvedSecretModelContent(value, registry) - if (!projection.safe) throw new Error('MCP workflow output could not be safely projected') + if (!projection.safe) { + refuseResolvedSecretProjection({ + site: 'mcpServe.workflowOutput', + message: 'MCP workflow output could not be safely projected', + registry, + }) + } return projection.value } @@ -939,7 +946,10 @@ async function handleToolsCall( }) : rawErrorMessage if (typeof errorMessage !== 'string') { - throw new Error('MCP workflow execution error could not be safely projected') + refuseResolvedSecretProjection({ + site: 'mcpServe.executionError', + message: 'MCP workflow execution error could not be safely projected', + }) } const status = getWorkflowErrorStatus(response.status) const responseHeaders: Record = {} diff --git a/apps/sim/executor/handlers/agent/agent-handler.ts b/apps/sim/executor/handlers/agent/agent-handler.ts index d1e972180da..e689b411c5c 100644 --- a/apps/sim/executor/handlers/agent/agent-handler.ts +++ b/apps/sim/executor/handlers/agent/agent-handler.ts @@ -63,6 +63,7 @@ import { buildAPIUrl, buildAuthHeaders } from '@/executor/utils/http' import { stringifyJSON } from '@/executor/utils/json' import { projectResolvedSecretDiagnosticContent } from '@/executor/utils/resolved-secret-content-projection' import { prepareResolvedSecretProjectedInputs } from '@/executor/utils/resolved-secret-input-projection' +import { refuseResolvedSecretProjection } from '@/executor/utils/resolved-secret-projection-refusal' import type { ResolvedSecretInputPath, ResolvedSecretTraceRegistry, @@ -94,6 +95,11 @@ import { getToolAsync } from '@/tools/utils.server' const logger = createLogger('AgentBlockHandler') const MODEL_SAFE_RESPONSE_FORMAT_NAME = 'response_schema' +const AGENT_MODEL_INPUT_REFUSAL = 'Agent model input could not be safely projected' +const AGENT_TOOL_INPUT_REFUSAL = 'Agent tool input could not be safely projected' +const AGENT_PRIVATE_SELECTOR_REFUSAL = 'Agent private selector could not be safely projected' +const toAgentToolInputSafetyError = (message: string) => new AgentToolInputSafetyError(message) + const AGENT_RAW_PROVIDER_ERROR_INPUT_PATHS: readonly ResolvedSecretInputPath[] = [ ['model'], ['temperature'], @@ -237,7 +243,13 @@ export class AgentBlockHandler implements BlockHandler { const privateAgentSelectors = this.getPrivateAgentSelectorInputPaths(ctx, inputs, []) privateAgentSelectorInputPaths.push(...privateAgentSelectors.inputPaths) if (!privateAgentSelectors.complete) { - throw new AgentToolInputSafetyError('Agent private selector could not be safely projected') + refuseResolvedSecretProjection({ + site: 'agent.privateSelectorProvenance', + message: AGENT_PRIVATE_SELECTOR_REFUSAL, + registry: ctx.resolvedSecretTraceRegistry, + inputPath: 'responseFormat,tools,skills', + createError: toAgentToolInputSafetyError, + }) } const responseFormatProjection = this.projectResponseFormatForModel( ctx, @@ -267,7 +279,11 @@ export class AgentBlockHandler implements BlockHandler { coreModelInputPaths ) if (!modelInputProjection.complete) { - throw new Error('Agent model input could not be safely projected') + refuseResolvedSecretProjection({ + site: 'agent.coreModelInput', + message: AGENT_MODEL_INPUT_REFUSAL, + registry: ctx.resolvedSecretTraceRegistry, + }) } const modelInputs: AgentInputs = { ...filteredInputs, @@ -652,7 +668,12 @@ export class AgentBlockHandler implements BlockHandler { const projection = registry.projectResolvedInputSelection({ tools: inputTools }) if (!projection.complete || !Array.isArray(projection.value.tools)) { - throw new Error('Agent tool input could not be safely projected') + refuseResolvedSecretProjection({ + site: 'agent.toolInputProvenanceProjection', + message: AGENT_TOOL_INPUT_REFUSAL, + registry, + inputPath: 'tools', + }) } return projection.value.tools as ToolInput[] } @@ -795,7 +816,12 @@ export class AgentBlockHandler implements BlockHandler { const provenance = registry.exportCommittedProvenanceForInputPaths(inputPaths) if (!provenance.complete) { - throw new AgentToolInputSafetyError('Agent tool input could not be safely projected') + refuseResolvedSecretProjection({ + site: 'agent.structuralInputProvenance', + message: AGENT_TOOL_INPUT_REFUSAL, + registry, + createError: toAgentToolInputSafetyError, + }) } if (provenance.entries.length > 0) { throw new AgentToolInputSafetyError(errorMessage) @@ -891,14 +917,26 @@ export class AgentBlockHandler implements BlockHandler { return null } if (!modelSchema?.function) { - throw new AgentToolInputSafetyError('Agent tool input could not be safely projected') + refuseResolvedSecretProjection({ + site: 'agent.customToolModelSchemaMissing', + message: AGENT_TOOL_INPUT_REFUSAL, + registry: ctx.resolvedSecretTraceRegistry, + inputPath: 'tools', + createError: toAgentToolInputSafetyError, + }) } const parametersProjection = projectModelSchemaAnnotations( schema.function.parameters, modelSchema.function.parameters ) if (!parametersProjection.safe) { - throw new AgentToolInputSafetyError('Agent tool input could not be safely projected') + refuseResolvedSecretProjection({ + site: 'agent.customToolSchemaAnnotations', + message: AGENT_TOOL_INPUT_REFUSAL, + registry: ctx.resolvedSecretTraceRegistry, + inputPath: 'tools', + createError: toAgentToolInputSafetyError, + }) } const rawDescription = schema.function.description const projectedDescription = modelSchema.function.description @@ -906,7 +944,13 @@ export class AgentBlockHandler implements BlockHandler { (rawDescription === undefined && projectedDescription !== undefined) || (rawDescription !== undefined && projectedDescription === undefined) ) { - throw new AgentToolInputSafetyError('Agent tool input could not be safely projected') + refuseResolvedSecretProjection({ + site: 'agent.customToolDescriptionArity', + message: AGENT_TOOL_INPUT_REFUSAL, + registry: ctx.resolvedSecretTraceRegistry, + inputPath: 'tools', + createError: toAgentToolInputSafetyError, + }) } const modelParameters = parametersProjection.value as ToolSchema @@ -1081,18 +1125,36 @@ export class AgentBlockHandler implements BlockHandler { const { serverId, toolName, serverName, ...userProvidedParams } = tool.params || {} const projectedSchema = projectedTool?.schema ?? tool.schema if (projectedSchema !== undefined && !isPlainRecord(projectedSchema)) { - throw new AgentToolInputSafetyError('Agent tool input could not be safely projected') + refuseResolvedSecretProjection({ + site: 'agent.mcpToolSchemaShape', + message: AGENT_TOOL_INPUT_REFUSAL, + registry: ctx.resolvedSecretTraceRegistry, + inputPath: 'tools', + createError: toAgentToolInputSafetyError, + }) } const schemaProjection = projectModelSchemaAnnotations(tool.schema, projectedSchema) if (!schemaProjection.safe || !isPlainRecord(schemaProjection.value)) { - throw new AgentToolInputSafetyError('Agent tool input could not be safely projected') + refuseResolvedSecretProjection({ + site: 'agent.mcpToolSchemaAnnotations', + message: AGENT_TOOL_INPUT_REFUSAL, + registry: ctx.resolvedSecretTraceRegistry, + inputPath: 'tools', + createError: toAgentToolInputSafetyError, + }) } const projectedServerName = typeof projectedTool?.params?.serverName === 'string' ? projectedTool.params.serverName : serverName if (schemaProjection.value.type !== 'object') { - throw new AgentToolInputSafetyError('Agent tool input could not be safely projected') + refuseResolvedSecretProjection({ + site: 'agent.mcpToolSchemaType', + message: AGENT_TOOL_INPUT_REFUSAL, + registry: ctx.resolvedSecretTraceRegistry, + inputPath: 'tools', + createError: toAgentToolInputSafetyError, + }) } const schema: McpToolSchema = { ...schemaProjection.value, type: 'object' } const schemaDescription = @@ -1407,7 +1469,12 @@ export class AgentBlockHandler implements BlockHandler { .pop() if (latestUserFromInput) { if (!latestRawUserFromInput) { - throw new Error('Agent model input could not be safely projected') + refuseResolvedSecretProjection({ + site: 'agent.memoryUserMessageArity', + message: AGENT_MODEL_INPUT_REFUSAL, + registry: ctx.resolvedSecretTraceRegistry, + inputPath: 'messages', + }) } const userMessageInThisRun = memoryMessages.some( (m) => m.role === 'user' && m.executionId === ctx.executionId @@ -1499,7 +1566,12 @@ export class AgentBlockHandler implements BlockHandler { } const projectedFiles = normalizeFileInput(projectedFilesInput) if (!projectedFiles || projectedFiles.length !== normalizedFiles.length) { - throw new Error('Agent model input could not be safely projected') + refuseResolvedSecretProjection({ + site: 'agent.fileInputArity', + message: AGENT_MODEL_INPUT_REFUSAL, + registry: ctx.resolvedSecretTraceRegistry, + inputPath: 'files', + }) } if (!messages || messages.length === 0) { @@ -1525,7 +1597,12 @@ export class AgentBlockHandler implements BlockHandler { const projectedFile = projectedFiles[index] if (!isPlainRecord(projectedFile) || typeof projectedFile.name !== 'string') { - throw new Error('Agent model input could not be safely projected') + refuseResolvedSecretProjection({ + site: 'agent.fileInputShape', + message: AGENT_MODEL_INPUT_REFUSAL, + registry: ctx.resolvedSecretTraceRegistry, + inputPath: 'files', + }) } const rawName = isPlainRecord(file) ? file.name : undefined if (typeof rawName === 'string' && projectedFile.name !== rawName) { @@ -1826,15 +1903,20 @@ export class AgentBlockHandler implements BlockHandler { const displayInputPaths = privateRoots.has('responseFormat') ? [...privateInputPaths, ['responseFormat']] : privateInputPaths - const displayProjection = sourceRegistry - .forkForInputPaths(displayInputPaths) - .projectResolvedInputSelection({ - responseFormat: inputs.responseFormat, - tools: inputs.tools, - skills: inputs.skills, - }) + const displayRegistry = sourceRegistry.forkForInputPaths(displayInputPaths) + const displayProjection = displayRegistry.projectResolvedInputSelection({ + responseFormat: inputs.responseFormat, + tools: inputs.tools, + skills: inputs.skills, + }) if (!displayProjection.complete) { - throw new AgentToolInputSafetyError('Agent private selector could not be safely projected') + refuseResolvedSecretProjection({ + site: 'agent.privateSelectorDisplayProjection', + message: AGENT_PRIVATE_SELECTOR_REFUSAL, + registry: displayRegistry, + inputPath: 'responseFormat,tools,skills', + createError: toAgentToolInputSafetyError, + }) } if (privateRoots.has('responseFormat')) { inputs.responseFormat = displayProjection.value @@ -1842,13 +1924,25 @@ export class AgentBlockHandler implements BlockHandler { } if (privateRoots.has('tools')) { if (!Array.isArray(displayProjection.value.tools)) { - throw new AgentToolInputSafetyError('Agent private selector could not be safely projected') + refuseResolvedSecretProjection({ + site: 'agent.privateSelectorToolsShape', + message: AGENT_PRIVATE_SELECTOR_REFUSAL, + registry: displayRegistry, + inputPath: 'tools', + createError: toAgentToolInputSafetyError, + }) } inputs.tools = displayProjection.value.tools as ToolInput[] } if (privateRoots.has('skills')) { if (!Array.isArray(displayProjection.value.skills)) { - throw new AgentToolInputSafetyError('Agent private selector could not be safely projected') + refuseResolvedSecretProjection({ + site: 'agent.privateSelectorSkillsShape', + message: AGENT_PRIVATE_SELECTOR_REFUSAL, + registry: displayRegistry, + inputPath: 'skills', + createError: toAgentToolInputSafetyError, + }) } inputs.skills = displayProjection.value.skills as AgentInputs['skills'] } @@ -1921,7 +2015,13 @@ export class AgentBlockHandler implements BlockHandler { } const projection = registry.projectResolvedInputSelection({ responseFormat }) if (!projection.complete) { - throw new AgentToolInputSafetyError('Agent model input could not be safely projected') + refuseResolvedSecretProjection({ + site: 'agent.responseFormatProjection', + message: AGENT_MODEL_INPUT_REFUSAL, + registry, + inputPath: 'responseFormat', + createError: toAgentToolInputSafetyError, + }) } const projectedResponseFormat = projection.value.responseFormat @@ -1930,20 +2030,36 @@ export class AgentBlockHandler implements BlockHandler { return { value: responseFormat, inputPaths: annotationInputPaths } } if (typeof projectedResponseFormat !== 'string') { - throw new AgentToolInputSafetyError('Agent model input could not be safely projected') + refuseResolvedSecretProjection({ + site: 'agent.responseFormatStringType', + message: AGENT_MODEL_INPUT_REFUSAL, + registry, + inputPath: 'responseFormat', + createError: toAgentToolInputSafetyError, + }) } try { const rawParsed = JSON.parse(responseFormat) const projectedParsed = JSON.parse(projectedResponseFormat) if (!isPlainRecord(rawParsed) || !isPlainRecord(projectedParsed)) { - throw new AgentToolInputSafetyError('Agent model input could not be safely projected') + refuseResolvedSecretProjection({ + site: 'agent.responseFormatJsonShape', + message: AGENT_MODEL_INPUT_REFUSAL, + registry, + inputPath: 'responseFormat', + createError: toAgentToolInputSafetyError, + }) } const privateNameInputPaths = Object.hasOwn(rawParsed, 'name') && !Object.is(rawParsed.name, projectedParsed.name) ? ([['responseFormat']] as const) : [] onPrivateNameInputPaths(privateNameInputPaths) - const modelSafeResponseFormat = this.projectResponseFormatObject(rawParsed, projectedParsed) + const modelSafeResponseFormat = this.projectResponseFormatObject( + rawParsed, + projectedParsed, + registry + ) const parsedIsWrapper = Object.hasOwn(rawParsed, 'schema') || Object.hasOwn(rawParsed, 'name') const parsedSchema = parsedIsWrapper ? rawParsed.schema : rawParsed @@ -1963,13 +2079,25 @@ export class AgentBlockHandler implements BlockHandler { } } catch (error) { if (error instanceof AgentToolInputSafetyError) throw error - throw new AgentToolInputSafetyError('Agent model input could not be safely projected') + refuseResolvedSecretProjection({ + site: 'agent.responseFormatJsonParse', + message: AGENT_MODEL_INPUT_REFUSAL, + registry, + inputPath: 'responseFormat', + createError: toAgentToolInputSafetyError, + }) } } if (!isPlainRecord(responseFormat)) { if (!Object.is(responseFormat, projectedResponseFormat)) { - throw new AgentToolInputSafetyError('Agent model input could not be safely projected') + refuseResolvedSecretProjection({ + site: 'agent.responseFormatScalarIdentity', + message: AGENT_MODEL_INPUT_REFUSAL, + registry, + inputPath: 'responseFormat', + createError: toAgentToolInputSafetyError, + }) } return { value: responseFormat, @@ -1989,23 +2117,40 @@ export class AgentBlockHandler implements BlockHandler { 'Agent structural model inputs cannot contain secret references' ) return { - value: this.projectResponseFormatObject(responseFormat, projectedResponseFormat), + value: this.projectResponseFormatObject(responseFormat, projectedResponseFormat, registry), inputPaths: annotationInputPaths, } } + /** + * Takes the registry from its caller so a refusal here reports the run that failed; without it + * the refusal would deduplicate process-wide and name no cause. + */ private projectResponseFormatObject( rawValue: Record, - projectedValue: unknown + projectedValue: unknown, + registry: ResolvedSecretTraceRegistry ): Record { if (!isPlainRecord(projectedValue)) { - throw new AgentToolInputSafetyError('Agent model input could not be safely projected') + refuseResolvedSecretProjection({ + site: 'agent.responseFormatObjectShape', + message: AGENT_MODEL_INPUT_REFUSAL, + registry, + inputPath: 'responseFormat', + createError: toAgentToolInputSafetyError, + }) } const isWrapper = Object.hasOwn(rawValue, 'schema') || Object.hasOwn(rawValue, 'name') if (!isWrapper) { const schemaProjection = projectModelSchemaAnnotations(rawValue, projectedValue) if (!schemaProjection.safe || !isPlainRecord(schemaProjection.value)) { - throw new AgentToolInputSafetyError('Agent model input could not be safely projected') + refuseResolvedSecretProjection({ + site: 'agent.responseFormatSchemaAnnotations', + message: AGENT_MODEL_INPUT_REFUSAL, + registry, + inputPath: 'responseFormat', + createError: toAgentToolInputSafetyError, + }) } return schemaProjection.value } @@ -2015,16 +2160,34 @@ export class AgentBlockHandler implements BlockHandler { rawKeys.length !== Object.keys(projectedValue).length || rawKeys.some((key) => !Object.hasOwn(projectedValue, key)) ) { - throw new AgentToolInputSafetyError('Agent model input could not be safely projected') + refuseResolvedSecretProjection({ + site: 'agent.responseFormatWrapperKeys', + message: AGENT_MODEL_INPUT_REFUSAL, + registry, + inputPath: 'responseFormat', + createError: toAgentToolInputSafetyError, + }) } for (const key of rawKeys) { if (key !== 'schema' && key !== 'name' && !Object.is(rawValue[key], projectedValue[key])) { - throw new AgentToolInputSafetyError('Agent model input could not be safely projected') + refuseResolvedSecretProjection({ + site: 'agent.responseFormatWrapperValues', + message: AGENT_MODEL_INPUT_REFUSAL, + registry, + inputPath: 'responseFormat', + createError: toAgentToolInputSafetyError, + }) } } const schemaProjection = projectModelSchemaAnnotations(rawValue.schema, projectedValue.schema) if (!schemaProjection.safe) { - throw new AgentToolInputSafetyError('Agent model input could not be safely projected') + refuseResolvedSecretProjection({ + site: 'agent.responseFormatWrapperSchemaAnnotations', + message: AGENT_MODEL_INPUT_REFUSAL, + registry, + inputPath: 'responseFormat', + createError: toAgentToolInputSafetyError, + }) } return { ...rawValue, @@ -2076,7 +2239,13 @@ export class AgentBlockHandler implements BlockHandler { inputPaths ) if (!projection.complete) { - throw new AgentToolInputSafetyError('Agent model input could not be safely projected') + refuseResolvedSecretProjection({ + site: 'agent.fileNameProjection', + message: AGENT_MODEL_INPUT_REFUSAL, + registry: ctx.resolvedSecretTraceRegistry, + inputPath: 'files,messages', + createError: toAgentToolInputSafetyError, + }) } let projectedFiles = projection.value.files @@ -2091,23 +2260,47 @@ export class AgentBlockHandler implements BlockHandler { files: inputs.files, }) if (!serializedProjection.complete) { - throw new AgentToolInputSafetyError('Agent model input could not be safely projected') + refuseResolvedSecretProjection({ + site: 'agent.serializedFilesProjection', + message: AGENT_MODEL_INPUT_REFUSAL, + registry: ctx.resolvedSecretTraceRegistry, + inputPath: 'files', + createError: toAgentToolInputSafetyError, + }) } const projectedSerializedFiles = serializedProjection.value.files if (!Object.is(inputs.files, projectedSerializedFiles)) { if (typeof projectedSerializedFiles !== 'string') { - throw new AgentToolInputSafetyError('Agent model input could not be safely projected') + refuseResolvedSecretProjection({ + site: 'agent.serializedFilesType', + message: AGENT_MODEL_INPUT_REFUSAL, + registry: ctx.resolvedSecretTraceRegistry, + inputPath: 'files', + createError: toAgentToolInputSafetyError, + }) } const rawFiles = normalizeFileInput(inputs.files) const projectedFileRecords = normalizeFileInput(projectedSerializedFiles) if (!rawFiles || !projectedFileRecords || rawFiles.length !== projectedFileRecords.length) { - throw new AgentToolInputSafetyError('Agent model input could not be safely projected') + refuseResolvedSecretProjection({ + site: 'agent.serializedFilesArity', + message: AGENT_MODEL_INPUT_REFUSAL, + registry: ctx.resolvedSecretTraceRegistry, + inputPath: 'files', + createError: toAgentToolInputSafetyError, + }) } projectedFiles = rawFiles.map((rawFile, index) => { const projectedFile = projectedFileRecords[index] if (!isPlainRecord(rawFile) || !isPlainRecord(projectedFile)) { - throw new AgentToolInputSafetyError('Agent model input could not be safely projected') + refuseResolvedSecretProjection({ + site: 'agent.serializedFileShape', + message: AGENT_MODEL_INPUT_REFUSAL, + registry: ctx.resolvedSecretTraceRegistry, + inputPath: 'files', + createError: toAgentToolInputSafetyError, + }) } if (!Object.is(rawFile.base64, projectedFile.base64)) { throw new AgentToolInputSafetyError( @@ -2116,7 +2309,13 @@ export class AgentBlockHandler implements BlockHandler { } if (rawFile.name === undefined) return rawFile if (typeof projectedFile.name !== 'string') { - throw new AgentToolInputSafetyError('Agent model input could not be safely projected') + refuseResolvedSecretProjection({ + site: 'agent.serializedFileName', + message: AGENT_MODEL_INPUT_REFUSAL, + registry: ctx.resolvedSecretTraceRegistry, + inputPath: 'files,name', + createError: toAgentToolInputSafetyError, + }) } return { ...rawFile, name: projectedFile.name } }) @@ -2137,7 +2336,13 @@ export class AgentBlockHandler implements BlockHandler { const projectedFiles = isPlainRecord(projectedMessage) ? projectedMessage.files : undefined if (!Array.isArray(rawFiles) || !Array.isArray(projectedFiles)) continue if (rawFiles.length !== projectedFiles.length) { - throw new AgentToolInputSafetyError('Agent model input could not be safely projected') + refuseResolvedSecretProjection({ + site: 'agent.messageFilesArity', + message: AGENT_MODEL_INPUT_REFUSAL, + registry: ctx.resolvedSecretTraceRegistry, + inputPath: 'messages,files', + createError: toAgentToolInputSafetyError, + }) } for (let fileIndex = 0; fileIndex < rawFiles.length; fileIndex++) { const rawFile = rawFiles[fileIndex] @@ -2145,7 +2350,13 @@ export class AgentBlockHandler implements BlockHandler { if (!isPlainRecord(rawFile) || !isPlainRecord(projectedFile)) continue if (rawFile.name === undefined) continue if (typeof projectedFile.name !== 'string') { - throw new AgentToolInputSafetyError('Agent model input could not be safely projected') + refuseResolvedSecretProjection({ + site: 'agent.messageFileName', + message: AGENT_MODEL_INPUT_REFUSAL, + registry: ctx.resolvedSecretTraceRegistry, + inputPath: 'messages,files,name', + createError: toAgentToolInputSafetyError, + }) } if (Object.is(rawFile.name, projectedFile.name)) continue projectedNameByFile.set(rawFile, { diff --git a/apps/sim/executor/handlers/agent/memory.ts b/apps/sim/executor/handlers/agent/memory.ts index 4007ae539a7..13523c742ce 100644 --- a/apps/sim/executor/handlers/agent/memory.ts +++ b/apps/sim/executor/handlers/agent/memory.ts @@ -24,11 +24,14 @@ import { projectResolvedSecretModelContent, projectResolvedSecretModelJsonStrings, } from '@/executor/utils/resolved-secret-content-projection' +import { refuseResolvedSecretProjection } from '@/executor/utils/resolved-secret-projection-refusal' import { ResolvedSecretTraceRegistry } from '@/executor/utils/resolved-secret-trace-registry' import { PROVIDER_DEFINITIONS } from '@/providers/models' const logger = createLogger('Memory') +const MEMORY_CONTENT_REFUSAL = 'Memory content could not be safely projected' + export class Memory { async fetchMemoryMessages(ctx: ExecutionContext, inputs: AgentInputs): Promise { if (!inputs.memoryType || inputs.memoryType === 'none') { @@ -82,7 +85,12 @@ export class Memory { messages ))) ) { - throw new Error('Memory content could not be safely projected') + refuseResolvedSecretProjection({ + site: 'memory.storedProvenanceImport', + message: MEMORY_CONTENT_REFUSAL, + registry: ctx.resolvedSecretTraceRegistry, + inputPath: 'messages', + }) } return Promise.all( @@ -95,7 +103,12 @@ export class Memory { ctx.resolvedSecretTraceRegistry?.exportProvenance().scope ) if (!(await importDurableSecretProvenance(modelRegistry, messageProvenance, message))) { - throw new Error('Memory content could not be safely projected') + refuseResolvedSecretProjection({ + site: 'memory.messageProvenanceImport', + message: MEMORY_CONTENT_REFUSAL, + registry: modelRegistry, + inputPath: 'messages', + }) } return this.projectMessageForModel(modelRegistry, message) }) @@ -213,12 +226,21 @@ export class Memory { } private projectMessageForModel(registry: ResolvedSecretTraceRegistry, message: Message): Message { - const functionArguments = this.readFunctionCallArguments(message.function_call) + const functionArguments = this.readFunctionCallArguments( + message.function_call, + registry, + 'function_call' + ) const toolArguments = message.tool_calls?.map((toolCall) => { if (!isPlainRecord(toolCall)) { - throw new Error('Memory content could not be safely projected') + refuseResolvedSecretProjection({ + site: 'memory.toolCallShape', + message: MEMORY_CONTENT_REFUSAL, + registry, + inputPath: 'tool_calls', + }) } - return this.readFunctionCallArguments(toolCall.function) + return this.readFunctionCallArguments(toolCall.function, registry, 'tool_calls.function') }) const contentProjection = projectResolvedSecretModelContent(message.content, registry) const argumentProjection = projectResolvedSecretModelJsonStrings( @@ -232,7 +254,12 @@ export class Memory { !Array.isArray(argumentProjection.value) || argumentProjection.value.length !== 1 + (toolArguments?.length ?? 0) ) { - throw new Error('Memory content could not be safely projected') + refuseResolvedSecretProjection({ + site: 'memory.messageContentProjection', + message: MEMORY_CONTENT_REFUSAL, + registry, + inputPath: 'content,function_call,tool_calls', + }) } const content = contentProjection.value @@ -241,13 +268,23 @@ export class Memory { (functionArguments !== undefined && typeof projectedFunctionArguments !== 'string') || (functionArguments === undefined && projectedFunctionArguments !== undefined) ) { - throw new Error('Memory content could not be safely projected') + refuseResolvedSecretProjection({ + site: 'memory.functionCallArgumentProjection', + message: MEMORY_CONTENT_REFUSAL, + registry, + inputPath: 'function_call.arguments', + }) } if ( (toolArguments !== undefined && projectedToolArguments.length !== toolArguments.length) || (toolArguments === undefined && projectedToolArguments.length !== 0) ) { - throw new Error('Memory content could not be safely projected') + refuseResolvedSecretProjection({ + site: 'memory.toolCallArgumentArity', + message: MEMORY_CONTENT_REFUSAL, + registry, + inputPath: 'tool_calls.function.arguments', + }) } const projectedToolCalls = message.tool_calls?.map((toolCall, index) => { @@ -255,11 +292,21 @@ export class Memory { const originalFunction = isPlainRecord(toolCall) ? toolCall.function : undefined if (originalFunction === undefined || originalFunction === null) return toolCall if (!isPlainRecord(originalFunction)) { - throw new Error('Memory content could not be safely projected') + refuseResolvedSecretProjection({ + site: 'memory.toolCallFunctionShape', + message: MEMORY_CONTENT_REFUSAL, + registry, + inputPath: 'tool_calls.function', + }) } if (!Object.hasOwn(originalFunction, 'arguments')) return toolCall if (typeof argument !== 'string') { - throw new Error('Memory content could not be safely projected') + refuseResolvedSecretProjection({ + site: 'memory.toolCallArgumentType', + message: MEMORY_CONTENT_REFUSAL, + registry, + inputPath: 'tool_calls.function.arguments', + }) } return { ...toolCall, @@ -283,14 +330,32 @@ export class Memory { } } - private readFunctionCallArguments(functionCall: unknown): string | undefined { + /** + * Takes the registry and path from its caller so a refusal here reports the run that failed. + * Without them the refusal would deduplicate process-wide and name no cause. + */ + private readFunctionCallArguments( + functionCall: unknown, + registry: ResolvedSecretTraceRegistry, + inputPath: string + ): string | undefined { if (functionCall === undefined || functionCall === null) return undefined if (!isPlainRecord(functionCall)) { - throw new Error('Memory content could not be safely projected') + refuseResolvedSecretProjection({ + site: 'memory.functionCallShape', + message: MEMORY_CONTENT_REFUSAL, + registry, + inputPath, + }) } if (!Object.hasOwn(functionCall, 'arguments')) return undefined if (typeof functionCall.arguments !== 'string') { - throw new Error('Memory content could not be safely projected') + refuseResolvedSecretProjection({ + site: 'memory.functionCallArgumentType', + message: MEMORY_CONTENT_REFUSAL, + registry, + inputPath, + }) } return functionCall.arguments } diff --git a/apps/sim/executor/handlers/evaluator/evaluator-handler.ts b/apps/sim/executor/handlers/evaluator/evaluator-handler.ts index 16ba1c8e831..6a094f64082 100644 --- a/apps/sim/executor/handlers/evaluator/evaluator-handler.ts +++ b/apps/sim/executor/handlers/evaluator/evaluator-handler.ts @@ -18,6 +18,7 @@ import type { BlockHandler, ExecutionContext } from '@/executor/types' import { buildAPIUrl, buildAuthHeaders, extractAPIErrorMessage } from '@/executor/utils/http' import { isJSONString, parseJSON, stringifyJSON } from '@/executor/utils/json' import { projectResolvedSecretDiagnosticError } from '@/executor/utils/resolved-secret-content-projection' +import { refuseResolvedSecretProjection } from '@/executor/utils/resolved-secret-projection-refusal' import type { ResolvedSecretInputPath, ResolvedSecretTraceRegistry, @@ -81,7 +82,12 @@ export class EvaluatorBlockHandler implements BlockHandler { modelInputPaths ) if (!modelInputProjection.complete) { - throw new Error('Evaluator model input could not be safely projected') + refuseResolvedSecretProjection({ + site: 'evaluator.contentMetricsModelInput', + message: 'Evaluator model input could not be safely projected', + registry: ctx.resolvedSecretTraceRegistry, + inputPath: 'content,metrics', + }) } const processedContent = this.processContent(modelInputProjection.value.content) const projectedMetrics = Array.isArray(modelInputProjection.value.metrics) diff --git a/apps/sim/executor/handlers/mothership/mothership-handler.ts b/apps/sim/executor/handlers/mothership/mothership-handler.ts index 1c77fdaccb7..b3af3d828d5 100644 --- a/apps/sim/executor/handlers/mothership/mothership-handler.ts +++ b/apps/sim/executor/handlers/mothership/mothership-handler.ts @@ -43,6 +43,7 @@ import type { StreamingExecution, } from '@/executor/types' import { buildAPIUrl, buildAuthHeaders, extractAPIErrorMessage } from '@/executor/utils/http' +import { refuseResolvedSecretProjection } from '@/executor/utils/resolved-secret-projection-refusal' import type { ResolvedSecretInputPath, ResolvedSecretTraceRegistry, @@ -50,6 +51,10 @@ import type { import type { SerializedBlock } from '@/serializer/types' const logger = createLogger('MothershipBlockHandler') + +const MOTHERSHIP_INPUT_REFUSAL = 'Mothership input could not be safely projected' +const MOTHERSHIP_SKILL_SELECTOR_REFUSAL = + 'Mothership skill selector could not be safely projected for display' const CANCELLATION_CHECK_INTERVAL_MS = 500 const MAX_MOTHERSHIP_ATTACHMENT_BYTES = 10 * 1024 * 1024 const MOTHERSHIP_EXECUTE_STREAM_HEADER = 'X-Mothership-Execute-Stream' @@ -234,11 +239,15 @@ function projectPrivateMothershipSkillSelectorsForDisplay( privateSelectorInputPaths: readonly ResolvedSecretInputPath[] ): unknown { if (!Array.isArray(skills) || privateSelectorIndexes.size === 0) return skills - const projection = registry - .forkForInputPaths(privateSelectorInputPaths) - .projectResolvedInputSelection({ skills }) + const selectorRegistry = registry.forkForInputPaths(privateSelectorInputPaths) + const projection = selectorRegistry.projectResolvedInputSelection({ skills }) if (!projection.complete || !Array.isArray(projection.value.skills)) { - throw new Error('Mothership skill selector could not be safely projected for display') + refuseResolvedSecretProjection({ + site: 'mothership.skillSelectorDisplay', + message: MOTHERSHIP_SKILL_SELECTOR_REFUSAL, + registry: selectorRegistry, + inputPath: 'skills', + }) } for (const inputIndex of privateSelectorIndexes) { const source = skills[inputIndex] @@ -249,7 +258,12 @@ function projectPrivateMothershipSkillSelectorsForDisplay( typeof source.skillId !== 'string' || typeof projected.skillId !== 'string' ) { - throw new Error('Mothership skill selector could not be safely projected for display') + refuseResolvedSecretProjection({ + site: 'mothership.skillSelectorDisplayEntry', + message: MOTHERSHIP_SKILL_SELECTOR_REFUSAL, + registry: selectorRegistry, + inputPath: 'skills.skillId', + }) } } return projection.value.skills @@ -293,19 +307,34 @@ function assertMothershipToolSchemaProjectionsAreSafe( if (!Array.isArray(tools)) return const projection = registry.projectResolvedInputSelection({ tools }) if (!projection.complete || !Array.isArray(projection.value.tools)) { - throw new Error('Mothership input could not be safely projected') + refuseResolvedSecretProjection({ + site: 'mothership.toolSchemaProjection', + message: MOTHERSHIP_INPUT_REFUSAL, + registry, + inputPath: 'tools', + }) } for (const { inputIndex, selection } of selectIndexedMothershipMcpTools(tools)) { if (!selection.schema) continue const projectedCandidate = projection.value.tools[inputIndex] if (!isPlainRecord(projectedCandidate)) { - throw new Error('Mothership input could not be safely projected') + refuseResolvedSecretProjection({ + site: 'mothership.toolSchemaProjectedEntry', + message: MOTHERSHIP_INPUT_REFUSAL, + registry, + inputPath: 'tools.schema', + }) } const projectedSchema = projectedCandidate.schema ?? selection.schema const schemaProjection = projectModelSchemaAnnotations(selection.schema, projectedSchema) if (!schemaProjection.safe) { - throw new Error('Mothership input could not be safely projected') + refuseResolvedSecretProjection({ + site: 'mothership.toolSchemaAnnotations', + message: MOTHERSHIP_INPUT_REFUSAL, + registry, + inputPath: 'tools.schema', + }) } } } @@ -316,7 +345,11 @@ function assertMothershipStructuralInputsDoNotResolveSecrets( ): void { const provenance = registry.exportCommittedProvenanceForInputPaths(inputPaths) if (!provenance.complete) { - throw new Error('Mothership input could not be safely projected') + refuseResolvedSecretProjection({ + site: 'mothership.structuralInputProvenance', + message: MOTHERSHIP_INPUT_REFUSAL, + registry, + }) } if (provenance.entries.length > 0) { throw new Error('Mothership structural model inputs cannot contain secret references') @@ -640,7 +673,12 @@ async function buildMothershipFileAttachments( } const projectedFiles = normalizeFileInput(projectedFilesInput) if (!projectedFiles || projectedFiles.length !== files.length) { - throw new Error('Mothership input could not be safely projected') + refuseResolvedSecretProjection({ + site: 'mothership.fileAttachmentArity', + message: MOTHERSHIP_INPUT_REFUSAL, + registry: ctx.resolvedSecretTraceRegistry, + inputPath: 'files', + }) } const userFiles = files.map((file) => @@ -767,7 +805,12 @@ export class MothershipBlockHandler implements BlockHandler { modelInputPaths ) if (!modelInputProjection.complete || typeof modelInputProjection.value.prompt !== 'string') { - throw new Error('Mothership input could not be safely projected') + refuseResolvedSecretProjection({ + site: 'mothership.modelInput', + message: MOTHERSHIP_INPUT_REFUSAL, + registry: sourceRegistry, + inputPath: 'prompt,files,tools,skills', + }) } const messages = [ { diff --git a/apps/sim/executor/handlers/pi/pi-handler.ts b/apps/sim/executor/handlers/pi/pi-handler.ts index 4d8a1f36ca6..2f42719b71c 100644 --- a/apps/sim/executor/handlers/pi/pi-handler.ts +++ b/apps/sim/executor/handlers/pi/pi-handler.ts @@ -53,6 +53,7 @@ import type { NormalizedBlockOutput, StreamingExecution, } from '@/executor/types' +import { refuseResolvedSecretProjection } from '@/executor/utils/resolved-secret-projection-refusal' import { isPiSupportedProvider, resolvePiModelId } from '@/providers/pi-providers' import { getProviderFromModel } from '@/providers/utils' import type { SerializedBlock } from '@/serializer/types' @@ -172,7 +173,12 @@ export class PiBlockHandler implements BlockHandler { [['task']] ) if (!taskProjection.complete || typeof taskProjection.value.task !== 'string') { - throw new Error('Pi input could not be safely projected') + refuseResolvedSecretProjection({ + site: 'pi.taskModelInput', + message: 'Pi input could not be safely projected', + registry: ctx.resolvedSecretTraceRegistry, + inputPath: 'task', + }) } const task = taskProjection.value.task const model = asOptString(inputs.model) ?? DEFAULT_MODEL @@ -431,7 +437,12 @@ export class PiBlockHandler implements BlockHandler { [['searchApiKey']] ) if (!searchInputProjection.complete) { - throw new Error('Pi search input could not be safely projected') + refuseResolvedSecretProjection({ + site: 'pi.searchApiKeyInput', + message: 'Pi search input could not be safely projected', + registry: ctx.resolvedSecretTraceRegistry, + inputPath: 'searchApiKey', + }) } const projectedApiKey = Object.is(searchInputProjection.value.searchApiKey, rawSearchApiKey) ? apiKey diff --git a/apps/sim/executor/handlers/router/router-handler.ts b/apps/sim/executor/handlers/router/router-handler.ts index 365453e64db..11e5445889a 100644 --- a/apps/sim/executor/handlers/router/router-handler.ts +++ b/apps/sim/executor/handlers/router/router-handler.ts @@ -24,6 +24,7 @@ import { } from '@/executor/constants' import type { BlockHandler, ExecutionContext } from '@/executor/types' import { buildAuthHeaders } from '@/executor/utils/http' +import { refuseResolvedSecretProjection } from '@/executor/utils/resolved-secret-projection-refusal' import type { ResolvedSecretInputPath } from '@/executor/utils/resolved-secret-trace-registry' import { resolveVertexCredential } from '@/executor/utils/vertex-credential' import { resolveProxiedModelCost } from '@/providers/cost-policy' @@ -80,7 +81,12 @@ export class RouterBlockHandler implements BlockHandler { promptModelInputPaths ) if (!modelInputProjection.complete) { - throw new Error('Router model input could not be safely projected') + refuseResolvedSecretProjection({ + site: 'router.promptModelInput', + message: 'Router model input could not be safely projected', + registry: ctx.resolvedSecretTraceRegistry, + inputPath: 'prompt', + }) } const targetBlocks = this.getTargetBlocks(ctx, block) @@ -251,11 +257,21 @@ export class RouterBlockHandler implements BlockHandler { modelInputPaths ) if (!modelInputProjection.complete) { - throw new Error('Router model input could not be safely projected') + refuseResolvedSecretProjection({ + site: 'router.contextModelInput', + message: 'Router model input could not be safely projected', + registry: ctx.resolvedSecretTraceRegistry, + inputPath: 'context,routes', + }) } const projectedRoutes = this.parseRoutes(modelInputProjection.value.routes) if (projectedRoutes.length !== routes.length) { - throw new Error('Router model input could not be safely projected') + refuseResolvedSecretProjection({ + site: 'router.routeArity', + message: 'Router model input could not be safely projected', + registry: ctx.resolvedSecretTraceRegistry, + inputPath: 'routes', + }) } const modelRoutes = routes.map((route, index) => ({ ...route, diff --git a/apps/sim/executor/utils/resolved-secret-projection-refusal.test.ts b/apps/sim/executor/utils/resolved-secret-projection-refusal.test.ts new file mode 100644 index 00000000000..f03318da166 --- /dev/null +++ b/apps/sim/executor/utils/resolved-secret-projection-refusal.test.ts @@ -0,0 +1,205 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { mockLogger } = vi.hoisted(() => ({ + mockLogger: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() }, +})) + +vi.mock('@sim/logger', () => ({ + createLogger: () => mockLogger, +})) + +import { refuseResolvedSecretProjection } from '@/executor/utils/resolved-secret-projection-refusal' +import { + createIncompleteResolvedSecretTraceRegistry, + ResolvedSecretTraceRegistry, +} from '@/executor/utils/resolved-secret-trace-registry' + +const scope = { userId: 'user-1', workspaceId: 'workspace-1' } + +function refusalRecords() { + return mockLogger.error.mock.calls.filter( + ([message]) => message === 'Resolved secret projection refused' + ) +} + +describe('refuseResolvedSecretProjection', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('throws the call site message unchanged so the user-facing wording never drifts', () => { + const registry = new ResolvedSecretTraceRegistry([], scope) + registry.markIncomplete('projection-mismatch') + + expect(() => + refuseResolvedSecretProjection({ + site: 'router.promptModelInput', + message: 'Router model input could not be safely projected', + registry, + }) + ).toThrow('Router model input could not be safely projected') + }) + + it('uses the call site error type when one is needed for control flow', () => { + class ToolInputSafetyError extends Error {} + const registry = new ResolvedSecretTraceRegistry([], scope) + registry.markIncomplete('projection-mismatch') + + expect(() => + refuseResolvedSecretProjection({ + site: 'agent.toolInput', + message: 'Agent tool input could not be safely projected', + registry, + createError: (message) => new ToolInputSafetyError(message), + }) + ).toThrow(ToolInputSafetyError) + }) + + it('reports the guard that caused the refusal, not merely that one occurred', () => { + const registry = new ResolvedSecretTraceRegistry([], scope) + registry.markIncomplete('projection-mismatch') + + expect(() => + refuseResolvedSecretProjection({ + site: 'router.promptModelInput', + message: 'Router model input could not be safely projected', + registry, + inputPath: 'prompt', + }) + ).toThrow() + + expect(refusalRecords()).toHaveLength(1) + expect(refusalRecords()[0][1]).toEqual( + expect.objectContaining({ + site: 'router.promptModelInput', + inputPath: 'prompt', + reason: 'projection-mismatch', + scopeWorkspaceId: 'workspace-1', + }) + ) + }) + + it('names a by-design origin that was silenced when it was marked', () => { + const registry = createIncompleteResolvedSecretTraceRegistry(scope) + expect(mockLogger.error).not.toHaveBeenCalled() + expect(mockLogger.warn).not.toHaveBeenCalled() + + expect(() => + refuseResolvedSecretProjection({ + site: 'agent.coreModelInput', + message: 'Agent model input could not be safely projected', + registry, + }) + ).toThrow() + + expect(refusalRecords()[0][1]).toEqual( + expect.objectContaining({ reason: 'constructed-incomplete' }) + ) + }) + + it('reports the originating guard through a fork that only inherited it', () => { + const parent = new ResolvedSecretTraceRegistry([], scope) + parent.markIncomplete('entry-decrypt-failed') + const fork = parent.forkForToolCall() + mockLogger.error.mockClear() + + expect(() => + refuseResolvedSecretProjection({ + site: 'agent.toolInput', + message: 'Agent tool input could not be safely projected', + registry: fork, + }) + ).toThrow() + + const details = refusalRecords()[0][1] as { reason: string; reasons: string[]; cause: string } + expect(details.reason).toBe('entry-decrypt-failed') + expect(details.reasons).toContain('inherited-incomplete-source') + expect(details.cause).toBe('registry-latched') + }) + + it('reports a repeated boundary once per registry, so a loop cannot flood', () => { + const registry = new ResolvedSecretTraceRegistry([], scope) + registry.markIncomplete('projection-mismatch') + + for (let iteration = 0; iteration < 25; iteration++) { + expect(() => + refuseResolvedSecretProjection({ + site: 'agent.toolInput', + message: 'Agent tool input could not be safely projected', + registry, + }) + ).toThrow() + } + + expect(refusalRecords()).toHaveLength(1) + }) + + it('reports distinct boundaries separately within one registry', () => { + const registry = new ResolvedSecretTraceRegistry([], scope) + registry.markIncomplete('projection-mismatch') + + for (const site of ['agent.coreModelInput', 'agent.toolInput']) { + expect(() => refuseResolvedSecretProjection({ site, message: 'refused', registry })).toThrow() + } + + expect(refusalRecords().map(([, d]) => (d as { site: string }).site)).toEqual([ + 'agent.coreModelInput', + 'agent.toolInput', + ]) + }) + + it('separates a latched registry from a caller-side cross-check', () => { + const complete = new ResolvedSecretTraceRegistry([], scope) + + expect(() => + refuseResolvedSecretProjection({ + site: 'agent.responseFormatObjectShape', + message: 'Agent model input could not be safely projected', + registry: complete, + }) + ).toThrow() + + expect(refusalRecords()[0][1]).toEqual( + expect.objectContaining({ cause: 'projection-cross-check', registryPresent: true }) + ) + }) + + it('reports the same boundary separately for different input paths', () => { + const registry = new ResolvedSecretTraceRegistry([], scope) + registry.markIncomplete('projection-mismatch') + + for (const inputPath of ['function_call', 'tool_calls.function']) { + expect(() => + refuseResolvedSecretProjection({ + site: 'memory.functionCallShape', + message: 'Memory content could not be safely projected', + registry, + inputPath, + }) + ).toThrow() + } + + expect(refusalRecords()).toHaveLength(2) + }) + + it('records no secret material', () => { + const registry = new ResolvedSecretTraceRegistry( + [{ name: 'API_KEY', plaintext: 'super-secret-value', encryptedValue: 'encrypted' }], + scope + ) + registry.recordResolved('API_KEY', 'super-secret-value') + registry.markIncomplete('projection-mismatch') + + expect(() => + refuseResolvedSecretProjection({ + site: 'agent.coreModelInput', + message: 'Agent model input could not be safely projected', + registry, + }) + ).toThrow() + + const logged = JSON.stringify(refusalRecords()) + expect(logged).not.toContain('super-secret-value') + expect(logged).not.toContain('API_KEY') + }) +}) diff --git a/apps/sim/executor/utils/resolved-secret-projection-refusal.ts b/apps/sim/executor/utils/resolved-secret-projection-refusal.ts new file mode 100644 index 00000000000..f5f3e7bff5c --- /dev/null +++ b/apps/sim/executor/utils/resolved-secret-projection-refusal.ts @@ -0,0 +1,101 @@ +import { createLogger } from '@sim/logger' +import type { ResolvedSecretTraceRegistry } from '@/executor/utils/resolved-secret-trace-registry' + +const logger = createLogger('ResolvedSecretProjectionRefusal') + +/** + * Reporting is deduplicated per registry, because one run can refuse the same boundary repeatedly: + * an agent projects tool input on every iteration of its loop, and a single latched registry would + * otherwise emit a line per iteration. + */ +const reportedSitesByRegistry = new WeakMap>() + +/** + * Keys that refused without a registry to deduplicate against. Every `site` in the tree is a build + * time literal, so this settles at one entry per site; the cap makes that a guarantee rather than a + * convention, since a future interpolated site would otherwise grow it for the process lifetime. + */ +const reportedKeysWithoutRegistry = new Set() +const MAX_REPORTED_KEYS_WITHOUT_REGISTRY = 512 + +export interface ResolvedSecretProjectionRefusal { + /** + * Stable dotted identifier for the boundary that refused, e.g. `agent.modelInput`. Chosen by the + * call site rather than derived, so it survives refactors and stays greppable. + */ + site: string + /** The message thrown to the user. Callers pass their existing text so wording never changes. */ + message: string + /** The registry whose incompleteness caused the refusal. */ + registry?: ResolvedSecretTraceRegistry + /** + * Field names of the path being projected, comma-separated when several are covered at once. + * Never a resolved value. + */ + inputPath?: string + /** Builds the thrown error when a call site needs its own type for control flow. */ + createError?: (message: string) => Error +} + +/** + * Records why a projection was refused, then throws the call site's own error. + * + * Every refusal reaches the user as the same fixed sentence whichever guard caused it, and that + * guard may have tripped many frames — or a whole process — earlier, so this is the only point + * where the failing boundary and the cause are both in hand. + * + * Returns `never`, so `if (!projection.complete) refuseResolvedSecretProjection(...)` still narrows + * the projection for the code that follows. + */ +export function refuseResolvedSecretProjection(refusal: ResolvedSecretProjectionRefusal): never { + reportRefusal(refusal) + const message = refusal.message + throw refusal.createError ? refusal.createError(message) : new Error(message) +} + +function reportRefusal({ site, registry, inputPath }: ResolvedSecretProjectionRefusal): void { + if (!shouldReport(dedupKey(site, inputPath), registry)) return + + const diagnostics = registry?.getIncompletenessDiagnostics() + logger.error('Resolved secret projection refused', { + site, + ...(inputPath ? { inputPath } : {}), + /** + * Separates the two failure families that reach this one event: a registry that latched and + * genuinely cannot vouch, versus a caller finding the projection's own output malformed. Only + * the former carries reasons, so a query filtered on `reason` would otherwise silently cover + * half of them. + */ + cause: diagnostics ? 'registry-latched' : 'projection-cross-check', + registryPresent: registry !== undefined, + ...(diagnostics + ? { + reason: diagnostics.reasons[0], + reasons: diagnostics.reasons, + incompleteInputPathCount: diagnostics.incompleteInputPathCount, + activeEntryCount: diagnostics.activeEntryCount, + ...(diagnostics.scopeWorkspaceId + ? { scopeWorkspaceId: diagnostics.scopeWorkspaceId } + : {}), + } + : {}), + }) +} + +/** Distinguishes the same boundary refusing on different paths, which are different incidents. */ +function dedupKey(site: string, inputPath: string | undefined): string { + return inputPath ? `${site}\u0000${inputPath}` : site +} + +function shouldReport(key: string, registry: ResolvedSecretTraceRegistry | undefined): boolean { + let reported = reportedKeysWithoutRegistry + if (registry) { + reported = reportedSitesByRegistry.get(registry) ?? new Set() + reportedSitesByRegistry.set(registry, reported) + } else if (reported.size >= MAX_REPORTED_KEYS_WITHOUT_REGISTRY) { + return false + } + if (reported.has(key)) return false + reported.add(key) + return true +} diff --git a/apps/sim/executor/utils/resolved-secret-trace-registry.test.ts b/apps/sim/executor/utils/resolved-secret-trace-registry.test.ts index f8906be8e12..0db5b2e881f 100644 --- a/apps/sim/executor/utils/resolved-secret-trace-registry.test.ts +++ b/apps/sim/executor/utils/resolved-secret-trace-registry.test.ts @@ -1453,6 +1453,51 @@ describe('incompleteness diagnostics', () => { ) }) + it('reports no diagnostics while it can still vouch', () => { + const registry = new ResolvedSecretTraceRegistry([], scope) + + expect(registry.getIncompletenessDiagnostics()).toBeUndefined() + }) + + it('retains the causal order of reasons, keeping the first as the originating one', () => { + const registry = new ResolvedSecretTraceRegistry([], scope) + + registry.markIncomplete('entry-decrypt-failed') + registry.markIncomplete('source-provenance-incomplete') + + const diagnostics = registry.getIncompletenessDiagnostics() + expect(diagnostics?.reasons[0]).toBe('entry-decrypt-failed') + expect(diagnostics?.reasons).toEqual(['entry-decrypt-failed', 'source-provenance-incomplete']) + }) + + it('retains every distinct reason, since the reason type is what bounds the set', () => { + const registry = new ResolvedSecretTraceRegistry([], scope) + const reasons = [ + 'entry-decrypt-failed', + 'source-provenance-incomplete', + 'projection-mismatch', + 'unresolved-placeholder', + 'provenance-capacity-exceeded', + 'tool-call-scope-mismatch', + 'untrusted-provenance', + 'value-provenance-untrusted', + 'value-provenance-import-failed', + 'unverified-resolved-entry', + ] as const + + for (const reason of reasons) registry.markIncomplete(reason) + + expect(registry.getIncompletenessDiagnostics()?.reasons).toEqual([...reasons]) + }) + + it('retains a by-design reason even though marking it reports nothing', () => { + const registry = createIncompleteResolvedSecretTraceRegistry(scope) + + expect(mockLogger.warn).not.toHaveBeenCalled() + expect(mockLogger.error).not.toHaveBeenCalled() + expect(registry.getIncompletenessDiagnostics()?.reasons[0]).toBe('constructed-incomplete') + }) + it('records no secret material alongside the reason', () => { const registry = new ResolvedSecretTraceRegistry([], scope) diff --git a/apps/sim/executor/utils/resolved-secret-trace-registry.ts b/apps/sim/executor/utils/resolved-secret-trace-registry.ts index 348a4fba6f9..55112cfe3c9 100644 --- a/apps/sim/executor/utils/resolved-secret-trace-registry.ts +++ b/apps/sim/executor/utils/resolved-secret-trace-registry.ts @@ -22,7 +22,7 @@ const logger = createLogger('ResolvedSecretTraceRegistry') * genuine containment from a matcher that merely could not decide — the reasons are static * literals and the logged path is block/field names, never a resolved value. */ -type ResolvedSecretIncompletenessReason = +export type ResolvedSecretIncompletenessReason = | 'untrusted-provenance' | 'source-provenance-incomplete' | 'entry-decrypt-failed' @@ -75,6 +75,24 @@ const BY_DESIGN_INCOMPLETENESS_REASONS = new Set() private readonly resolvedInputPaths = new Map() private readonly incompleteInputPaths = new Map() + /** Insertion-ordered; see {@link ResolvedSecretIncompletenessDiagnostics}. */ + private readonly incompletenessReasons = new Set() private activeProvenanceEntryBytes = 0 private complete = true private pendingActivations = 0 @@ -653,7 +673,7 @@ export class ResolvedSecretTraceRegistry { } this.copyResolvedInputPathsTo(fork) this.copyIncompleteInputPathsTo(fork) - if (!this.complete) fork.markIncomplete('inherited-incomplete-source') + if (!this.complete) fork.markIncomplete('inherited-incomplete-source', this) return fork } @@ -664,12 +684,12 @@ export class ResolvedSecretTraceRegistry { ): ResolvedSecretTraceRegistry { const fork = new ResolvedSecretTraceRegistry(this.catalog.values(), this.scope) if (!this.complete) { - fork.markIncomplete('inherited-incomplete-source') + fork.markIncomplete('inherited-incomplete-source', this) return fork } if (this.hasIncompleteInputPathOverlapping(paths)) { - fork.markIncomplete('inherited-incomplete-input-path') + fork.markIncomplete('inherited-incomplete-input-path', this) return fork } @@ -693,7 +713,7 @@ export class ResolvedSecretTraceRegistry { fork.addActiveEntry({ ...entry }, { propagated: true }) } } - if (this.isPermanentlyIncomplete()) fork.markIncomplete('inherited-incomplete-source') + if (this.isPermanentlyIncomplete()) fork.markIncomplete('inherited-incomplete-source', this) return fork } @@ -705,7 +725,7 @@ export class ResolvedSecretTraceRegistry { } if (!child.isComplete()) { - this.markIncomplete('inherited-incomplete-source') + this.markIncomplete('inherited-incomplete-source', child) return } @@ -1308,11 +1328,45 @@ export class ResolvedSecretTraceRegistry { return this.complete && this.incompleteInputPaths.size === 0 && this.pendingActivations === 0 } + /** + * Reports why this registry is incomplete, for a caller that is about to refuse a projection. + * + * Returns undefined while the registry can still vouch, so a caller cannot accidentally report a + * cause for a projection that succeeded. + */ + getIncompletenessDiagnostics(): ResolvedSecretIncompletenessDiagnostics | undefined { + if (!this.isPermanentlyIncomplete()) return undefined + return { + reasons: [...this.incompletenessReasons], + incompleteInputPathCount: this.incompleteInputPaths.size, + activeEntryCount: this.activeEntries.size, + ...(this.scope?.workspaceId ? { scopeWorkspaceId: this.scope.workspaceId } : {}), + } + } + + /** Retains a reason for later refusal reporting; the reason type bounds the set at its size. */ + private recordIncompletenessReason(reason: ResolvedSecretIncompletenessReason): void { + this.incompletenessReasons.add(reason) + } + + /** + * Carries a source registry's reasons into a fork or merge target, so a refusal downstream still + * names the guard that originally tripped rather than only the propagation that reached it. + */ + private inheritIncompletenessReasonsFrom(source: ResolvedSecretTraceRegistry): void { + for (const reason of source.incompletenessReasons) this.recordIncompletenessReason(reason) + } + isPermanentlyIncomplete(): boolean { return !this.complete || this.incompleteInputPaths.size > 0 } - markIncomplete(reason: ResolvedSecretIncompletenessReason = 'unspecified'): void { + markIncomplete( + reason: ResolvedSecretIncompletenessReason = 'unspecified', + source?: ResolvedSecretTraceRegistry + ): void { + if (source) this.inheritIncompletenessReasonsFrom(source) + this.recordIncompletenessReason(reason) if (!this.complete) return this.complete = false this.modelEgressRevision += 1 @@ -1743,6 +1797,7 @@ export class ResolvedSecretTraceRegistry { this.markIncomplete(reason) return } + this.recordIncompletenessReason(reason) const key = inputPathKey(path) if (this.incompleteInputPaths.has(key)) return this.incompleteInputPaths.set(key, [...path]) @@ -1766,6 +1821,7 @@ export class ResolvedSecretTraceRegistry { for (const [key, path] of this.incompleteInputPaths) { if (roots && !roots.some((root) => inputPathsOverlap(path, root))) continue target.incompleteInputPaths.set(key, [...path]) + target.inheritIncompletenessReasonsFrom(this) } } diff --git a/apps/sim/lib/copilot/request/lifecycle/run.ts b/apps/sim/lib/copilot/request/lifecycle/run.ts index b17dc94f4e2..dfa0ca2ea91 100644 --- a/apps/sim/lib/copilot/request/lifecycle/run.ts +++ b/apps/sim/lib/copilot/request/lifecycle/run.ts @@ -69,6 +69,7 @@ import { isHosted, } from '@/lib/core/config/env-flags' import { filterModelSafeWorkspaceFileAttachments } from '@/lib/uploads/contexts/workspace/workspace-file-secret-provenance' +import { refuseResolvedSecretProjection } from '@/executor/utils/resolved-secret-projection-refusal' import type { ResolvedSecretTraceRegistry } from '@/executor/utils/resolved-secret-trace-registry' const logger = createLogger('CopilotLifecycle') @@ -81,9 +82,11 @@ const MOTHERSHIP_CODE_TOOL_ROUTES = new Set([ '/api/mothership/execute', ]) +const COPILOT_MODEL_CONTENT_PROJECTION_ERROR = 'Copilot model input could not be safely projected' + class CopilotModelContentProjectionError extends Error { constructor() { - super('Copilot model input could not be safely projected') + super(COPILOT_MODEL_CONTENT_PROJECTION_ERROR) this.name = 'CopilotModelContentProjectionError' } } @@ -96,7 +99,14 @@ async function omitUnsafeInitialCopilotAttachments( for (const key of ['attachments', 'fileAttachments'] as const) { if (!Object.hasOwn(projected, key)) continue const attachments = projected[key] - if (!Array.isArray(attachments)) throw new CopilotModelContentProjectionError() + if (!Array.isArray(attachments)) { + refuseResolvedSecretProjection({ + site: 'copilot.initialAttachmentsShape', + message: COPILOT_MODEL_CONTENT_PROJECTION_ERROR, + inputPath: key, + createError: () => new CopilotModelContentProjectionError(), + }) + } let safeAttachments: unknown[] try { @@ -106,7 +116,12 @@ async function omitUnsafeInitialCopilotAttachments( attachmentCount: attachments.length, error: toError(error).message, }) - throw new CopilotModelContentProjectionError() + refuseResolvedSecretProjection({ + site: 'copilot.initialAttachmentsProvenance', + message: COPILOT_MODEL_CONTENT_PROJECTION_ERROR, + inputPath: key, + createError: () => new CopilotModelContentProjectionError(), + }) } if (safeAttachments.length === attachments.length) continue diff --git a/apps/sim/lib/guardrails/validate_hallucination.ts b/apps/sim/lib/guardrails/validate_hallucination.ts index a3396a37ce9..7185236baaf 100644 --- a/apps/sim/lib/guardrails/validate_hallucination.ts +++ b/apps/sim/lib/guardrails/validate_hallucination.ts @@ -22,6 +22,7 @@ import { } from '@/lib/execution/private-tool-metadata' import { refreshTokenIfNeeded } from '@/app/api/auth/oauth/utils' import { projectResolvedSecretModelContent } from '@/executor/utils/resolved-secret-content-projection' +import { refuseResolvedSecretProjection } from '@/executor/utils/resolved-secret-projection-refusal' import type { ResolvedSecretTraceRegistry } from '@/executor/utils/resolved-secret-trace-registry' import { executeProviderRequest } from '@/providers' import { isAbortError } from '@/providers/streaming-tool-loop-shared' @@ -388,7 +389,12 @@ export async function validateHallucination( !Array.isArray(contextProjection.value) || !contextProjection.value.every((value) => typeof value === 'string') ) { - throw new Error('Hallucination model input could not be safely projected') + refuseResolvedSecretProjection({ + site: 'guardrails.hallucinationModelInput', + message: 'Hallucination model input could not be safely projected', + registry: inputRegistry, + inputPath: 'input', + }) } const providerRegistry = inputRegistry diff --git a/apps/sim/lib/knowledge/model-input-provenance.ts b/apps/sim/lib/knowledge/model-input-provenance.ts index fb244dc2ca9..af4ea906476 100644 --- a/apps/sim/lib/knowledge/model-input-provenance.ts +++ b/apps/sim/lib/knowledge/model-input-provenance.ts @@ -2,6 +2,7 @@ import { AsyncLocalStorage } from 'node:async_hooks' import { prepareCopilotEnvironmentContext } from '@/lib/copilot/environment-context' import { inspectModelInputProvenanceRequest } from '@/lib/execution/model-input-provenance' import { projectResolvedSecretModelContent } from '@/executor/utils/resolved-secret-content-projection' +import { refuseResolvedSecretProjection } from '@/executor/utils/resolved-secret-projection-refusal' import { isResolvedSecretTraceProvenanceV1, ResolvedSecretTraceRegistry, @@ -91,8 +92,13 @@ export function runWithKnowledgeModelInputProvenance( /** Rejects opaque bytes/URLs that cannot be selectively projected before an external model call. */ export function assertKnowledgeOpaqueModelInputSafe(): void { - if (knowledgeModelInputContext.getStore()?.opaqueInputSafe === false) { - throw new Error(MODEL_INPUT_PROJECTION_ERROR) + const context = knowledgeModelInputContext.getStore() + if (context?.opaqueInputSafe === false) { + refuseResolvedSecretProjection({ + site: 'knowledge.opaqueModelInputSafety', + message: MODEL_INPUT_PROJECTION_ERROR, + registry: context.registry, + }) } } @@ -100,7 +106,11 @@ export function assertKnowledgeOpaqueModelInputSafe(): void { export function getKnowledgeOpaqueModelInputRegistry(): ResolvedSecretTraceRegistry { const context = knowledgeModelInputContext.getStore() if (!context?.opaqueInputSafe) { - throw new Error(MODEL_INPUT_PROJECTION_ERROR) + refuseResolvedSecretProjection({ + site: 'knowledge.opaqueModelInputRegistry', + message: MODEL_INPUT_PROJECTION_ERROR, + registry: context?.registry, + }) } return context.registry ?? new ResolvedSecretTraceRegistry() } @@ -112,7 +122,11 @@ export function projectKnowledgeModelInput(value: string): string { const projection = projectResolvedSecretModelContent(value, registry) if (!projection.safe || typeof projection.value !== 'string') { - throw new Error(MODEL_INPUT_PROJECTION_ERROR) + refuseResolvedSecretProjection({ + site: 'knowledge.modelInput', + message: MODEL_INPUT_PROJECTION_ERROR, + registry, + }) } return projection.value } @@ -128,7 +142,11 @@ export function projectKnowledgeModelInputs(values: readonly string[]): string[] !Array.isArray(projection.value) || !projection.value.every((value) => typeof value === 'string') ) { - throw new Error(MODEL_INPUT_PROJECTION_ERROR) + refuseResolvedSecretProjection({ + site: 'knowledge.modelInputs', + message: MODEL_INPUT_PROJECTION_ERROR, + registry, + }) } return projection.value } diff --git a/apps/sim/tools/request-transport.ts b/apps/sim/tools/request-transport.ts index 64d1f3c20ab..41bca3e8e99 100644 --- a/apps/sim/tools/request-transport.ts +++ b/apps/sim/tools/request-transport.ts @@ -7,6 +7,7 @@ import { createPrivateSecretProvenanceRequestMetadata, markModelInputProjected, } from '@/lib/execution/model-input-provenance' +import { refuseResolvedSecretProjection } from '@/executor/utils/resolved-secret-projection-refusal' import type { ResolvedSecretTraceRegistry } from '@/executor/utils/resolved-secret-trace-registry' import type { ToolConfig } from '@/tools/types' @@ -117,7 +118,11 @@ export function projectToolModelInputParams( return patchedParams } catch { - throw new Error(MODEL_INPUT_PROJECTION_ERROR_MESSAGE) + refuseResolvedSecretProjection({ + site: 'tools.requestTransportModelInput', + message: MODEL_INPUT_PROJECTION_ERROR_MESSAGE, + registry, + }) } } From 398e2a3db93dab5d5cb619b7a20628cabc3d6c6e Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Sun, 9 Aug 2026 20:02:30 -0700 Subject: [PATCH 2/3] 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. --- ...resolved-secret-projection-refusal.test.ts | 13 +++++++++ .../resolved-secret-projection-refusal.ts | 27 +++++++++---------- 2 files changed, 25 insertions(+), 15 deletions(-) diff --git a/apps/sim/executor/utils/resolved-secret-projection-refusal.test.ts b/apps/sim/executor/utils/resolved-secret-projection-refusal.test.ts index f03318da166..17cdfef8d63 100644 --- a/apps/sim/executor/utils/resolved-secret-projection-refusal.test.ts +++ b/apps/sim/executor/utils/resolved-secret-projection-refusal.test.ts @@ -182,6 +182,19 @@ describe('refuseResolvedSecretProjection', () => { expect(refusalRecords()).toHaveLength(2) }) + it('reports a registry-less refusal every time, since a later request is a new incident', () => { + for (let request = 0; request < 3; request++) { + expect(() => + refuseResolvedSecretProjection({ + site: 'copilot.initialAttachmentsShape', + message: 'Copilot model input could not be safely projected', + }) + ).toThrow() + } + + expect(refusalRecords()).toHaveLength(3) + }) + it('records no secret material', () => { const registry = new ResolvedSecretTraceRegistry( [{ name: 'API_KEY', plaintext: 'super-secret-value', encryptedValue: 'encrypted' }], diff --git a/apps/sim/executor/utils/resolved-secret-projection-refusal.ts b/apps/sim/executor/utils/resolved-secret-projection-refusal.ts index f5f3e7bff5c..bd8837d7245 100644 --- a/apps/sim/executor/utils/resolved-secret-projection-refusal.ts +++ b/apps/sim/executor/utils/resolved-secret-projection-refusal.ts @@ -10,14 +10,6 @@ const logger = createLogger('ResolvedSecretProjectionRefusal') */ const reportedSitesByRegistry = new WeakMap>() -/** - * Keys that refused without a registry to deduplicate against. Every `site` in the tree is a build - * time literal, so this settles at one entry per site; the cap makes that a guarantee rather than a - * convention, since a future interpolated site would otherwise grow it for the process lifetime. - */ -const reportedKeysWithoutRegistry = new Set() -const MAX_REPORTED_KEYS_WITHOUT_REGISTRY = 512 - export interface ResolvedSecretProjectionRefusal { /** * Stable dotted identifier for the boundary that refused, e.g. `agent.modelInput`. Chosen by the @@ -87,14 +79,19 @@ function dedupKey(site: string, inputPath: string | undefined): string { return inputPath ? `${site}\u0000${inputPath}` : site } +/** + * Deduplicates only against a registry, whose lifetime is the run that refused. + * + * A refusal with no registry is never deduplicated: those sites abort the request rather than + * iterate, so each reaches this at most once per request, and any process-wide memory of them would + * silence every later request — including the one being investigated. A new registry-less site + * placed inside a loop would therefore repeat; give it a registry instead. + */ function shouldReport(key: string, registry: ResolvedSecretTraceRegistry | undefined): boolean { - let reported = reportedKeysWithoutRegistry - if (registry) { - reported = reportedSitesByRegistry.get(registry) ?? new Set() - reportedSitesByRegistry.set(registry, reported) - } else if (reported.size >= MAX_REPORTED_KEYS_WITHOUT_REGISTRY) { - return false - } + if (!registry) return true + + const reported = reportedSitesByRegistry.get(registry) ?? new Set() + reportedSitesByRegistry.set(registry, reported) if (reported.has(key)) return false reported.add(key) return true From 335f2c10f64db46f1e5607fc44f1fb0e8477a7ff Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Sun, 9 Aug 2026 20:14:45 -0700 Subject: [PATCH 3/3] improvement(provenance): inherit copied-path reasons once per copy MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- apps/sim/executor/utils/resolved-secret-trace-registry.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/apps/sim/executor/utils/resolved-secret-trace-registry.ts b/apps/sim/executor/utils/resolved-secret-trace-registry.ts index 55112cfe3c9..853573eae6d 100644 --- a/apps/sim/executor/utils/resolved-secret-trace-registry.ts +++ b/apps/sim/executor/utils/resolved-secret-trace-registry.ts @@ -1818,11 +1818,13 @@ export class ResolvedSecretTraceRegistry { target: ResolvedSecretTraceRegistry, roots?: readonly ResolvedSecretInputPath[] ): void { + let copied = false for (const [key, path] of this.incompleteInputPaths) { if (roots && !roots.some((root) => inputPathsOverlap(path, root))) continue target.incompleteInputPaths.set(key, [...path]) - target.inheritIncompletenessReasonsFrom(this) + copied = true } + if (copied) target.inheritIncompletenessReasonsFrom(this) } private addActiveEntry(entry: ActiveSecretEntry, options: { propagated?: boolean } = {}): void {