diff --git a/apps/sim/app/api/function/execute/route.test.ts b/apps/sim/app/api/function/execute/route.test.ts index ac983adfcbb..91c9fb403f2 100644 --- a/apps/sim/app/api/function/execute/route.test.ts +++ b/apps/sim/app/api/function/execute/route.test.ts @@ -590,7 +590,7 @@ describe('Function Execute API Route', () => { createMockRequest( 'POST', { - code: 'return environmentVariables.API_KEY', + code: 'return {{API_KEY}}', envVars: { API_KEY: 'secret-at-the-end' }, workflowId: 'workflow-1', workspaceId: 'workspace-1', @@ -686,7 +686,7 @@ describe('Function Execute API Route', () => { createMockRequest( 'POST', { - code: 'print("done")', + code: 'print("{{API_KEY}}")', language: 'python', workspaceId: 'workspace-1', envVars: { API_KEY: 'secret-value' }, @@ -821,6 +821,54 @@ describe('Function Execute API Route', () => { expect(mockExecuteInSandbox).not.toHaveBeenCalled() }) + it('runs with authenticated incomplete mount provenance and marks exported bytes unknown', async () => { + envFlagsMock.isRemoteSandboxEnabled = true + mockExecuteInSandbox.mockResolvedValueOnce({ + result: 'raw result', + stdout: '', + sandboxId: 'sandbox-123', + exportedFiles: { '/home/user/output.txt': 'raw output' }, + }) + + const response = await POST( + createMockRequest( + 'POST', + { + code: 'print("done")', + language: 'python', + workspaceId: 'workspace-1', + outputs: { + files: [ + { + path: 'files/output.txt', + sandboxPath: '/home/user/output.txt', + mimeType: 'text/plain', + }, + ], + }, + [PRIVATE_SECRET_PROVENANCE_FIELD]: { + version: 1, + complete: false, + selections: [], + }, + }, + { [PRIVATE_SECRET_PROVENANCE_HEADER]: PRIVATE_SECRET_PROVENANCE_BUNDLE_V1 } + ) + ) + + expect(response.status).toBe(200) + expect((await response.json()).output.result).toEqual( + expect.objectContaining({ fileId: 'wf_output_txt', vfsPath: 'files/output.txt' }) + ) + expect(mockExecuteInSandbox).toHaveBeenCalledOnce() + expect(mockWriteWorkspaceFileByPath).toHaveBeenCalledWith( + expect.objectContaining({ + buffer: Buffer.from('raw output'), + secretProvenance: { status: 'unknown' }, + }) + ) + }) + it('does not rewrite a static export path that happens to equal a resolved secret', async () => { envFlagsMock.isRemoteSandboxEnabled = true mockExecuteInSandbox.mockResolvedValueOnce({ @@ -853,6 +901,7 @@ describe('Function Execute API Route', () => { expect(mockWriteWorkspaceFileByPath).toHaveBeenCalledWith( expect.objectContaining({ target: expect.objectContaining({ path: 'files/report-secret-value.txt' }), + secretProvenance: { status: 'exact', entries: [] }, }) ) expect(JSON.stringify(data)).toContain('files/report-secret-value.txt') @@ -890,7 +939,7 @@ describe('Function Execute API Route', () => { ) }) - it('keeps a binary export unknown when files were mounted without a provenance envelope', async () => { + it('classifies a binary export exact-empty when ordinary files were mounted without secret provenance', async () => { envFlagsMock.isRemoteSandboxEnabled = true mockExecuteInSandbox.mockResolvedValueOnce({ result: 'done', @@ -919,7 +968,7 @@ describe('Function Execute API Route', () => { expect(response.status).toBe(200) expect(mockWriteWorkspaceFileByPath).toHaveBeenCalledWith( - expect.objectContaining({ secretProvenance: { status: 'unknown' } }) + expect.objectContaining({ secretProvenance: { status: 'exact', entries: [] } }) ) }) @@ -987,7 +1036,7 @@ describe('Function Execute API Route', () => { const response = await POST( createMockRequest('POST', { - code: 'print("done")', + code: 'print("{{API_KEY}}")', language: 'python', workspaceId: 'workspace-1', envVars: { API_KEY: 'secret-value' }, @@ -2002,6 +2051,117 @@ describe('Function Execute API Route', () => { expect(Object.values(request.contextVariables)).not.toContain('must-not-bind') }) + it('does not infer provenance from an unused low-entropy environment value', async () => { + mockExecuteInIsolatedVM.mockResolvedValueOnce({ result: 'Box eSign', stdout: '' }) + + const response = await POST( + createMockRequest( + 'POST', + { + code: 'return "Box eSign"', + envVars: { SERVICENOW_PASSWORD: 'x' }, + }, + { 'x-sim-request-private-tool-metadata': 'resolved-secret-names-v1' } + ) + ) + const data = await response.json() + + expect(response.status).toBe(200) + expect(data.output.result).toBe('Box eSign') + expect(data.__resolvedSecretNames).toEqual([]) + }) + + it('does not build provenance matchers for unused oversized environment values', async () => { + mockExecuteInIsolatedVM.mockResolvedValueOnce({ result: 'safe', stdout: '' }) + + const response = await POST( + createMockRequest( + 'POST', + { + code: 'return "safe"', + envVars: { UNUSED: 'x'.repeat(65 * 1024) }, + }, + { 'x-sim-request-private-tool-metadata': 'resolved-secret-names-v1' } + ) + ) + + expect(response.status).toBe(200) + expect((await response.json()).__resolvedSecretNames).toEqual([]) + }) + + it('conservatively reports only compiled secrets when bounded output classification is exceeded', async () => { + const result = Array.from({ length: 100_001 }, () => 'ordinary') + mockExecuteInIsolatedVM.mockResolvedValueOnce({ result, stdout: '' }) + + const response = await POST( + createMockRequest( + 'POST', + { + code: 'const key = {{API_KEY}}; return params.items', + params: { items: result }, + envVars: { API_KEY: 'secret-value', UNUSED: 'x' }, + }, + { 'x-sim-request-private-tool-metadata': 'resolved-secret-names-v1' } + ) + ) + const data = await response.json() + + expect(response.status).toBe(200) + expect(response.headers.get('x-sim-private-tool-metadata')).toBe('resolved-secret-names-v1') + expect(data.output.result).toHaveLength(100_001) + expect(data.output.result[0]).toBe('ordinary') + expect(data.__resolvedSecretNames).toEqual(['API_KEY']) + }) + + it('conservatively reports a compiled secret whose value exceeds matcher capacity', async () => { + mockExecuteInIsolatedVM.mockResolvedValueOnce({ result: 'ordinary', stdout: '' }) + + const response = await POST( + createMockRequest( + 'POST', + { + code: 'const key = {{OVERSIZED_SECRET}}; return "ordinary"', + envVars: { OVERSIZED_SECRET: 's'.repeat(64 * 1024 + 1), UNUSED: 'x' }, + }, + { 'x-sim-request-private-tool-metadata': 'resolved-secret-names-v1' } + ) + ) + const data = await response.json() + + expect(response.status).toBe(200) + expect(data.output.result).toBe('ordinary') + expect(data.__resolvedSecretNames).toEqual(['OVERSIZED_SECRET']) + }) + + it('tracks only compiled names when configured secrets share the same value', async () => { + mockExecuteInIsolatedVM.mockResolvedValueOnce({ result: 'true', stdout: '' }) + const oneResponse = await POST( + createMockRequest( + 'POST', + { + code: 'return {{SECOND}}', + envVars: { FIRST: 'true', SECOND: 'true' }, + }, + { 'x-sim-request-private-tool-metadata': 'resolved-secret-names-v1' } + ) + ) + + mockExecuteInIsolatedVM.mockResolvedValueOnce({ result: 'true', stdout: '' }) + const bothResponse = await POST( + createMockRequest( + 'POST', + { + code: 'const first = {{FIRST}}; return {{SECOND}}', + envVars: { FIRST: 'true', SECOND: 'true' }, + }, + { 'x-sim-request-private-tool-metadata': 'resolved-secret-names-v1' } + ) + ) + + expect((await oneResponse.json()).__resolvedSecretNames).toEqual(['SECOND']) + expect((await bothResponse.json()).__resolvedSecretNames).toEqual(['FIRST', 'SECOND']) + }) + it('lowers missing shell placeholders while preserving comments and heredoc delimiters', async () => { envFlagsMock.isRemoteSandboxEnabled = true const response = await POST( @@ -2134,7 +2294,7 @@ describe('Function Execute API Route', () => { expect(mockExecuteInSandbox).not.toHaveBeenCalled() }) - it('reports exact secret values returned through placeholders and the environment map', async () => { + it('reports exact secret values returned through placeholders without inferring direct environment reads', async () => { mockExecuteInIsolatedVM.mockResolvedValueOnce({ result: 'secret-valueother-secret', stdout: '', @@ -2171,14 +2331,15 @@ describe('Function Execute API Route', () => { const directData = await directResponse.json() expect(envData.__resolvedSecretNames).toEqual(['ENV_ONLY', 'SHARED']) - expect(directData.__resolvedSecretNames).toEqual(['API_KEY']) + expect(directData.output.result).toBe('secret-value') + expect(directData.__resolvedSecretNames).toEqual([]) }) it.each([ { name: 'numeric', secret: '123', result: 123 }, { name: 'boolean', secret: 'true', result: true }, ])( - 'records provenance for a typed $name secret returned through direct environment access', + 'preserves a typed $name value returned through legacy direct environment access without inferred provenance', async ({ secret, result }) => { mockExecuteInIsolatedVM.mockResolvedValueOnce({ result, stdout: '' }) @@ -2197,11 +2358,11 @@ describe('Function Execute API Route', () => { const data = await response.json() expect(data.output.result).toBe(result) - expect(data.__resolvedSecretNames).toEqual(['API_KEY']) + expect(data.__resolvedSecretNames).toEqual([]) } ) - it('reports shell substitutions and exact secret output from direct environment access', async () => { + it('reports placeholder output without inferring provenance from legacy shell environment access', async () => { envFlagsMock.isRemoteSandboxEnabled = true mockExecuteShellInSandbox.mockResolvedValueOnce({ result: null, @@ -2245,7 +2406,8 @@ describe('Function Execute API Route', () => { const directData = await directResponse.json() expect(referencedData.__resolvedSecretNames).toEqual(['API_KEY']) - expect(directData.__resolvedSecretNames).toEqual(['API_KEY']) + expect(directData.output.stdout).toBe('secret-value') + expect(directData.__resolvedSecretNames).toEqual([]) }) it('returns nonzero shell stderr as a visible 422 error and diagnostic output', async () => { @@ -2289,8 +2451,8 @@ describe('Function Execute API Route', () => { ) expect(response.status).toBe(200) - expect((await response.json()).__resolvedSecretNames).toBeUndefined() - expect(response.headers.get('x-sim-private-tool-metadata')).toBeNull() + expect((await response.json()).__resolvedSecretNames).toEqual([]) + expect(response.headers.get('x-sim-private-tool-metadata')).toBe('resolved-secret-names-v1') expect(mockExecuteInIsolatedVM).toHaveBeenCalled() }) diff --git a/apps/sim/app/api/function/execute/route.ts b/apps/sim/app/api/function/execute/route.ts index 5d3d95df6d3..6dbcacffebb 100644 --- a/apps/sim/app/api/function/execute/route.ts +++ b/apps/sim/app/api/function/execute/route.ts @@ -113,8 +113,6 @@ const TAG_PATTERN = createReferencePattern() const E2B_JS_WRAPPER_LINES = 3 const E2B_PYTHON_WRAPPER_LINES = 1 const MAX_SANDBOX_OUTPUT_FILES = 20 -const MAX_PRIVATE_RESOLVED_SECRET_NAMES = 10_000 -const MAX_PRIVATE_RESOLVED_SECRET_NAMES_BYTES = 1024 * 1024 const MAX_PRIVATE_FILE_SECRET_MATCH_EVENTS = 1_000_000 const SANDBOX_RUNTIME_PAYLOAD_PATH_ENV = '__SIM_RUNTIME_PAYLOAD_PATH' @@ -984,12 +982,10 @@ interface FunctionRouteExecutionContext { resolvedSecretNames: Set includePrivateResolvedSecretNames: boolean privateResolvedSecretNamesMetadataType?: ResolvedSecretNamesMetadataType - outputProvenanceComplete: boolean outputSecretMatcher?: ResolvedSecretMatcher outputSecretNamesByScanLiteral: Map outputSecretPlaintextsByName: Map mountedFileSecretProvenanceScanner?: MountedFileSecretProvenanceScanner - hasMountedSandboxFiles: boolean } type ResolvedSecretNamesMetadataType = @@ -1007,10 +1003,16 @@ function inspectMountedWorkspaceFileProvenance( ): MountedWorkspaceFileProvenanceInspection { const inspection = inspectPrivateSecretProvenanceRequest(headers, body) if (inspection.status === 'unsupported') return { status: 'none' } + if (inspection.status !== 'verified' || !isPrivateSecretProvenanceBundleV1(inspection.value)) { + return { status: 'invalid' } + } + if (!inspection.value.complete) { + return { + status: 'verified', + provenance: { version: 1, complete: false, entries: [] }, + } + } if ( - inspection.status !== 'verified' || - !isPrivateSecretProvenanceBundleV1(inspection.value) || - !inspection.value.complete || inspection.value.selections.length !== 1 || inspection.value.selections[0]?.key !== MOUNTED_WORKSPACE_FILES_PROVENANCE_KEY ) { @@ -1188,7 +1190,10 @@ function activateOutputSecretProvenance( body: unknown, context: FunctionRouteExecutionContext ): void { - if (!context.outputSecretMatcher) return + if (!context.outputSecretMatcher) { + activateCompiledSecretProvenance(context) + return + } const matchedPlaintexts = new Set() const projection = projectResolvedSecretContent( @@ -1200,7 +1205,7 @@ function activateOutputSecretProvenance( } ) if (!projection.safe) { - context.outputProvenanceComplete = false + activateCompiledSecretProvenance(context) return } for (const plaintext of matchedPlaintexts) { @@ -1211,19 +1216,24 @@ function activateOutputSecretProvenance( } /** - * True when any secret material was in scope for this execution — a mounted environment secret, or - * a secret carried by a mounted input file. When false, nothing secret ever reached the sandbox, so - * no export of any kind can carry one. - * - * Mounted bytes are classified from the caller's provenance envelope. Files mounted *without* one - * are unclassifiable rather than clean: absence of an envelope is absence of evidence, not evidence - * the mount carried nothing. Those fail closed here so the classification can never be stronger - * than what the caller actually attested to. + * Conservatively activates only secrets whose placeholders were compiled for this invocation. + * This fallback is used when the bounded output classifier cannot inspect a result; it never + * considers configured-but-unused environment values and never mutates the functional result. + */ +function activateCompiledSecretProvenance(context: FunctionRouteExecutionContext): void { + for (const name of context.outputSecretPlaintextsByName.keys()) { + context.resolvedSecretNames.add(name) + } +} + +/** + * True when this execution compiled a secret placeholder or received a mounted file with verified + * secret provenance. Ordinary mounts without a provenance envelope are user data, not evidence that + * a Sim secret was resolved in this call. */ function hasSecretMaterialInScope(context: FunctionRouteExecutionContext): boolean { if (context.outputSecretPlaintextsByName.size > 0) return true - const scanner = context.mountedFileSecretProvenanceScanner - return scanner ? scanner.hasSecrets : context.hasMountedSandboxFiles + return context.mountedFileSecretProvenanceScanner?.hasSecrets ?? false } /** @@ -1268,7 +1278,6 @@ async function getOutputFileSecretProvenance( MAX_PRIVATE_FILE_SECRET_MATCH_EVENTS ) } catch { - context.outputProvenanceComplete = false return { status: 'unknown' } } @@ -1293,17 +1302,8 @@ async function getOutputFileSecretProvenance( } } -function getPrivateResolvedSecretNames(context: FunctionRouteExecutionContext): string[] | null { - if (!context.outputProvenanceComplete) return null - if (context.resolvedSecretNames.size > MAX_PRIVATE_RESOLVED_SECRET_NAMES) return null - - const names = Array.from(context.resolvedSecretNames).sort() - let bytes = 0 - for (const name of names) { - bytes += Buffer.byteLength(name, 'utf8') - if (bytes > MAX_PRIVATE_RESOLVED_SECRET_NAMES_BYTES) return null - } - return names +function getPrivateResolvedSecretNames(context: FunctionRouteExecutionContext): string[] { + return Array.from(context.resolvedSecretNames).sort() } async function appendResolvedSecretNames( @@ -1327,21 +1327,17 @@ async function appendPrivateResolvedSecretNames( ): Promise { if (!names || !metadataType) return response - try { - const body = (await response.clone().json()) as Record - const headers = new Headers(response.headers) - headers.delete('content-length') - headers.set(PRIVATE_TOOL_METADATA_RESPONSE_HEADER, metadataType) - return NextResponse.json( - { - ...body, - [RESOLVED_SECRET_NAMES_FIELD]: names, - }, - { status: response.status, statusText: response.statusText, headers } - ) - } catch { - return response - } + const body = (await response.json()) as Record + const headers = new Headers(response.headers) + headers.delete('content-length') + headers.set(PRIVATE_TOOL_METADATA_RESPONSE_HEADER, metadataType) + return NextResponse.json( + { + ...body, + [RESOLVED_SECRET_NAMES_FIELD]: names, + }, + { status: response.status, statusText: response.statusText, headers } + ) } /** @@ -2026,33 +2022,9 @@ export const POST = withRouteHandler(async (req: NextRequest) => { resolvedSecretNames: new Set(), includePrivateResolvedSecretNames, privateResolvedSecretNamesMetadataType, - outputProvenanceComplete: true, outputSecretNamesByScanLiteral: new Map(), outputSecretPlaintextsByName: new Map(), mountedFileSecretProvenanceScanner, - hasMountedSandboxFiles: (_sandboxFiles?.length ?? 0) > 0, - } - for (const [name, plaintext] of Object.entries(envVars)) { - if (!plaintext) continue - routeContext.outputSecretPlaintextsByName.set(name, plaintext) - const scanLiterals = new Set([plaintext, JSON.stringify(plaintext).slice(1, -1)]) - for (const scanLiteral of scanLiterals) { - const names = routeContext.outputSecretNamesByScanLiteral.get(scanLiteral) ?? [] - names.push(name) - routeContext.outputSecretNamesByScanLiteral.set(scanLiteral, names) - } - } - if (routeContext.outputSecretNamesByScanLiteral.size > 0) { - try { - routeContext.outputSecretMatcher = createResolvedSecretMatcher( - [...routeContext.outputSecretNamesByScanLiteral].map(([plaintext, names]) => ({ - plaintext, - replacement: `{{${names[0]}}}`, - })) - ) - } catch { - routeContext.outputProvenanceComplete = false - } } const lang = isValidCodeLanguage(language) ? language : DEFAULT_CODE_LANGUAGE @@ -2081,6 +2053,30 @@ export const POST = withRouteHandler(async (req: NextRequest) => { environmentVariables: envVars, reservedNames: Object.keys(contextVariables), }) + for (const name of compilation.resolvedSecretNames) { + if (!Object.hasOwn(envVars, name)) continue + const plaintext = envVars[name] + if (!plaintext) continue + routeContext.outputSecretPlaintextsByName.set(name, plaintext) + const scanLiterals = new Set([plaintext, JSON.stringify(plaintext).slice(1, -1)]) + for (const scanLiteral of scanLiterals) { + const names = routeContext.outputSecretNamesByScanLiteral.get(scanLiteral) ?? [] + names.push(name) + routeContext.outputSecretNamesByScanLiteral.set(scanLiteral, names) + } + } + if (routeContext.outputSecretNamesByScanLiteral.size > 0) { + try { + routeContext.outputSecretMatcher = createResolvedSecretMatcher( + [...routeContext.outputSecretNamesByScanLiteral].map(([plaintext, names]) => ({ + plaintext, + replacement: `{{${[...names].sort()[0]}}}`, + })) + ) + } catch { + activateCompiledSecretProvenance(routeContext) + } + } resolvedCode = compilation.code compilerInternalIdentifiers = [...compilation.internalIdentifiers] compilerPrivateInputs = [...compilation.privateInputs] diff --git a/apps/sim/app/api/guardrails/validate/route.test.ts b/apps/sim/app/api/guardrails/validate/route.test.ts index 3f6963a9f9d..5ae3dbe996a 100644 --- a/apps/sim/app/api/guardrails/validate/route.test.ts +++ b/apps/sim/app/api/guardrails/validate/route.test.ts @@ -115,11 +115,11 @@ describe('POST /api/guardrails/validate', () => { }, }) mockValidateHallucination.mockResolvedValue({ passed: true, score: 8 }) - mockImportProvenance.mockResolvedValue(true) + mockImportProvenance.mockResolvedValue({ success: true, matched: true }) mockRegistryIsComplete.mockReturnValue(true) mockPrepareCopilotEnvironmentContext.mockResolvedValue({ resolvedSecretTraceRegistry: { - importProvenanceForValue: mockImportProvenance, + importProvenanceForValueAtInputPath: mockImportProvenance, isComplete: mockRegistryIsComplete, }, }) @@ -242,7 +242,7 @@ describe('POST /api/guardrails/validate', () => { ) expect(res.status).toBe(200) - expect(mockImportProvenance).toHaveBeenCalledWith(provenance, 'secret value', { + expect(mockImportProvenance).toHaveBeenCalledWith(provenance, 'secret value', ['input'], { trusted: true, }) }) @@ -371,7 +371,7 @@ describe('POST /api/guardrails/validate', () => { ) }) - it('rejects a headerless internal hallucination check before model execution', async () => { + it('preserves a headerless legacy internal hallucination check', async () => { hybridAuthMockFns.mockCheckSessionOrInternalAuth.mockResolvedValue({ success: true, userId: 'user-1', @@ -388,9 +388,9 @@ describe('POST /api/guardrails/validate', () => { }) ) - expect(res.status).toBe(400) + expect(res.status).toBe(200) expect(mockImportProvenance).not.toHaveBeenCalled() - expect(mockValidateHallucination).not.toHaveBeenCalled() + expect(mockValidateHallucination).toHaveBeenCalled() }) it('rejects invalid internal billing attribution as a protocol error', async () => { diff --git a/apps/sim/app/api/guardrails/validate/route.ts b/apps/sim/app/api/guardrails/validate/route.ts index 9a3f36df36b..f0e50dcfa9d 100644 --- a/apps/sim/app/api/guardrails/validate/route.ts +++ b/apps/sim/app/api/guardrails/validate/route.ts @@ -257,25 +257,19 @@ export const POST = withRouteHandler(async (request: NextRequest) => { if (provenanceInspection.status === 'invalid') { return NextResponse.json({ error: 'Invalid model input provenance' }, { status: 400 }) } - if ( - provenanceInspection.status === 'unsupported' && - auth.authType === AuthType.INTERNAL_JWT - ) { - return NextResponse.json( - { error: 'Model input provenance is unavailable' }, - { status: 400 } - ) - } if (provenanceInspection.status === 'verified' && auth.authType !== AuthType.INTERNAL_JWT) { return NextResponse.json({ error: 'Invalid model input provenance' }, { status: 400 }) } const provenanceReady = provenanceInspection.status === 'verified' - ? await resolvedSecretTraceRegistry.importProvenanceForValue( - provenanceInspection.value, - inputStr, - { trusted: true } - ) + ? ( + await resolvedSecretTraceRegistry.importProvenanceForValueAtInputPath( + provenanceInspection.value, + inputStr, + ['input'], + { trusted: true } + ) + ).success : true if (!provenanceReady || !resolvedSecretTraceRegistry.isComplete()) { return NextResponse.json( diff --git a/apps/sim/app/api/knowledge/secret-provenance.test.ts b/apps/sim/app/api/knowledge/secret-provenance.test.ts index 75467c52dcd..305a65926e1 100644 --- a/apps/sim/app/api/knowledge/secret-provenance.test.ts +++ b/apps/sim/app/api/knowledge/secret-provenance.test.ts @@ -230,7 +230,7 @@ describe('knowledge write secret provenance', () => { if (!result.success) expect(result.response.status).toBe(400) }) - it('rejects an unavailable verified selection before a write can start', () => { + it('persists authenticated unavailable selection lineage as unknown', () => { const bundle = { version: 1 as const, complete: true, @@ -257,7 +257,111 @@ describe('knowledge write secret provenance', () => { selectionKeys: ['document-source:0'], }) - expect(result.success).toBe(false) - if (!result.success) expect(result.response.status).toBe(400) + expect(result).toEqual({ success: true, provenances: [{ status: 'unknown' }] }) + }) + + it('persists an authenticated incomplete document bundle as unknown', () => { + const payload = { + [PRIVATE_SECRET_PROVENANCE_FIELD]: { + version: 1 as const, + complete: false, + selections: [], + }, + } + + const result = resolveKnowledgeDocumentWriteSecretProvenance({ + request: createRequest(payload), + payload, + authType: AuthType.INTERNAL_JWT, + userId: 'user-1', + workspaceId: 'workspace-1', + documents: [{ documentTagsData: JSON.stringify([{ tagName: 'region', value: 'west' }]) }], + }) + + expect(result).toEqual({ + success: true, + provenances: [ + { + filename: { status: 'unknown' }, + content: { status: 'unknown' }, + tags: [{ tagName: 'region', provenance: { status: 'unknown' } }], + }, + ], + }) + }) + + it('keeps persisted tag names raw while retaining tag-value provenance', () => { + const documentTagsData = JSON.stringify([{ tagName: 'private-name', value: 'west' }]) + const payload = { + documents: [{ filename: 'doc.md', documentTagsData }], + [PRIVATE_SECRET_PROVENANCE_FIELD]: { + version: 1 as const, + complete: true, + selections: [ + { + key: 'document-filename:0', + provenance: { + version: 1 as const, + complete: true, + entries: [], + scope: PRIVATE_PROVENANCE_SCOPE, + }, + }, + { + key: 'document-content:0', + provenance: { + version: 1 as const, + complete: true, + entries: [], + scope: PRIVATE_PROVENANCE_SCOPE, + }, + }, + { + key: 'document-tag-value:0:0', + provenance: { + version: 1 as const, + complete: true, + entries: [{ name: 'TAG_VALUE', encryptedValue: 'encrypted-tag-value' }], + scope: PRIVATE_PROVENANCE_SCOPE, + }, + }, + ], + }, + } + + const result = resolveKnowledgeDocumentWriteSecretProvenance({ + request: createRequest(payload), + payload, + authType: AuthType.INTERNAL_JWT, + userId: 'user-1', + workspaceId: 'workspace-1', + documents: [{ documentTagsData }], + }) + + expect(result).toEqual({ + success: true, + provenances: [ + { + filename: { status: 'exact', entries: [] }, + content: { status: 'exact', entries: [] }, + tags: [ + { + tagName: 'private-name', + provenance: { + status: 'exact', + entries: [ + { + name: 'TAG_VALUE', + encryptedValue: 'encrypted-tag-value', + sourceUserId: 'user-1', + sourceWorkspaceId: 'workspace-1', + }, + ], + }, + }, + ], + }, + ], + }) }) }) diff --git a/apps/sim/app/api/knowledge/secret-provenance.ts b/apps/sim/app/api/knowledge/secret-provenance.ts index 45d45206b4d..5f3415f1c95 100644 --- a/apps/sim/app/api/knowledge/secret-provenance.ts +++ b/apps/sim/app/api/knowledge/secret-provenance.ts @@ -23,7 +23,6 @@ import { import { knowledgeDocumentContentSelectionKey, knowledgeDocumentFilenameSelectionKey, - knowledgeDocumentTagNameSelectionKey, knowledgeDocumentTagValueSelectionKey, parseKnowledgeDocumentTagProvenanceTargets, } from '@/lib/knowledge/secret-provenance-selection' @@ -59,11 +58,16 @@ export function resolveKnowledgeWriteSecretProvenance(options: { if (inspection.status !== 'verified' || options.authType !== AuthType.INTERNAL_JWT) { return { success: false, response: invalidKnowledgeProvenanceResponse() } } - if ( - !isPrivateSecretProvenanceBundleV1(inspection.value) || - !inspection.value.complete || - inspection.value.selections.length !== options.selectionKeys.length - ) { + if (!isPrivateSecretProvenanceBundleV1(inspection.value)) { + return { success: false, response: invalidKnowledgeProvenanceResponse() } + } + if (!inspection.value.complete) { + return { + success: true, + provenances: options.selectionKeys.map(() => ({ status: 'unknown' })), + } + } + if (inspection.value.selections.length !== options.selectionKeys.length) { return { success: false, response: invalidKnowledgeProvenanceResponse() } } const provenances = options.selectionKeys.map((selectionKey) => @@ -72,9 +76,7 @@ export function resolveKnowledgeWriteSecretProvenance(options: { ...(options.workspaceId ? { workspaceId: options.workspaceId } : {}), }) ) - if ( - provenances.some((provenance) => provenance === undefined || provenance.status === 'unknown') - ) { + if (provenances.some((provenance) => provenance === undefined)) { return { success: false, response: invalidKnowledgeProvenanceResponse() } } return { success: true, provenances: provenances as DurableSecretProvenance[] } @@ -84,7 +86,7 @@ type KnowledgeDocumentWriteProvenanceResolution = | { success: true; provenances?: KnowledgeDocumentWriteSecretProvenance[] } | { success: false; response: NextResponse } -/** Resolves field-separated document input provenance and rejects dynamic secret tag names. */ +/** Resolves provenance for durable document fields; persisted tag names remain raw and untracked. */ export function resolveKnowledgeDocumentWriteSecretProvenance(options: { request: NextRequest payload: unknown @@ -99,10 +101,9 @@ export function resolveKnowledgeDocumentWriteSecretProvenance(options: { const selectionKeys = options.documents.flatMap((_document, documentIndex) => [ knowledgeDocumentFilenameSelectionKey(documentIndex), knowledgeDocumentContentSelectionKey(documentIndex), - ...tagTargets[documentIndex].flatMap((_tag, tagIndex) => [ - knowledgeDocumentTagNameSelectionKey(documentIndex, tagIndex), - knowledgeDocumentTagValueSelectionKey(documentIndex, tagIndex), - ]), + ...tagTargets[documentIndex].map((_tag, tagIndex) => + knowledgeDocumentTagValueSelectionKey(documentIndex, tagIndex) + ), ]) const resolved = resolveKnowledgeWriteSecretProvenance({ request: options.request, @@ -122,11 +123,7 @@ export function resolveKnowledgeDocumentWriteSecretProvenance(options: { const content = resolved.provenances[provenanceIndex++] const tagProvenances: KnowledgeDocumentWriteSecretProvenance['tags'][number][] = [] for (const tag of tags) { - const tagName = resolved.provenances[provenanceIndex++] const tagValue = resolved.provenances[provenanceIndex++] - if (tagName.status !== 'exact' || tagName.entries.length > 0) { - return { success: false, response: invalidKnowledgeProvenanceResponse() } - } tagProvenances.push({ tagName: tag.tagName, provenance: tagValue }) } provenances.push({ filename, content, tags: tagProvenances }) diff --git a/apps/sim/app/api/mcp/serve/[serverId]/route.test.ts b/apps/sim/app/api/mcp/serve/[serverId]/route.test.ts index 5bcdaf7848b..e0598d0ccbe 100644 --- a/apps/sim/app/api/mcp/serve/[serverId]/route.test.ts +++ b/apps/sim/app/api/mcp/serve/[serverId]/route.test.ts @@ -1008,7 +1008,7 @@ describe('MCP Serve Route', () => { expect(JSON.stringify(body)).not.toContain(RESOLVED_SECRET_PROVENANCE_FIELD) }) - it('fails closed when a successful workflow MCP response omits private provenance', async () => { + it('preserves a successful legacy workflow MCP response without private provenance', async () => { dbChainMockFns.limit .mockResolvedValueOnce([ { @@ -1041,9 +1041,8 @@ describe('MCP Serve Route', () => { const response = await POST(req, { params: Promise.resolve({ serverId: 'server-1' }) }) const body = await response.json() - expect(response.status).toBe(500) - expect(body.error.message).toBe('Tool execution failed') - expect(JSON.stringify(body)).not.toContain('secret-value') + expect(response.status).toBe(200) + expect(body.result.content[0].text).toBe('"secret-value"') }) it.each([ diff --git a/apps/sim/app/api/mcp/serve/[serverId]/route.ts b/apps/sim/app/api/mcp/serve/[serverId]/route.ts index affbf3f74d8..c5a8146d200 100644 --- a/apps/sim/app/api/mcp/serve/[serverId]/route.ts +++ b/apps/sim/app/api/mcp/serve/[serverId]/route.ts @@ -256,8 +256,7 @@ interface WorkflowExecutionProvenance { async function consumeWorkflowExecutionProvenance( response: Response, - value: unknown, - scope: { userId: string; workspaceId: string } + value: unknown ): Promise { const inspection = inspectPrivateToolMetadataEnvelope( response.headers, @@ -265,8 +264,7 @@ async function consumeWorkflowExecutionProvenance( RESOLVED_SECRET_PROVENANCE_METADATA_V1 ) if (inspection.status === 'unsupported') { - if (!response.ok) return { value, hasPrivateProvenance: false } - throw new Error('MCP workflow execution provenance is unavailable') + return { value, hasPrivateProvenance: false } } if (inspection.status === 'invalid' || !isJsonObject(value)) { throw new Error('MCP workflow execution provenance is invalid') @@ -912,10 +910,7 @@ async function handleToolsCall( }) const rawExecuteResult = await readWorkflowExecutionResult(response, abortSignal.signal) - const provenance = await consumeWorkflowExecutionProvenance(response, rawExecuteResult, { - userId: actorUserId, - workspaceId: wf.workspaceId, - }) + const provenance = await consumeWorkflowExecutionProvenance(response, rawExecuteResult) const executeResult = provenance.value const executeResultObject = isJsonObject(executeResult) ? executeResult : null @@ -959,17 +954,12 @@ async function handleToolsCall( : executeResultObject && hasResponseField(executeResultObject, 'output') ? executeResultObject.output : executeResult - if (!provenance.hasPrivateProvenance) { - throw new Error('MCP workflow execution provenance is unavailable') - } - const projectedToolOutput = await projectWorkflowMcpModelContent( - toolOutput, - provenance.privateProvenance, - { - userId: actorUserId, - workspaceId: wf.workspaceId, - } - ) + const projectedToolOutput = provenance.hasPrivateProvenance + ? await projectWorkflowMcpModelContent(toolOutput, provenance.privateProvenance, { + userId: actorUserId, + workspaceId: wf.workspaceId, + }) + : toolOutput const result: CallToolResult = { content: [{ type: 'text', text: serializeToolText(projectedToolOutput) }], isError: executeResultObject?.success === false, diff --git a/apps/sim/app/api/mcp/tools/execute/route.test.ts b/apps/sim/app/api/mcp/tools/execute/route.test.ts index 92a1b4efd62..24d9b20fe91 100644 --- a/apps/sim/app/api/mcp/tools/execute/route.test.ts +++ b/apps/sim/app/api/mcp/tools/execute/route.test.ts @@ -9,17 +9,11 @@ const { mockDiscoverServerTools, mockExecuteTool, mockGetExecutionTimeout, - mockReadResponseToBufferWithLimit, } = vi.hoisted(() => ({ mockCapExecutionTimeoutMs: vi.fn((_policy: number, requested?: number) => requested ?? 0), mockDiscoverServerTools: vi.fn(), mockExecuteTool: vi.fn(), mockGetExecutionTimeout: vi.fn(() => 0), - mockReadResponseToBufferWithLimit: vi.fn(), -})) - -vi.mock('@/lib/core/utils/stream-limits', () => ({ - readResponseToBufferWithLimit: mockReadResponseToBufferWithLimit, })) vi.mock('@/lib/mcp/middleware', () => ({ @@ -102,45 +96,24 @@ describe('MCP tool execution private secret provenance', () => { vi.clearAllMocks() mockDiscoverServerTools.mockResolvedValue([{ name: 'example_tool', inputSchema: {} }]) mockExecuteTool.mockResolvedValue({ content: [{ type: 'text', text: 'ok' }] }) - mockReadResponseToBufferWithLimit.mockImplementation(async (response: Response) => - Buffer.from(await response.arrayBuffer()) - ) }) - it('returns fail-closed scoped provenance only to an authenticated internal caller', async () => { + it('returns provenance activated by this MCP transport call', async () => { mockDiscoverServerTools.mockImplementationOnce( async ( _userId: string, _serverId: string, _workspaceId: string, _forceRefresh: boolean, - report: (value: unknown) => void - ) => { - report({ - version: 1, - complete: false, - entries: [], - scope: { userId: 'user-1', workspaceId: 'workspace-1' }, - }) - return [{ name: 'example_tool', inputSchema: {} }] - } - ) - mockExecuteTool.mockImplementationOnce( - async ( - _userId: string, - _serverId: string, - _toolCall: unknown, - _workspaceId: string, - _headers: unknown, - report: (value: unknown) => void + recordProvenance?: (provenance: unknown) => void ) => { - report({ + recordProvenance?.({ version: 1, complete: true, - entries: [{ name: 'NEW_TOKEN', encryptedValue: 'encrypted-v2' }], + entries: [{ name: 'MCP_TOKEN', encryptedValue: 'encrypted-mcp-token' }], scope: { userId: 'user-1', workspaceId: 'workspace-1' }, }) - return { content: [{ type: 'text', text: 'ok' }] } + return [{ name: 'example_tool', inputSchema: {} }] } ) const request = createRequest({ @@ -155,10 +128,12 @@ describe('MCP tool execution private secret provenance', () => { ) expect(body.__resolvedSecretTraceProvenance).toEqual({ version: 1, - complete: false, - entries: [], + complete: true, + entries: [{ name: 'MCP_TOKEN', encryptedValue: 'encrypted-mcp-token' }], scope: { userId: 'user-1', workspaceId: 'workspace-1' }, }) + expect(mockDiscoverServerTools.mock.calls[0]?.[4]).toEqual(expect.any(Function)) + expect(mockExecuteTool.mock.calls[0]?.[5]).toEqual(expect.any(Function)) }) it('does not expose private provenance metadata to a session caller', async () => { @@ -176,10 +151,10 @@ describe('MCP tool execution private secret provenance', () => { expect(mockExecuteTool.mock.calls[0]?.[5]).toBeUndefined() }) - it('preserves the functional response when private provenance cannot be attached', async () => { - mockReadResponseToBufferWithLimit.mockRejectedValueOnce(new Error('Response exceeds limit')) + it('preserves MCP error status and message when attaching private provenance', async () => { mockExecuteTool.mockResolvedValueOnce({ - content: [{ type: 'text', text: 'unchanged' }], + isError: true, + content: [{ type: 'text', text: 'Provider rejected the request' }], }) const request = createRequest({ 'x-sim-request-private-tool-metadata': 'resolved-secret-provenance-v1', @@ -188,10 +163,54 @@ describe('MCP tool execution private secret provenance', () => { const response = await POST(request, {}) const body = (await response.json()) as Record + expect(response.status).toBe(400) + expect(response.headers.get('x-sim-private-tool-metadata')).toBe( + 'resolved-secret-provenance-v1' + ) + expect(body).toMatchObject({ + success: false, + error: 'Provider rejected the request', + __resolvedSecretTraceProvenance: { + version: 1, + complete: true, + entries: [], + scope: { userId: 'user-1', workspaceId: 'workspace-1' }, + }, + }) + }) + + it('attaches private provenance without imposing a second functional response limit', async () => { + const largeText = 'x'.repeat(10 * 1024 * 1024 + 1) + mockExecuteTool.mockResolvedValueOnce({ + content: [{ type: 'text', text: largeText }], + }) + const request = createRequest({ + 'x-sim-request-private-tool-metadata': 'resolved-secret-provenance-v1', + }) + + const response = await (async () => { + const responseJsonSpy = vi.spyOn(Response.prototype, 'json') + try { + const result = await POST(request, {}) + expect(responseJsonSpy).not.toHaveBeenCalled() + return result + } finally { + responseJsonSpy.mockRestore() + } + })() + const body = (await response.json()) as Record + expect(response.status).toBe(200) expect(response.ok).toBe(true) - expect(response.headers.has('x-sim-private-tool-metadata')).toBe(false) - expect(body).not.toHaveProperty('__resolvedSecretTraceProvenance') + expect(response.headers.get('x-sim-private-tool-metadata')).toBe( + 'resolved-secret-provenance-v1' + ) + expect(body.__resolvedSecretTraceProvenance).toEqual({ + version: 1, + complete: true, + entries: [], + scope: { userId: 'user-1', workspaceId: 'workspace-1' }, + }) expect(body).toMatchObject({ success: true, data: { @@ -201,7 +220,7 @@ describe('MCP tool execution private secret provenance', () => { }) expect( (body.data as { output: { content: Array<{ text?: unknown }> } }).output.content[0]?.text - ).toBe('unchanged') + ).toBe(largeText) }) it('uses the remaining workflow deadline for trusted internal tool calls', async () => { diff --git a/apps/sim/app/api/mcp/tools/execute/route.ts b/apps/sim/app/api/mcp/tools/execute/route.ts index a3679fb4f99..eabe79c70ba 100644 --- a/apps/sim/app/api/mcp/tools/execute/route.ts +++ b/apps/sim/app/api/mcp/tools/execute/route.ts @@ -11,15 +11,13 @@ import { } from '@/lib/billing/core/billing-attribution' import { capExecutionTimeoutMs, getExecutionTimeout } from '@/lib/core/execution-limits' import type { SubscriptionPlan } from '@/lib/core/rate-limiter/types' -import { readResponseToBufferWithLimit } from '@/lib/core/utils/stream-limits' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { SIM_VIA_HEADER } from '@/lib/execution/call-chain' import { parseRemainingExecutionDeadlineMs } from '@/lib/execution/execution-deadline-header' import { - PRIVATE_TOOL_METADATA_RESPONSE_HEADER, - RESOLVED_SECRET_PROVENANCE_FIELD, RESOLVED_SECRET_PROVENANCE_METADATA_V1, requestsPrivateToolMetadata, + serializePrivateToolMetadataResponseEnvelope, } from '@/lib/execution/private-tool-metadata' import { mcpBodyReadErrorResponse, @@ -34,7 +32,7 @@ import { type McpToolCall, type McpToolResult, } from '@/lib/mcp/types' -import { categorizeError, createMcpErrorResponse, createMcpSuccessResponse } from '@/lib/mcp/utils' +import { categorizeError } from '@/lib/mcp/utils' import { assertPermissionsAllowed, McpToolsNotAllowedError, @@ -45,7 +43,6 @@ import { } from '@/executor/utils/resolved-secret-trace-registry' const logger = createLogger('McpToolExecutionAPI') -const MAX_PRIVATE_MCP_RESPONSE_BYTES = 10 * 1024 * 1024 export const dynamic = 'force-dynamic' @@ -68,33 +65,21 @@ function hasType(prop: unknown): prop is SchemaProperty { return typeof prop === 'object' && prop !== null && 'type' in prop } -async function attachPrivateProvenance( - response: NextResponse, - provenance: ResolvedSecretTraceProvenanceAccumulator -): Promise { - let payload: Record - try { - const body = await readResponseToBufferWithLimit(response.clone(), { - maxBytes: MAX_PRIVATE_MCP_RESPONSE_BYTES, - label: 'MCP private metadata response', - allowNoBodyFallback: true, - }) - const parsed: unknown = JSON.parse(body.toString('utf8')) - if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) { - throw new Error('MCP response is not a JSON object') - } - payload = parsed as Record - } catch { - return response +function createToolExecutionResponse( + body: Record, + status: number, + provenance: ResolvedSecretTraceProvenanceAccumulator | undefined +): NextResponse { + if (!provenance) { + return NextResponse.json(body, { status }) } - const headers = new Headers(response.headers) - headers.delete('content-length') - headers.set(PRIVATE_TOOL_METADATA_RESPONSE_HEADER, RESOLVED_SECRET_PROVENANCE_METADATA_V1) - return NextResponse.json( - { ...payload, [RESOLVED_SECRET_PROVENANCE_FIELD]: provenance.exportProvenance() }, - { status: response.status, headers } + const envelope = serializePrivateToolMetadataResponseEnvelope( + body, + RESOLVED_SECRET_PROVENANCE_METADATA_V1, + provenance.exportProvenance() ) + return NextResponse.json(envelope.body, { status, headers: envelope.headers }) } /** @@ -115,13 +100,22 @@ export const POST = withRouteHandler( resolvedSecretTraceProvenance.record(provenance) } : undefined - const response = await (async (): Promise => { + const errorResponse = (message: string, status: number): NextResponse => + createToolExecutionResponse( + { success: false, error: message }, + status, + resolvedSecretTraceProvenance + ) + const successResponse = (data: T, status = 200): NextResponse => + createToolExecutionResponse({ success: true, data }, status, resolvedSecretTraceProvenance) + + return (async (): Promise => { try { const rawBody = await readMcpJsonBodyWithLimit(request) const parsedBody = mcpToolExecutionBodySchema.safeParse(rawBody) if (!parsedBody.success) { - return createMcpErrorResponse(parsedBody.error, 'Invalid request format', 400) + return errorResponse('Invalid request format', 400) } const body = parsedBody.data @@ -148,7 +142,7 @@ export const POST = withRouteHandler( }) } catch (err) { if (err instanceof McpToolsNotAllowedError) { - return createMcpErrorResponse(err, err.message, 403) + return errorResponse(err.message, 403) } throw err } @@ -172,11 +166,7 @@ export const POST = withRouteHandler( logger.warn(`[${requestId}] Tool ${toolName} not found on server ${serverId}`, { availableTools: tools.map((t) => t.name), }) - return createMcpErrorResponse( - new Error('Tool not found'), - 'Tool not found on the specified server', - 404 - ) + return errorResponse('Tool not found on the specified server', 404) } if (tool.inputSchema?.properties) { @@ -241,11 +231,7 @@ export const POST = withRouteHandler( const validationError = validateToolArguments(tool, args) if (validationError) { logger.warn(`[${requestId}] Tool validation failed: ${validationError}`) - return createMcpErrorResponse( - new Error(`Invalid arguments for tool ${toolName}: ${validationError}`), - 'Invalid tool arguments', - 400 - ) + return errorResponse('Invalid tool arguments', 400) } } @@ -317,11 +303,7 @@ export const POST = withRouteHandler( logger.warn( `[${requestId}] Tool execution returned error for ${toolName} on ${serverId}` ) - return createMcpErrorResponse( - transformedResult, - transformedResult.error || 'Tool execution failed', - 400 - ) + return errorResponse(transformedResult.error || 'Tool execution failed', 400) } logger.info(`[${requestId}] Successfully executed tool ${toolName} on server ${serverId}`) @@ -342,7 +324,7 @@ export const POST = withRouteHandler( }) } - return createMcpSuccessResponse(transformedResult) + return successResponse(transformedResult) } catch (error) { if (getErrorMessage(error) === 'Tool execution timeout') { resolvedSecretTraceProvenance?.markIncomplete() @@ -359,27 +341,24 @@ export const POST = withRouteHandler( logger.warn(`[${requestId}] OAuth re-authorization required for MCP tool execution`, { serverId: errorServerId, }) - return NextResponse.json( + return createToolExecutionResponse( { success: false, error: 'OAuth re-authorization required', code: 'reauth_required', serverId: errorServerId, }, - { status: 401 } + 401, + resolvedSecretTraceProvenance ) } logger.error(`[${requestId}] Error executing MCP tool:`, error) const { message, status } = categorizeError(error) - return createMcpErrorResponse(new Error(message), message, status) + return errorResponse(message, status) } })() - - return resolvedSecretTraceProvenance - ? attachPrivateProvenance(response, resolvedSecretTraceProvenance) - : response } ) ) diff --git a/apps/sim/app/api/memory/secret-provenance.test.ts b/apps/sim/app/api/memory/secret-provenance.test.ts index c2ce6b51059..476c513bc36 100644 --- a/apps/sim/app/api/memory/secret-provenance.test.ts +++ b/apps/sim/app/api/memory/secret-provenance.test.ts @@ -77,14 +77,19 @@ describe('memory write secret provenance', () => { expect(result).toEqual({ success: true }) }) - it('rejects an unavailable verified selection before persistence', () => { + it('persists authenticated unavailable selection lineage as unknown', () => { const bundle = { version: 1 as const, complete: true, selections: [ { key: 'data', - provenance: { version: 1 as const, complete: false, entries: [] }, + provenance: { + version: 1 as const, + complete: false, + entries: [], + scope: { userId: 'user-1', workspaceId: 'workspace-1' }, + }, }, ], } @@ -103,8 +108,32 @@ describe('memory write secret provenance', () => { workspaceId: 'workspace-1', }) - expect(result.success).toBe(false) - if (!result.success) expect(result.response.status).toBe(400) + expect(result).toEqual({ success: true, provenance: { status: 'unknown' } }) + }) + + it('persists an authenticated incomplete bundle as unknown', () => { + const payload = { + [PRIVATE_SECRET_PROVENANCE_FIELD]: { + version: 1 as const, + complete: false, + selections: [], + }, + } + const request = new NextRequest('http://localhost/api/memory', { + method: 'POST', + headers: { [PRIVATE_SECRET_PROVENANCE_HEADER]: PRIVATE_SECRET_PROVENANCE_BUNDLE_V1 }, + body: JSON.stringify(payload), + }) + + expect( + resolveMemoryWriteSecretProvenance({ + request, + payload, + authType: AuthType.INTERNAL_JWT, + userId: 'user-1', + workspaceId: 'workspace-1', + }) + ).toEqual({ success: true, provenance: { status: 'unknown' } }) }) it('accepts exact-empty provenance from the workflow owner in the actor workspace', () => { diff --git a/apps/sim/app/api/memory/secret-provenance.ts b/apps/sim/app/api/memory/secret-provenance.ts index 272ee3a7133..dc6ea8f7368 100644 --- a/apps/sim/app/api/memory/secret-provenance.ts +++ b/apps/sim/app/api/memory/secret-provenance.ts @@ -57,18 +57,20 @@ export function resolveMemoryWriteSecretProvenance(options: { if (inspection.status !== 'verified' || options.authType !== AuthType.INTERNAL_JWT) { return { success: false, response: invalidMemoryProvenanceResponse() } } - if ( - !isPrivateSecretProvenanceBundleV1(inspection.value) || - !inspection.value.complete || - inspection.value.selections.length !== 1 - ) { + if (!isPrivateSecretProvenanceBundleV1(inspection.value)) { + return { success: false, response: invalidMemoryProvenanceResponse() } + } + if (!inspection.value.complete) { + return { success: true, provenance: { status: 'unknown' } } + } + if (inspection.value.selections.length !== 1) { return { success: false, response: invalidMemoryProvenanceResponse() } } const provenance = durableSecretProvenanceFromPrivateBundle(inspection.value, 'data', { userId: options.userId, workspaceId: options.workspaceId, }) - return provenance?.status === 'exact' + return provenance ? { success: true, provenance } : { success: false, response: invalidMemoryProvenanceResponse() } } diff --git a/apps/sim/app/api/mothership/execute/route.test.ts b/apps/sim/app/api/mothership/execute/route.test.ts index 659ae6de3f9..007f9424f1b 100644 --- a/apps/sim/app/api/mothership/execute/route.test.ts +++ b/apps/sim/app/api/mothership/execute/route.test.ts @@ -275,12 +275,7 @@ describe('mothership private trace provenance transport', () => { ) expect(response.status).toBe(200) - expect(mockBuildTaggedMcpToolSchemas).toHaveBeenCalledWith( - 'user-1', - 'workspace-1', - ['123'], - expect.any(Function) - ) + expect(mockBuildTaggedMcpToolSchemas).toHaveBeenCalledWith('user-1', 'workspace-1', ['123']) expect(mockProcessContextsServer).toHaveBeenCalledWith( [ { @@ -415,7 +410,7 @@ describe('mothership private trace provenance transport', () => { }) }) - it('returns encrypted provenance on a marker-gated successful request', async () => { + it('returns exact-empty output provenance on a marker-gated successful request', async () => { mockRunHeadlessCopilotLifecycle.mockImplementation( async (_payload: Record, options: CopilotLifecycleOptions) => { expect(options.environmentContext).not.toHaveProperty('decryptedEnvVars') @@ -447,48 +442,31 @@ describe('mothership private trace provenance transport', () => { expect(body.__resolvedSecretTraceProvenance).toEqual({ version: 1, complete: true, - entries: [{ name: 'API_KEY', encryptedValue: 'encrypted-secret' }], + entries: [], scope: { userId: 'user-1', workspaceId: 'workspace-1' }, }) expect(JSON.stringify(body.__resolvedSecretTraceProvenance)).not.toContain('secret-value') expect(mockGetPersonalAndWorkspaceEnv).toHaveBeenCalledTimes(1) }) - it('imports only MCP provenance present in the discovered schemas', async () => { - const provenance = { - version: 1, - complete: true, - entries: [ - { name: 'API_KEY', encryptedValue: 'encrypted-secret' }, - { name: 'UNRELATED', encryptedValue: 'encrypted-unrelated' }, - ], - scope: { userId: 'user-1', workspaceId: 'workspace-1' }, - } - mockDecryptSecret.mockImplementation(async (encryptedValue: string) => ({ - decrypted: encryptedValue === 'encrypted-secret' ? 'secret-value' : 'unrelated-value', - })) - mockBuildTaggedMcpToolSchemas.mockImplementationOnce( - async ( - _userId: string, - _workspaceId: string, - _serverIds: string[], - report: (value: unknown) => void - ) => { - report(provenance) - return [{ name: 'mcp-docs', description: 'Uses secret-value' }] - } - ) + it('keeps discovered MCP schemas raw without activating matching configured secrets', async () => { + mockBuildTaggedMcpToolSchemas.mockResolvedValueOnce([ + { name: 'mcp-docs', description: 'Uses secret-value' }, + ]) mockRunHeadlessCopilotLifecycle.mockImplementation( async (payload: Record, options: CopilotLifecycleOptions) => { const registry = options.environmentContext?.resolvedSecretTraceRegistry ?? options.resolvedSecretTraceRegistry expect(registry?.exportProvenance()).toEqual({ - ...provenance, - entries: [{ name: 'API_KEY', encryptedValue: 'encrypted-secret' }], + version: 1, + complete: true, + entries: [], + scope: { userId: 'user-1', workspaceId: 'workspace-1' }, }) - expect(JSON.stringify(payload)).not.toContain('encrypted-secret') - expect(JSON.stringify(payload)).not.toContain('__resolvedSecretTraceProvenance') + expect(payload.mothershipTools).toEqual([ + { name: 'mcp-docs', description: 'Uses secret-value' }, + ]) return successResult() } ) @@ -514,62 +492,15 @@ describe('mothership private trace provenance transport', () => { expect({ status: response.status, provenance: body.__resolvedSecretTraceProvenance }).toEqual({ status: 200, provenance: { - ...provenance, - entries: [{ name: 'API_KEY', encryptedValue: 'encrypted-secret' }], + version: 1, + complete: true, + entries: [], + scope: { userId: 'user-1', workspaceId: 'workspace-1' }, }, }) }) - it('omits MCP tools with malformed discovery provenance without poisoning the lifecycle', async () => { - mockBuildTaggedMcpToolSchemas.mockImplementationOnce( - async ( - _userId: string, - _workspaceId: string, - _serverIds: string[], - report: (value: unknown) => void - ) => { - report({ version: 1, complete: true, entries: 'invalid' }) - return [] - } - ) - mockRunHeadlessCopilotLifecycle.mockImplementation( - async (payload: Record, options: CopilotLifecycleOptions) => { - const registry = - options.environmentContext?.resolvedSecretTraceRegistry ?? - options.resolvedSecretTraceRegistry - expect(registry?.isComplete()).toBe(true) - expect(payload).not.toHaveProperty('mothershipTools') - return successResult() - } - ) - - const response = await POST( - createMockRequest( - 'POST', - { - ...requestBody, - contexts: [{ kind: 'mcp', label: 'Docs', serverId: 'server-1' }], - }, - { - Authorization: 'Bearer internal', - 'x-sim-billing-attribution': 'billing', - 'x-sim-request-private-tool-metadata': 'resolved-secret-provenance-v1', - }, - 'http://localhost:3000/api/mothership/execute' - ) - ) - const body = await response.json() - - expect(response.status).toBe(200) - expect(body.__resolvedSecretTraceProvenance).toEqual({ - version: 1, - complete: true, - entries: [], - scope: { userId: 'user-1', workspaceId: 'workspace-1' }, - }) - }) - - it('returns encrypted provenance with marker-gated failures', async () => { + it('returns exact-empty provenance for an already projected marker-gated failure', async () => { mockRunHeadlessCopilotLifecycle.mockImplementation( async (_payload: Record, options: CopilotLifecycleOptions) => { activateSecret(options) @@ -601,12 +532,10 @@ describe('mothership private trace provenance transport', () => { 'resolved-secret-provenance-v1' ) expect(body.content).toBe('secret-value') - expect(body.__resolvedSecretTraceProvenance.entries).toEqual([ - { name: 'API_KEY', encryptedValue: 'encrypted-secret' }, - ]) + expect(body.__resolvedSecretTraceProvenance.entries).toEqual([]) }) - it('places encrypted provenance only on the terminal streamed event', async () => { + it('places exact-empty provenance only on the terminal streamed event', async () => { mockRunHeadlessCopilotLifecycle.mockImplementation( async (_payload: Record, options: CopilotLifecycleOptions) => { activateSecret(options) @@ -642,7 +571,7 @@ describe('mothership private trace provenance transport', () => { data: { content: 'secret-value', __resolvedSecretTraceProvenance: { - entries: [{ name: 'API_KEY', encryptedValue: 'encrypted-secret' }], + entries: [], }, }, }) diff --git a/apps/sim/app/api/mothership/execute/route.ts b/apps/sim/app/api/mothership/execute/route.ts index 6af4aeec898..39f061a92d3 100644 --- a/apps/sim/app/api/mothership/execute/route.ts +++ b/apps/sim/app/api/mothership/execute/route.ts @@ -38,7 +38,6 @@ import { } from '@/lib/workspaces/permissions/utils' import { createIncompleteResolvedSecretTraceRegistry, - ResolvedSecretTraceProvenanceAccumulator, type ResolvedSecretTraceRegistry, } from '@/executor/utils/resolved-secret-trace-registry' import type { ChatContext } from '@/stores/panel' @@ -60,7 +59,9 @@ function withPrivateProvenance>( return { ...payload, ...(include && registry - ? { [RESOLVED_SECRET_PROVENANCE_FIELD]: registry.exportProvenance() } + ? { + [RESOLVED_SECRET_PROVENANCE_FIELD]: registry.exportCommittedProvenanceForInputPaths([]), + } : {}), } } @@ -201,14 +202,6 @@ export const POST = withRouteHandler(async (req: NextRequest) => { activeResolvedSecretTraceRegistry = createIncompleteResolvedSecretTraceRegistry(scope) } resolvedSecretTraceRegistry = activeResolvedSecretTraceRegistry - const mcpDiscoveryProvenance = new ResolvedSecretTraceProvenanceAccumulator({ - userId, - workspaceId, - }) - const recordMcpDiscoveryProvenance = (provenance: unknown): void => { - mcpDiscoveryProvenance.record(provenance) - } - const effectiveChatId = chatId || generateId() messageId = providedMessageId || generateId() requestId = providedRequestId || generateId() @@ -227,39 +220,15 @@ export const POST = withRouteHandler(async (req: NextRequest) => { const nonMcpAgentMentions = agentMentions?.filter((context) => context.kind !== 'mcp') const userPermission = workspaceAccess.permission const mothershipToolsPromise = Promise.allSettled([ - buildSelectedMcpToolSchemas( - userId, - workspaceId, - mcpTools ?? [], - recordMcpDiscoveryProvenance - ), - buildTaggedMcpToolSchemas( - userId, - workspaceId, - taggedMcpServerIds, - recordMcpDiscoveryProvenance - ), - ]).then(async (results) => { + buildSelectedMcpToolSchemas(userId, workspaceId, mcpTools ?? []), + buildTaggedMcpToolSchemas(userId, workspaceId, taggedMcpServerIds), + ]).then((results) => { const groups = results.map((result) => { if (result.status === 'rejected') throw result.reason return result.value }) const byName = new Map(groups.flat().map((tool) => [tool.name, tool])) - const tools = [...byName.values()] - if (activeResolvedSecretTraceRegistry) { - const discoveryRegistry = activeResolvedSecretTraceRegistry.forkForToolInput(tools) - const imported = await discoveryRegistry.importProvenanceForValue( - mcpDiscoveryProvenance.exportProvenance(), - tools, - { trusted: true } - ) - if (!imported || !discoveryRegistry.isComplete()) { - reqLogger.warn('Omitting MCP tools with unverifiable secret provenance') - return [] - } - activeResolvedSecretTraceRegistry.mergeToolCallRegistry(discoveryRegistry) - } - return tools + return [...byName.values()] }) const [workspaceContext, integrationTools, mothershipTools, entitlements, agentContexts] = await Promise.all([ diff --git a/apps/sim/app/api/providers/route.test.ts b/apps/sim/app/api/providers/route.test.ts index 34558934d2e..55641053694 100644 --- a/apps/sim/app/api/providers/route.test.ts +++ b/apps/sim/app/api/providers/route.test.ts @@ -3,6 +3,10 @@ */ import { createMockRequest, hybridAuthMockFns } from '@sim/testing' import { beforeEach, describe, expect, it, vi } from 'vitest' +import { + PRIVATE_MODEL_INPUT_STATE_HEADER, + PROJECTED_MODEL_INPUT_PATHS_V1, +} from '@/lib/execution/model-input-provenance' const { mockExecuteProviderRequest, @@ -10,18 +14,18 @@ const { mockCheckWorkspaceAccess, mockAuthorizeCredentialUse, mockPrepareCopilotEnvironmentContext, - mockCollectProviderModelInputProvenanceValues, mockImportProvenance, mockRegistryIsComplete, + mockProjectResolvedSecretModelContent, } = vi.hoisted(() => ({ mockExecuteProviderRequest: vi.fn(), mockRequireBillingAttributionHeader: vi.fn(), mockCheckWorkspaceAccess: vi.fn(), mockAuthorizeCredentialUse: vi.fn(), mockPrepareCopilotEnvironmentContext: vi.fn(), - mockCollectProviderModelInputProvenanceValues: vi.fn(), mockImportProvenance: vi.fn(), mockRegistryIsComplete: vi.fn(), + mockProjectResolvedSecretModelContent: vi.fn(), })) vi.mock('@/providers', () => ({ @@ -45,8 +49,8 @@ vi.mock('@/lib/copilot/environment-context', () => ({ prepareCopilotEnvironmentContext: mockPrepareCopilotEnvironmentContext, })) -vi.mock('@/providers/model-input-provenance', () => ({ - collectProviderModelInputProvenanceValues: mockCollectProviderModelInputProvenanceValues, +vi.mock('@/executor/utils/resolved-secret-content-projection', () => ({ + projectResolvedSecretModelContent: mockProjectResolvedSecretModelContent, })) vi.mock('@/app/api/auth/oauth/utils', () => ({ @@ -89,6 +93,7 @@ function createProviderRequest( }, { 'x-sim-private-model-input-provenance': 'resolved-secret-provenance-v1', + [PRIVATE_MODEL_INPUT_STATE_HEADER]: PROJECTED_MODEL_INPUT_PATHS_V1, ...headers, } ) @@ -109,12 +114,15 @@ describe('POST /api/providers', () => { model: 'gpt-4o', tokens: { input: 1, output: 1, total: 2 }, }) - mockCollectProviderModelInputProvenanceValues.mockReturnValue(['selected-model-input']) mockImportProvenance.mockResolvedValue(true) mockRegistryIsComplete.mockReturnValue(true) + mockProjectResolvedSecretModelContent.mockImplementation((value) => ({ + safe: true, + value, + })) mockPrepareCopilotEnvironmentContext.mockResolvedValue({ resolvedSecretTraceRegistry: { - importProvenanceForValue: mockImportProvenance, + importProvenance: mockImportProvenance, isComplete: mockRegistryIsComplete, }, }) @@ -200,9 +208,91 @@ describe('POST /api/providers', () => { ) expect(res.status).toBe(200) - expect(mockImportProvenance).toHaveBeenCalledWith(provenance, expect.any(Array), { - trusted: true, + expect(mockImportProvenance).toHaveBeenCalledWith(provenance, { trusted: true }) + }) + + it('projects legacy private prompt provenance on the provider-facing copy', async () => { + mockProjectResolvedSecretModelContent.mockReturnValue({ + safe: true, + value: { + systemPrompt: 'Use {{TOKEN}} safely', + context: '[{"role":"user","content":"{{TOKEN}}"}]', + }, }) + + const res = await POST( + createMockRequest( + 'POST', + { + provider: 'openai', + model: 'gpt-4o', + workspaceId: 'ws-1', + systemPrompt: 'Use secret-value safely', + context: '[{"role":"user","content":"secret-value"}]', + __resolvedSecretTraceProvenance: { version: 1, complete: true, entries: [] }, + }, + { 'x-sim-private-model-input-provenance': 'resolved-secret-provenance-v1' } + ) + ) + + expect(res.status).toBe(200) + expect(mockExecuteProviderRequest).toHaveBeenCalledWith( + 'openai', + expect.objectContaining({ + systemPrompt: 'Use {{TOKEN}} safely', + context: '[{"role":"user","content":"{{TOKEN}}"}]', + }), + expect.anything() + ) + }) + + it('does not re-project an explicitly projected private request', async () => { + mockProjectResolvedSecretModelContent.mockReturnValue({ + safe: true, + value: { systemPrompt: 'Bo{{TOKEN}}', context: undefined }, + }) + + const res = await POST( + createProviderRequest({ + provider: 'openai', + model: 'gpt-4o', + workspaceId: 'ws-1', + systemPrompt: 'Box', + }) + ) + + expect(res.status).toBe(200) + expect(mockProjectResolvedSecretModelContent).not.toHaveBeenCalled() + expect(mockExecuteProviderRequest).toHaveBeenCalledWith( + 'openai', + expect.objectContaining({ systemPrompt: 'Box' }), + expect.anything() + ) + }) + + it('rejects a projected marker without a private provenance envelope', async () => { + const res = await POST( + createMockRequest( + 'POST', + { provider: 'openai', model: 'gpt-4o', workspaceId: 'ws-1' }, + { [PRIVATE_MODEL_INPUT_STATE_HEADER]: PROJECTED_MODEL_INPUT_PATHS_V1 } + ) + ) + + expect(res.status).toBe(400) + expect(mockExecuteProviderRequest).not.toHaveBeenCalled() + }) + + it('rejects an unknown private projection marker', async () => { + const res = await POST( + createProviderRequest( + { provider: 'openai', model: 'gpt-4o', workspaceId: 'ws-1' }, + { [PRIVATE_MODEL_INPUT_STATE_HEADER]: 'unknown-projection' } + ) + ) + + expect(res.status).toBe(400) + expect(mockExecuteProviderRequest).not.toHaveBeenCalled() }) it('rejects a partial private provenance envelope', async () => { @@ -218,7 +308,7 @@ describe('POST /api/providers', () => { expect(mockExecuteProviderRequest).not.toHaveBeenCalled() }) - it('rejects an internal request without the private provenance envelope', async () => { + it('preserves legacy internal requests without the private provenance envelope', async () => { const res = await POST( createMockRequest('POST', { provider: 'openai', @@ -227,9 +317,9 @@ describe('POST /api/providers', () => { }) ) - expect(res.status).toBe(400) + expect(res.status).toBe(200) expect(mockImportProvenance).not.toHaveBeenCalled() - expect(mockExecuteProviderRequest).not.toHaveBeenCalled() + expect(mockExecuteProviderRequest).toHaveBeenCalled() }) it('omits provisional stream output from the execution header', async () => { diff --git a/apps/sim/app/api/providers/route.ts b/apps/sim/app/api/providers/route.ts index bc335f9ceaa..2fe21daae23 100644 --- a/apps/sim/app/api/providers/route.ts +++ b/apps/sim/app/api/providers/route.ts @@ -2,6 +2,7 @@ import { db } from '@sim/db' import { account } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { getErrorMessage, toError } from '@sim/utils/errors' +import { isPlainRecord } from '@sim/utils/object' import { eq } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' import { executeProviderContract } from '@/lib/api/contracts/providers' @@ -16,7 +17,10 @@ import { import { prepareCopilotEnvironmentContext } from '@/lib/copilot/environment-context' import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { inspectModelInputProvenanceRequest } from '@/lib/execution/model-input-provenance' +import { + inspectModelInputProjectionState, + inspectModelInputProvenanceRequest, +} from '@/lib/execution/model-input-provenance' import { checkWorkspaceAccess } from '@/lib/workspaces/permissions/utils' import { getServiceAccountToken, @@ -30,8 +34,8 @@ import { ProviderNotAllowedError, } from '@/ee/access-control/utils/permission-check' import type { StreamingExecution } from '@/executor/types' +import { projectResolvedSecretModelContent } from '@/executor/utils/resolved-secret-content-projection' import { executeProviderRequest } from '@/providers' -import { collectProviderModelInputProvenanceValues } from '@/providers/model-input-provenance' import { projectStreamingExecutionToByteStream } from '@/providers/stream-pump' import type { ProviderRequest } from '@/providers/types' @@ -224,7 +228,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => { hasBillingAttribution: !!billingAttribution, }) - const providerRequest: ProviderRequest = { + let providerRequest: ProviderRequest = { model, systemPrompt, context, @@ -254,22 +258,54 @@ export const POST = withRouteHandler(async (request: NextRequest) => { verbosity, } const provenanceInspection = inspectModelInputProvenanceRequest(request.headers, body) - if (provenanceInspection.status === 'unsupported') { - return NextResponse.json({ error: 'Model input provenance is unavailable' }, { status: 400 }) - } - if (provenanceInspection.status === 'invalid') { + const projectionState = inspectModelInputProjectionState(request.headers) + if ( + provenanceInspection.status === 'invalid' || + projectionState === 'invalid' || + (projectionState === 'projected' && provenanceInspection.status !== 'verified') + ) { return NextResponse.json({ error: 'Invalid model input provenance' }, { status: 400 }) } const providerRuntimeContext = await prepareCopilotEnvironmentContext(auth.userId, workspaceId) - const provenanceReady = - await providerRuntimeContext.resolvedSecretTraceRegistry.importProvenanceForValue( - provenanceInspection.value, - collectProviderModelInputProvenanceValues(providerRequest, provider), - { trusted: true } - ) - if (!provenanceReady || !providerRuntimeContext.resolvedSecretTraceRegistry.isComplete()) { - return NextResponse.json({ error: 'Model input provenance is unavailable' }, { status: 400 }) + if (provenanceInspection.status === 'verified') { + const provenanceReady = + await providerRuntimeContext.resolvedSecretTraceRegistry.importProvenance( + provenanceInspection.value, + { trusted: true } + ) + if (!provenanceReady || !providerRuntimeContext.resolvedSecretTraceRegistry.isComplete()) { + return NextResponse.json( + { error: 'Model input provenance is unavailable' }, + { status: 400 } + ) + } + + if (projectionState === 'unmarked') { + const projection = projectResolvedSecretModelContent( + { systemPrompt: providerRequest.systemPrompt, context: providerRequest.context }, + providerRuntimeContext.resolvedSecretTraceRegistry + ) + if (!projection.safe || !isPlainRecord(projection.value)) { + return NextResponse.json( + { error: 'Model input provenance is unavailable' }, + { status: 400 } + ) + } + const projectedSystemPrompt = projection.value.systemPrompt + const projectedContext = projection.value.context + if ( + (projectedSystemPrompt !== undefined && typeof projectedSystemPrompt !== 'string') || + (projectedContext !== undefined && typeof projectedContext !== 'string') + ) { + return NextResponse.json({ error: 'Invalid model input provenance' }, { status: 400 }) + } + providerRequest = { + ...providerRequest, + systemPrompt: projectedSystemPrompt, + context: projectedContext, + } + } } const response = await executeProviderRequest(provider, providerRequest, providerRuntimeContext) diff --git a/apps/sim/app/api/tools/file/manage/route.test.ts b/apps/sim/app/api/tools/file/manage/route.test.ts index 27fbdb18858..c528f6c0242 100644 --- a/apps/sim/app/api/tools/file/manage/route.test.ts +++ b/apps/sim/app/api/tools/file/manage/route.test.ts @@ -347,6 +347,39 @@ describe('POST /api/tools/file/manage content provenance', () => { ) }) + it('persists an authenticated file write with unavailable lineage as unknown', async () => { + const response = await POST( + createMockRequest( + 'POST', + { + operation: 'write', + workspaceId: 'workspace-1', + fileName: 'new.txt', + content: 'possibly secret', + __privateSecretProvenance: { + version: 1, + complete: false, + selections: [], + }, + }, + PRIVATE_SECRET_PROVENANCE_HEADER + ) + ) + + expect(response.status).toBe(200) + expect(mockUploadWorkspaceFile).toHaveBeenCalledWith( + 'workspace-1', + 'user-1', + Buffer.from('possibly secret'), + 'new.txt', + 'text/plain', + { + folderId: null, + secretProvenance: { status: 'unknown' }, + } + ) + }) + it('atomically binds append provenance to the exact predecessor version', async () => { const existing = workspaceFile('file-1') mockResolveWorkspaceFileReference.mockResolvedValue(existing) @@ -487,7 +520,7 @@ describe('POST /api/tools/file/manage content provenance', () => { ) }) - it('rejects a secret-bearing archive before downloading or extracting it', async () => { + it('extracts a secret-bearing archive with unknown output provenance', async () => { const zip = new JSZip() zip.file('child.txt', 'secret-value') mockDownloadFileFromStorage.mockResolvedValue( @@ -511,9 +544,19 @@ describe('POST /api/tools/file/manage content provenance', () => { }) ) - expect(response.status).toBe(422) - expect(mockDownloadFileFromStorage).not.toHaveBeenCalled() - expect(mockUploadWorkspaceFile).not.toHaveBeenCalled() + expect(response.status).toBe(200) + expect(mockDownloadFileFromStorage).toHaveBeenCalledTimes(1) + expect(mockUploadWorkspaceFile).toHaveBeenCalledWith( + 'workspace-1', + 'user-1', + Buffer.from('secret-value'), + 'child.txt', + 'text/plain', + { + folderId: null, + secretProvenance: { status: 'unknown' }, + } + ) }) it('omits source scope when canonical files have different owners', async () => { diff --git a/apps/sim/app/api/tools/file/manage/route.ts b/apps/sim/app/api/tools/file/manage/route.ts index 1af9a5f4c70..305271acac1 100644 --- a/apps/sim/app/api/tools/file/manage/route.ts +++ b/apps/sim/app/api/tools/file/manage/route.ts @@ -330,26 +330,37 @@ function resolveFileMutationSecretProvenance(options: { if ( inspection.status !== 'verified' || options.authType !== AuthType.INTERNAL_JWT || - !isPrivateSecretProvenanceBundleV1(inspection.value) || - !inspection.value.complete || - inspection.value.selections.length !== options.selectionKeys.length + !isPrivateSecretProvenanceBundleV1(inspection.value) ) { return { success: false, error: 'Invalid file secret provenance' } } - const destinationScope = { userId: options.userId, workspaceId: options.workspaceId } const provenanceBySelection = new Map() + if (!inspection.value.complete) { + for (const selectionKey of options.selectionKeys) { + provenanceBySelection.set(selectionKey, { status: 'unknown' }) + } + return { success: true, provenanceBySelection } + } + if (inspection.value.selections.length !== options.selectionKeys.length) { + return { success: false, error: 'Invalid file secret provenance' } + } + + const destinationScope = { userId: options.userId, workspaceId: options.workspaceId } for (const selectionKey of options.selectionKeys) { const provenance = durableSecretProvenanceFromPrivateBundle( inspection.value, selectionKey, destinationScope ) - if ( - !provenance || - provenance.status === 'unknown' || - provenance.entries.some((entry) => !entry.name || !entry.sourceUserId) - ) { + if (!provenance) { + return { success: false, error: 'Invalid file secret provenance' } + } + if (provenance.status === 'unknown') { + provenanceBySelection.set(selectionKey, provenance) + continue + } + if (provenance.entries.some((entry) => !entry.name || !entry.sourceUserId)) { return { success: false, error: 'Invalid file secret provenance' } } provenanceBySelection.set(selectionKey, { @@ -383,7 +394,7 @@ function resolveFileWriteSecretProvenance(options: { }) if (!resolution.success || !resolution.provenanceBySelection) return resolution const content = resolution.provenanceBySelection.get('content') - if (!content || content.status !== 'exact') { + if (!content) { return { success: false, error: 'Invalid file secret provenance' } } return { success: true, contentProvenance: content } @@ -1128,16 +1139,6 @@ export const POST = withRouteHandler(async (request: NextRequest) => { targetOwnerUserId: userId, sources: canonicalArchiveSource.concat(selectedArchiveSource), }) - if (archiveProvenance.status === 'unknown' || archiveProvenance.entries.length > 0) { - return NextResponse.json( - { - success: false, - error: - 'Archive cannot be decompressed because its secret provenance is not exact-empty', - }, - { status: 422 } - ) - } const archiveBuffer = await downloadFileFromStorage(archive, requestId, logger, { maxBytes: MAX_ARCHIVE_BYTES, diff --git a/apps/sim/app/api/tools/fireflies/upload-audio/route.ts b/apps/sim/app/api/tools/fireflies/upload-audio/route.ts index bcaa83894ef..aedba25a335 100644 --- a/apps/sim/app/api/tools/fireflies/upload-audio/route.ts +++ b/apps/sim/app/api/tools/fireflies/upload-audio/route.ts @@ -93,7 +93,6 @@ export const POST = withRouteHandler(async (request: NextRequest) => { headers: request.headers, payload: body, isInternalRequest: true, - allowLegacyWithoutEnvelope: true, }) if (!modelInputProvenance.success) { return errorResponse(modelInputProvenance.error, modelInputProvenance.status) diff --git a/apps/sim/app/api/workflows/[id]/execute/route.async.test.ts b/apps/sim/app/api/workflows/[id]/execute/route.async.test.ts index b10ba5c4480..04ee4145afc 100644 --- a/apps/sim/app/api/workflows/[id]/execute/route.async.test.ts +++ b/apps/sim/app/api/workflows/[id]/execute/route.async.test.ts @@ -360,6 +360,7 @@ function createInternalProvenanceRequest( useDraftState?: boolean provenance?: typeof WORKFLOW_INPUT_PROVENANCE selectionKey?: string + bundleComplete?: boolean includeHeader?: boolean includeField?: boolean } = {} @@ -371,6 +372,7 @@ function createInternalProvenanceRequest( useDraftState, provenance = WORKFLOW_INPUT_PROVENANCE, selectionKey = 'input', + bundleComplete = true, includeHeader = true, includeField = true, } = options @@ -387,8 +389,8 @@ function createInternalProvenanceRequest( ? { [PRIVATE_SECRET_PROVENANCE_FIELD]: { version: 1, - complete: true, - selections: [{ key: selectionKey, provenance }], + complete: bundleComplete, + selections: bundleComplete ? [{ key: selectionKey, provenance }] : [], }, } : {}), @@ -496,6 +498,9 @@ describe('workflow execute async route', () => { close: vi.fn().mockResolvedValue(undefined), }) loggingSessionMockFns.mockWaitForPostExecution.mockReset().mockResolvedValue(undefined) + loggingSessionMockFns.mockExportResolvedSecretTraceProvenanceForValue + .mockReset() + .mockReturnValue({ version: 1, complete: false, entries: [] }) mockExecuteWorkflowJob.mockReset().mockResolvedValue({ success: true }) encryptionMockFns.mockDecryptSecret.mockReset().mockImplementation(async (value: string) => ({ decrypted: value === 'encrypted-token' ? 'secret-value' : 'other-secret', @@ -532,6 +537,26 @@ describe('workflow execute async route', () => { expect(executionOptions.snapshot.input).toEqual({ hello: 'world' }) }) + it('runs authenticated incomplete workflow input with incomplete downstream lineage', async () => { + configureExecutionCaller(EXECUTION_CALLERS[4]) + + const response = await POST(createInternalProvenanceRequest({ bundleComplete: false }), { + params: Promise.resolve({ id: 'workflow-1' }), + }) + + expect(response.status).toBe(200) + const executionOptions = mockExecuteWorkflowCore.mock.calls[0]?.[0] + expect(executionOptions).toMatchObject({ + trustedInitialResolvedSecretTraceProvenance: { + version: 1, + complete: false, + entries: [], + }, + }) + expect(executionOptions.snapshot.input).toEqual({ input: { token: 'secret-value' } }) + expect(executionOptions.snapshot.input).not.toHaveProperty(PRIVATE_SECRET_PROVENANCE_FIELD) + }) + it.each([ { name: 'standard stream', useDraftState: false }, { name: 'manual event stream', useDraftState: true }, @@ -1955,19 +1980,29 @@ describe('workflow execute async route', () => { expect(runFromBlock?.sourceSnapshot).not.toHaveProperty('resolvedSecretTraceProvenance') }) - it('returns encrypted resolution provenance only to an authenticated internal tool caller', async () => { + it('exports exact provenance for the final response body to an authenticated internal caller', async () => { const caller = EXECUTION_CALLERS[4] configureExecutionCaller(caller) - const provenance = { + const runProvenance = { + version: 1, + complete: true, + entries: [{ name: 'UNRELATED_SECRET', encryptedValue: 'encrypted-unrelated-secret' }], + } + const responseProvenance = { version: 1, complete: true, entries: [{ name: 'CHILD_SECRET', encryptedValue: 'encrypted-child-secret' }], } + loggingSessionMockFns.mockExportResolvedSecretTraceProvenanceForValue.mockReturnValueOnce( + responseProvenance + ) mockExecuteWorkflowCore.mockResolvedValueOnce({ success: true, status: 'completed', output: { ok: true }, - executionState: { resolvedSecretTraceProvenance: provenance }, + executionState: { + resolvedSecretTraceProvenance: runProvenance, + }, metadata: { duration: 100, startTime: '2026-01-01T00:00:00Z', @@ -1985,8 +2020,48 @@ describe('workflow execute async route', () => { ) await expect(response.json()).resolves.toMatchObject({ output: { ok: true }, - __resolvedSecretTraceProvenance: provenance, + __resolvedSecretTraceProvenance: responseProvenance, }) + expect( + loggingSessionMockFns.mockExportResolvedSecretTraceProvenanceForValue + ).toHaveBeenCalledWith( + expect.objectContaining({ + success: true, + executionId: 'execution-123', + output: { ok: true }, + }) + ) + }) + + it('includes a thrown execution error in the exact response-provenance boundary', async () => { + const caller = EXECUTION_CALLERS[4] + configureExecutionCaller(caller) + const responseProvenance = { + version: 1, + complete: true, + entries: [{ name: 'ERROR_SECRET', encryptedValue: 'encrypted-error-secret' }], + } + loggingSessionMockFns.mockExportResolvedSecretTraceProvenanceForValue.mockReturnValueOnce( + responseProvenance + ) + mockExecuteWorkflowCore.mockRejectedValueOnce(new Error('resolved error value')) + const request = createCallerExecutionRequest(caller, undefined, 'sync') + request.headers.set('x-sim-request-private-tool-metadata', 'resolved-secret-provenance-v1') + + const response = await POST(request, { params: Promise.resolve({ id: 'workflow-1' }) }) + const body = await response.json() + + expect(response.status).toBe(500) + expect(body).toMatchObject({ + success: false, + error: 'resolved error value', + __resolvedSecretTraceProvenance: responseProvenance, + }) + expect( + loggingSessionMockFns.mockExportResolvedSecretTraceProvenanceForValue + ).toHaveBeenCalledWith( + expect.objectContaining({ success: false, error: 'resolved error value' }) + ) }) it('does not expose private provenance metadata to non-internal callers', async () => { diff --git a/apps/sim/app/api/workflows/[id]/execute/route.ts b/apps/sim/app/api/workflows/[id]/execute/route.ts index 308b786dd5d..e0d2953bc6b 100644 --- a/apps/sim/app/api/workflows/[id]/execute/route.ts +++ b/apps/sim/app/api/workflows/[id]/execute/route.ts @@ -157,12 +157,7 @@ import type { IterationContext, SerializableExecutionState, } from '@/executor/execution/types' -import type { - BlockLog, - ExecutionResult, - NormalizedBlockOutput, - StreamingExecution, -} from '@/executor/types' +import type { BlockLog, NormalizedBlockOutput, StreamingExecution } from '@/executor/types' import { getExecutionErrorStatus, hasExecutionResult } from '@/executor/utils/errors' import type { ResolvedSecretTraceProvenanceV1 } from '@/executor/utils/resolved-secret-trace-registry' import { Serializer } from '@/serializer' @@ -202,7 +197,7 @@ function createExecutionJsonResponse( body: Record, init: ResponseInit | undefined, includePrivateProvenance: boolean, - result?: ExecutionResult + loggingSession?: LoggingSession ): NextResponse { if (!includePrivateProvenance) { return NextResponse.json(body, init) @@ -213,11 +208,12 @@ function createExecutionJsonResponse( return NextResponse.json( { ...body, - [RESOLVED_SECRET_PROVENANCE_FIELD]: result?.executionState?.resolvedSecretTraceProvenance ?? { - version: 1, - complete: false, - entries: [], - }, + [RESOLVED_SECRET_PROVENANCE_FIELD]: + loggingSession?.exportResolvedSecretTraceProvenanceForValue(body) ?? { + version: 1, + complete: false, + entries: [], + }, }, { ...init, headers } ) @@ -1583,7 +1579,7 @@ async function handleExecutePost( }, { status: 408 }, includePrivateTraceProvenance, - result + loggingSession ) } @@ -1654,7 +1650,7 @@ async function handleExecutePost( filteredResult, undefined, includePrivateTraceProvenance, - result + loggingSession ) } catch (error: unknown) { const executionTimedOut = didExecutionTimeOut(error) @@ -1716,7 +1712,7 @@ async function handleExecutePost( }, { status }, includePrivateTraceProvenance, - executionResult + loggingSession ) } finally { requestAbort.cleanup() diff --git a/apps/sim/blocks/blocks/knowledge.ts b/apps/sim/blocks/blocks/knowledge.ts index 7debc95c938..4aebae6984a 100644 --- a/apps/sim/blocks/blocks/knowledge.ts +++ b/apps/sim/blocks/blocks/knowledge.ts @@ -1,3 +1,4 @@ +import { isPlainRecord } from '@sim/utils/object' import { PackageSearchIcon } from '@/components/icons' import { DEFAULT_RERANKER_MODEL, SUPPORTED_RERANKER_MODELS } from '@/lib/knowledge/reranker-models' import type { BlockConfig } from '@/blocks/types' @@ -377,6 +378,7 @@ export const KnowledgeBlock: BlockConfig = { } }, params: (params) => { + params = { ...params } const knowledgeBaseId = params.knowledgeBaseId ? String(params.knowledgeBaseId).trim() : '' if (!knowledgeBaseId) { throw new Error('Knowledge base ID is required') @@ -428,6 +430,19 @@ export const KnowledgeBlock: BlockConfig = { params.documentId = String(params.upsertDocumentId).trim() } + if ( + (params.operation === 'create_document' || params.operation === 'upsert_document') && + typeof params.documentTags === 'string' && + params.documentTags.trim().length > 0 + ) { + try { + const documentTags: unknown = JSON.parse(params.documentTags) + if (Array.isArray(documentTags) || isPlainRecord(documentTags)) { + params.documentTags = documentTags + } + } catch {} + } + // Convert enabled dropdown string to boolean for update_chunk if (params.operation === 'update_chunk' && typeof params.enabled === 'string') { params.enabled = params.enabled === 'true' diff --git a/apps/sim/ee/workspace-forking/lib/copy/cleanup-failed.test.ts b/apps/sim/ee/workspace-forking/lib/copy/cleanup-failed.test.ts index c122f181427..e36cfc54c22 100644 --- a/apps/sim/ee/workspace-forking/lib/copy/cleanup-failed.test.ts +++ b/apps/sim/ee/workspace-forking/lib/copy/cleanup-failed.test.ts @@ -1,7 +1,13 @@ /** * @vitest-environment node */ -import { knowledgeBase, workflow, workflowBlocks, workflowDeploymentVersion } from '@sim/db/schema' +import { + document, + knowledgeBase, + workflow, + workflowBlocks, + workflowDeploymentVersion, +} from '@sim/db/schema' import { dbChainMockFns, queueTableRows, resetDbChainMock } from '@sim/testing' import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest' @@ -269,6 +275,31 @@ describe('cleanup-failed', () => { expect(updates()).toHaveLength(0) expect(mockInvalidateDeployedStateCache).not.toHaveBeenCalled() }) + + it('attempts every workflow, then reports a workflow-scoped cleanup failure', async () => { + queueTableRows(workflowDeploymentVersion, [ + { id: 'dv-failed', version: 5, state: versionState('failed-kb') }, + ]) + queueTableRows(workflowDeploymentVersion, [ + { id: 'dv-cleaned', version: 5, state: versionState('failed-kb') }, + ]) + dbChainMockFns.set.mockImplementationOnce(() => { + throw new Error('first workflow update failed') + }) + + await expect( + clearFailedReferencesInDeploymentVersions( + new Set(['wf-failed', 'wf-cleaned']), + failedByKind(), + 'test' + ) + ).rejects.toThrow('Failed to clear deployment-version references for 1 workflow(s)') + + // The second workflow is still processed after the first workflow's update fails. + expect(dbChainMockFns.update).toHaveBeenCalledTimes(2) + expect(mockInvalidateDeployedStateCache).toHaveBeenCalledTimes(1) + expect(mockInvalidateDeployedStateCache).toHaveBeenCalledWith('dv-cleaned') + }) }) describe('clearFailedForkResourceReferences', () => { @@ -316,6 +347,75 @@ describe('cleanup-failed', () => { expect(deletes()[0].table).toBe(knowledgeBase) }) + it('keeps a failed copied knowledge base when it contains a non-fork document', async () => { + queueTableRows(knowledgeBase, [{ id: 'failed-kb' }]) + + const cleaned = await clearFailedForkResourceReferences({ + childWorkspaceId: 'child-ws', + failures: [{ kind: 'knowledge-base', childId: 'failed-kb', documentChildIds: [] }], + requestId: 'test', + }) + + expect(cleaned).toEqual({ cleared: 0, clearingFailed: false }) + expect(dbChainMockFns.update).not.toHaveBeenCalled() + expect(dbChainMockFns.delete).not.toHaveBeenCalled() + expect(mockInvalidateDeployedStateCache).not.toHaveBeenCalled() + }) + + it('clears only failed fork-document references when a user document keeps the KB alive', async () => { + queueTableRows(knowledgeBase, [{ id: 'failed-kb' }]) + queueTableRows(workflow, [{ id: 'wf-1' }]) + queueTableRows(workflowBlocks, [ + { + ...draftBlockRow('failed-kb'), + subBlocks: { + ...draftBlockRow('failed-kb').subBlocks, + documentId: { + id: 'documentId', + type: 'document-selector', + value: 'fork_document_aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', + }, + }, + }, + ]) + + const cleaned = await clearFailedForkResourceReferences({ + childWorkspaceId: 'child-ws', + failures: [ + { + kind: 'knowledge-base', + childId: 'failed-kb', + documentChildIds: ['fork_document_aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa'], + }, + ], + requestId: 'test', + }) + + expect(cleaned).toEqual({ cleared: 1, clearingFailed: false }) + const cleared = updates()[0].values.subBlocks as Record + expect(cleared.knowledgeBaseId.value).toBe('failed-kb') + expect(cleared.documentId.value).toBe('') + expect(deletes().map(({ table }) => table)).toEqual([document]) + }) + + it('guards the final KB delete against a non-fork document inserted during cleanup', async () => { + queueTableRows(workflow, []) + + await clearFailedForkResourceReferences({ + childWorkspaceId: 'child-ws', + failures: [{ kind: 'knowledge-base', childId: 'failed-kb', documentChildIds: [] }], + requestId: 'test', + }) + + const deletePredicate = dbChainMockFns.where.mock.calls.at(-1)?.[0] + expect(deletePredicate).toEqual( + expect.objectContaining({ + type: 'and', + conditions: expect.arrayContaining([expect.objectContaining({ type: 'notExists' })]), + }) + ) + }) + it('sweeps a deployed target version even when no draft referenced the failed id', async () => { // Draft is clean (other-kb), but a deployed target version still points at the dropped // placeholder - the deployed-target scope (not draft divergence) catches it. @@ -376,5 +476,26 @@ describe('cleanup-failed', () => { // The drop is skipped, so the placeholder row survives (no delete issued). expect(dbChainMockFns.delete).not.toHaveBeenCalled() }) + + it('keeps placeholders when a deployed-version cleanup fails after draft cleanup succeeds', async () => { + queueTableRows(workflow, [{ id: 'wf-1' }]) + queueTableRows(workflowBlocks, [draftBlockRow('other-kb')]) + queueTableRows(workflowDeploymentVersion, [ + { id: 'dv-failed', version: 5, state: versionState('failed-kb') }, + ]) + dbChainMockFns.set.mockImplementationOnce(() => { + throw new Error('deployment update failed') + }) + + const cleaned = await clearFailedForkResourceReferences({ + childWorkspaceId: 'child-ws', + failures: [{ kind: 'knowledge-base', childId: 'failed-kb', documentChildIds: [] }], + deployedTargetWorkflowIds: ['wf-deployed'], + requestId: 'test', + }) + + expect(cleaned).toEqual({ cleared: 0, clearingFailed: true }) + expect(dbChainMockFns.delete).not.toHaveBeenCalled() + }) }) }) diff --git a/apps/sim/ee/workspace-forking/lib/copy/cleanup-failed.ts b/apps/sim/ee/workspace-forking/lib/copy/cleanup-failed.ts index dcf3f6ffed8..a33abf5d12b 100644 --- a/apps/sim/ee/workspace-forking/lib/copy/cleanup-failed.ts +++ b/apps/sim/ee/workspace-forking/lib/copy/cleanup-failed.ts @@ -9,10 +9,13 @@ import { } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { getErrorMessage } from '@sim/utils/errors' -import { and, asc, eq, gt, inArray } from 'drizzle-orm' +import { and, asc, eq, exists, gt, inArray, isNull, notExists, sql } from 'drizzle-orm' import { isRecord, type SubBlockRecord } from '@/lib/workflows/persistence/remap-internal-ids' import { invalidateDeployedStateCache } from '@/lib/workflows/persistence/utils' -import type { ForkFailedResource } from '@/ee/workspace-forking/lib/copy/copy-resources' +import { + FORK_DOCUMENT_ID_PATTERN, + type ForkFailedResource, +} from '@/ee/workspace-forking/lib/copy/copy-resources' import type { ForkCopyResolver } from '@/ee/workspace-forking/lib/remap/fork-bootstrap' import { clearDependentsOnRemap, @@ -28,6 +31,30 @@ const WORKFLOW_PAGE = 200 /** Deployment versions loaded per page so a workflow with many versions never loads all at once. */ const DEPLOYMENT_VERSION_PAGE = 100 +async function findKnowledgeBasesWithNonForkDocuments(ids: string[]): Promise> { + if (ids.length === 0) return new Set() + const rows = await db + .select({ id: knowledgeBase.id }) + .from(knowledgeBase) + .where(and(inArray(knowledgeBase.id, ids), exists(liveNonForkDocumentQuery()))) + .limit(ids.length) + return new Set(rows.map(({ id }) => id)) +} + +function liveNonForkDocumentQuery() { + return db + .select({ id: document.id }) + .from(document) + .where( + and( + eq(document.knowledgeBaseId, knowledgeBase.id), + sql`${document.id} !~ ${FORK_DOCUMENT_ID_PATTERN}`, + isNull(document.deletedAt), + isNull(document.archivedAt) + ) + ) +} + /** Identity-or-clear resolver: a failed id resolves to null (cleared), any other id to itself. */ function buildFailedResolver(failedByKind: Map>): ForkCopyResolver { return (kind, id) => (failedByKind.get(kind)?.has(id) ? null : id) @@ -77,12 +104,9 @@ function clearFailedSubBlockReferences( * is the count of failed resources whose references were cleared. * * Storage accounting: this cleanup never decrements storage usage because it never removes - * anything that was counted. Copied file blobs are the only counted copies (incremented in - * `executeForkFileBlobCopies` only after the blob lands), and a failed file's blob never - * landed - its metadata row is intentionally left re-uploadable, and nothing was charged. The - * dropped table/KB/document placeholders are DB rows the upload path never counts, and any KB - * blobs copied before their KB failed are left in storage (rows only are dropped here) but - * uncounted - mirroring the KB upload path, which never counts KB blobs. + * anything that remains counted. A failed file copy is not charged and leaves its metadata row + * re-uploadable. A failed KB copy reverses its usage and retires its active file-ownership rows + * before reaching this cleanup; deterministic blobs remain available for a safe retry. */ export async function clearFailedForkResourceReferences(params: { childWorkspaceId: string @@ -94,6 +118,12 @@ export async function clearFailedForkResourceReferences(params: { const { childWorkspaceId, failures, requestId = 'unknown' } = params if (failures.length === 0) return { cleared: 0, clearingFailed: false } + const failedKnowledgeBaseIds = failures.flatMap((failure) => + failure.kind === 'knowledge-base' ? [failure.childId] : [] + ) + const retainedKnowledgeBaseIds = + await findKnowledgeBasesWithNonForkDocuments(failedKnowledgeBaseIds) + const failedByKind = new Map>() const markFailed = (kind: ForkRemapKind, id: string) => { const set = failedByKind.get(kind) @@ -105,24 +135,43 @@ export async function clearFailedForkResourceReferences(params: { // Standalone documents copied into an already-existing target KB (the doc-into-mapped-KB sync // path) - dropped individually, since their KB is not ours to remove. const docIds: string[] = [] + let cleanupCount = 0 for (const failure of failures) { if (failure.kind === 'table') { markFailed('table', failure.childId) tableIds.push(failure.childId) + cleanupCount += 1 } else if (failure.kind === 'knowledge-document') { markFailed('knowledge-document', failure.childId) docIds.push(failure.childId) + cleanupCount += 1 } else if (failure.kind === 'file') { // A failed file blob: clear `file-upload` references to its copied storage key. No row to // drop - the metadata row is left in place so the user can re-upload the missing blob. markFailed('file', failure.childKey) + cleanupCount += 1 } else { + if (retainedKnowledgeBaseIds.has(failure.childId)) { + for (const docId of failure.documentChildIds) { + markFailed('knowledge-document', docId) + docIds.push(docId) + } + if (failure.documentChildIds.length > 0) cleanupCount += 1 + logger.warn( + `[${requestId}] Keeping a failed copied knowledge base that contains non-fork documents`, + { childWorkspaceId, childKnowledgeBaseId: failure.childId } + ) + continue + } markFailed('knowledge-base', failure.childId) for (const docId of failure.documentChildIds) markFailed('knowledge-document', docId) kbIds.push(failure.childId) + cleanupCount += 1 } } + if (failedByKind.size === 0) return { cleared: 0, clearingFailed: false } + // Whether BOTH reference-clear phases completed without throwing. The placeholder drop below is // gated on this: if clearing threw, a workflow (draft or deployed version) may still reference // the failed id, so dropping its placeholder would create a dangling reference to a deleted row. @@ -185,7 +234,9 @@ export async function clearFailedForkResourceReferences(params: { await db.delete(userTableDefinitions).where(inArray(userTableDefinitions.id, tableIds)) } if (kbIds.length > 0) { - await db.delete(knowledgeBase).where(inArray(knowledgeBase.id, kbIds)) + await db + .delete(knowledgeBase) + .where(and(inArray(knowledgeBase.id, kbIds), notExists(liveNonForkDocumentQuery()))) } if (docIds.length > 0) { await db.delete(document).where(inArray(document.id, docIds)) @@ -197,7 +248,7 @@ export async function clearFailedForkResourceReferences(params: { }) } - return { cleared: failures.length, clearingFailed: false } + return { cleared: cleanupCount, clearingFailed: false } } /** @@ -304,6 +355,9 @@ export function rewriteDeploymentVersionState( * no-op. After a version is rewritten its cached deployed state is evicted so execute/serve rebuilds * from the cleaned snapshot. Bounded work (no long transaction): per-version short UPDATEs, versions * keyset-paginated, and a per-workflow failure is logged without aborting the other workflows. + * After every workflow has been attempted, any failures are reported to the caller so it can keep + * the failed resource placeholders in place rather than deleting rows a deployed version may still + * reference. */ export async function clearFailedReferencesInDeploymentVersions( workflowIds: ReadonlySet, @@ -312,6 +366,7 @@ export async function clearFailedReferencesInDeploymentVersions( ): Promise { if (workflowIds.size === 0) return const resolve = buildFailedResolver(failedByKind) + const failures: unknown[] = [] for (const workflowId of workflowIds) { try { @@ -355,10 +410,18 @@ export async function clearFailedReferencesInDeploymentVersions( afterVersion = versions[versions.length - 1].version } } catch (error) { + failures.push(error) logger.error(`[${requestId}] Failed to clear references in deployment versions`, { workflowId, error: getErrorMessage(error), }) } } + + if (failures.length > 0) { + throw new AggregateError( + failures, + `Failed to clear deployment-version references for ${failures.length} workflow(s)` + ) + } } diff --git a/apps/sim/ee/workspace-forking/lib/copy/copy-resources.test.ts b/apps/sim/ee/workspace-forking/lib/copy/copy-resources.test.ts index 62c1dbe0157..e15e0055fdf 100644 --- a/apps/sim/ee/workspace-forking/lib/copy/copy-resources.test.ts +++ b/apps/sim/ee/workspace-forking/lib/copy/copy-resources.test.ts @@ -1,6 +1,8 @@ /** * @vitest-environment node */ + +import { sha256Hex } from '@sim/security/hash' import { dbChainMockFns, resetDbChainMock, @@ -8,15 +10,26 @@ import { storageServiceMockFns, } from '@sim/testing' import { beforeEach, describe, expect, it, vi } from 'vitest' +import { hashDurableSecretProvenanceValue } from '@/lib/execution/durable-secret-provenance' +import { + bindKnowledgeDocumentFieldSecretProvenance, + createKnowledgeDocumentSourceValue, +} from '@/lib/knowledge/secret-provenance' const { mockIncrementStorageUsageInTx, mockDecrementStorageUsageInTx, mockResolveStorageBillingContext, + mockRecordKnowledgeBaseFileOwnership, + mockPersistCopiedResourceMappings, + mockDeleteCopiedResourceMappingsByTargets, } = vi.hoisted(() => ({ mockIncrementStorageUsageInTx: vi.fn(), mockDecrementStorageUsageInTx: vi.fn(), mockResolveStorageBillingContext: vi.fn(), + mockRecordKnowledgeBaseFileOwnership: vi.fn(), + mockPersistCopiedResourceMappings: vi.fn(), + mockDeleteCopiedResourceMappingsByTargets: vi.fn(), })) vi.mock('@/lib/uploads/core/storage-service', () => storageServiceMock) @@ -25,6 +38,13 @@ vi.mock('@/lib/billing/storage', () => ({ incrementStorageUsageForBillingContextInTx: mockIncrementStorageUsageInTx, resolveStorageBillingContext: mockResolveStorageBillingContext, })) +vi.mock('@/lib/uploads/server/metadata', () => ({ + recordKnowledgeBaseFileOwnership: mockRecordKnowledgeBaseFileOwnership, +})) +vi.mock('@/ee/workspace-forking/lib/mapping/mapping-store', () => ({ + persistCopiedResourceMappings: mockPersistCopiedResourceMappings, + deleteCopiedResourceMappingsByTargets: mockDeleteCopiedResourceMappingsByTargets, +})) import type { DbOrTx } from '@/lib/db/types' import { @@ -59,6 +79,34 @@ const sourceDoc = { mimeType: 'application/pdf', } +function queueMappedDocumentCopy( + source: Record = sourceDoc, + provenanceRow: Record = source +): void { + dbChainMockFns.limit + .mockResolvedValueOnce([]) + .mockResolvedValueOnce([source]) + .mockResolvedValueOnce([provenanceRow]) + .mockResolvedValueOnce([]) +} + +function mappedDocumentPlan(): ForkContentPlan { + return basePlan({ + documents: [ + { + sourceDocId: 'doc-1', + childDocId: 'child-doc-1', + childKnowledgeBaseId: 'existing-target-kb', + storageKey: 'kb/source-key', + fileUrl: '/api/files/serve/kb%2Fsource-key', + fileSize: 321, + filename: 'report.pdf', + mimeType: 'application/pdf', + }, + ], + }) +} + describe('copyForkResourceContent', () => { beforeEach(() => { vi.clearAllMocks() @@ -80,6 +128,7 @@ describe('copyForkResourceContent', () => { }) mockIncrementStorageUsageInTx.mockResolvedValue(321) mockDecrementStorageUsageInTx.mockResolvedValue(undefined) + mockRecordKnowledgeBaseFileOwnership.mockResolvedValue(undefined) }) it('rewrites in-workspace resource URLs nested in copied table cell data', async () => { @@ -227,13 +276,200 @@ describe('copyForkResourceContent', () => { const uploadArg = storageServiceMockFns.mockUploadFile.mock.calls[0][0] expect(uploadArg.context).toBe('knowledge-base') expect(uploadArg.preserveKey).toBe(true) - // The ownership binding is what verifyKBFileAccess resolves the owning workspace from; - // it must name the CHILD workspace and the initiating user, or the copy is download-denied. expect(uploadArg.metadata).toEqual({ userId: 'user-1', workspaceId: 'child-ws', originalName: 'report.pdf', }) + expect(mockRecordKnowledgeBaseFileOwnership).toHaveBeenNthCalledWith(1, { + key: uploadArg.customKey, + userId: 'user-1', + workspaceId: 'child-ws', + originalName: 'report.pdf', + contentType: 'application/pdf', + size: 321, + }) + expect(mockRecordKnowledgeBaseFileOwnership).toHaveBeenCalledWith( + { + key: uploadArg.customKey, + userId: 'user-1', + workspaceId: 'child-ws', + originalName: 'report.pdf', + contentType: 'application/pdf', + size: 321, + }, + expect.anything() + ) + expect(mockRecordKnowledgeBaseFileOwnership.mock.invocationCallOrder[0]).toBeLessThan( + storageServiceMockFns.mockUploadFile.mock.invocationCallOrder[0] + ) + expect(mockRecordKnowledgeBaseFileOwnership.mock.invocationCallOrder[0]).toBeLessThan( + mockIncrementStorageUsageInTx.mock.invocationCallOrder[0] + ) + // Compatibility with a content-copy job queued before document mapping context existed. + expect(mockPersistCopiedResourceMappings).not.toHaveBeenCalled() + }) + + it('uses the blob content digest so a retry cannot adopt an older failed snapshot', async () => { + dbChainMockFns.limit + .mockResolvedValueOnce([sourceDoc]) + .mockResolvedValueOnce([]) + .mockResolvedValueOnce([sourceDoc]) + const body = Buffer.from('new-source-bytes') + storageServiceMockFns.mockDownloadFile.mockResolvedValueOnce(body) + storageServiceMockFns.mockHeadObject.mockImplementationOnce(async (key: string) => + key === 'kb/fork-child-doc-1' ? { size: 321 } : null + ) + + const result = await copyForkResourceContent({ + contentPlan: basePlan({ + knowledgeBases: [ + { + sourceId: 'src-kb', + childId: 'child-kb', + documentIdMap: { 'doc-1': 'child-doc-1' }, + }, + ], + }), + requestId: 'test', + }) + + const expectedKey = `kb/fork-child-doc-1-${sha256Hex(body)}` + expect(result).toEqual({ copied: 1, failed: 0, failures: [] }) + expect(storageServiceMockFns.mockHeadObject).toHaveBeenCalledWith(expectedKey, 'knowledge-base') + expect(storageServiceMockFns.mockUploadFile).toHaveBeenCalledWith( + expect.objectContaining({ customKey: expectedKey }) + ) + expect(mockRecordKnowledgeBaseFileOwnership).toHaveBeenCalledWith( + expect.objectContaining({ key: expectedKey }), + expect.anything() + ) + }) + + it('reuses a content-addressed blob only after hashing the current source bytes', async () => { + dbChainMockFns.limit + .mockResolvedValueOnce([sourceDoc]) + .mockResolvedValueOnce([]) + .mockResolvedValueOnce([sourceDoc]) + const body = Buffer.from('same-source-bytes') + const expectedKey = `kb/fork-child-doc-1-${sha256Hex(body)}` + storageServiceMockFns.mockDownloadFile.mockResolvedValueOnce(body) + storageServiceMockFns.mockHeadObject.mockResolvedValueOnce({ size: body.length }) + + const result = await copyForkResourceContent({ + contentPlan: basePlan({ + knowledgeBases: [ + { + sourceId: 'src-kb', + childId: 'child-kb', + documentIdMap: { 'doc-1': 'child-doc-1' }, + }, + ], + }), + requestId: 'test', + }) + + expect(result).toEqual({ copied: 1, failed: 0, failures: [] }) + expect(storageServiceMockFns.mockDownloadFile).toHaveBeenCalledTimes(1) + expect(storageServiceMockFns.mockHeadObject).toHaveBeenCalledWith(expectedKey, 'knowledge-base') + expect(storageServiceMockFns.mockUploadFile).not.toHaveBeenCalled() + expect(storageServiceMockFns.mockDeleteFile).not.toHaveBeenCalled() + }) + + it('persists every successfully copied full-KB document identity with bounded page orientation', async () => { + dbChainMockFns.limit + .mockResolvedValueOnce([sourceDoc]) + .mockResolvedValueOnce([]) + .mockResolvedValueOnce([sourceDoc]) + + const result = await copyForkResourceContent({ + contentPlan: basePlan({ + knowledgeBases: [{ sourceId: 'src-kb', childId: 'child-kb', documentIdMap: {} }], + documentMappingContext: { + edgeChildWorkspaceId: 'edge-child-ws', + sourceIsParent: true, + }, + }), + requestId: 'test', + }) + + expect(result).toEqual({ copied: 1, failed: 0, failures: [] }) + expect(mockPersistCopiedResourceMappings).toHaveBeenCalledWith({ + executor: expect.anything(), + edgeChildWorkspaceId: 'edge-child-ws', + userId: 'user-1', + sourceIsParent: true, + entries: [ + { + resourceType: 'knowledge_document', + parentResourceId: 'doc-1', + childResourceId: expect.stringMatching(/^fork_document_/), + }, + ], + }) + }) + + it('keeps the KB all-or-nothing when its document mapping page cannot be persisted', async () => { + dbChainMockFns.limit + .mockResolvedValueOnce([sourceDoc]) + .mockResolvedValueOnce([]) + .mockResolvedValueOnce([sourceDoc]) + .mockResolvedValueOnce([]) + .mockResolvedValueOnce([{ id: 'copied-doc-target' }]) + storageServiceMockFns.mockHeadObject.mockResolvedValueOnce({}) + mockPersistCopiedResourceMappings.mockRejectedValueOnce(new Error('mapping write failed')) + + const result = await copyForkResourceContent({ + contentPlan: basePlan({ + knowledgeBases: [{ sourceId: 'src-kb', childId: 'child-kb', documentIdMap: {} }], + documentMappingContext: { + edgeChildWorkspaceId: 'edge-child-ws', + sourceIsParent: true, + }, + }), + requestId: 'test', + }) + + expect(result).toEqual({ + copied: 0, + failed: 1, + failures: [{ kind: 'knowledge-base', childId: 'child-kb', documentChildIds: [] }], + }) + expect(mockDecrementStorageUsageInTx).toHaveBeenCalled() + expect(mockDeleteCopiedResourceMappingsByTargets).toHaveBeenCalledWith({ + executor: expect.anything(), + edgeChildWorkspaceId: 'edge-child-ws', + sourceIsParent: true, + targets: [{ resourceType: 'knowledge_document', resourceId: 'copied-doc-target' }], + }) + expect(dbChainMockFns.set).toHaveBeenCalledWith({ deletedAt: expect.any(Date) }) + expect(storageServiceMockFns.mockUploadFile).not.toHaveBeenCalled() + expect(storageServiceMockFns.mockDeleteFile).not.toHaveBeenCalled() + }) + + it('refuses to pair a stale document snapshot with newer provenance', async () => { + const newerSource = { ...sourceDoc, filename: 'newer-report.pdf' } + dbChainMockFns.limit + .mockResolvedValueOnce([sourceDoc]) + .mockResolvedValueOnce([]) + .mockResolvedValueOnce([newerSource]) + + const result = await copyForkResourceContent({ + contentPlan: basePlan({ + knowledgeBases: [{ sourceId: 'src-kb', childId: 'child-kb', documentIdMap: {} }], + }), + requestId: 'test', + }) + + expect(result).toEqual({ + copied: 0, + failed: 1, + failures: [{ kind: 'knowledge-base', childId: 'child-kb', documentChildIds: [] }], + }) + expect(storageServiceMockFns.mockDownloadFile).not.toHaveBeenCalled() + expect(storageServiceMockFns.mockUploadFile).not.toHaveBeenCalled() + expect(mockRecordKnowledgeBaseFileOwnership).not.toHaveBeenCalled() + expect(mockIncrementStorageUsageInTx).not.toHaveBeenCalled() }) it('charges each copied KB blob by exact document bytes in the metadata activation transaction', async () => { @@ -269,6 +505,42 @@ describe('copyForkResourceContent', () => { ) }) + it('leaves a discoverable ownership reservation when a copied KB upload fails', async () => { + dbChainMockFns.limit + .mockResolvedValueOnce([sourceDoc]) + .mockResolvedValueOnce([]) + .mockResolvedValueOnce([sourceDoc]) + storageServiceMockFns.mockUploadFile.mockRejectedValueOnce(new Error('upload failed')) + + const result = await copyForkResourceContent({ + contentPlan: basePlan({ + knowledgeBases: [ + { + sourceId: 'src-kb', + childId: 'child-kb', + documentIdMap: { 'doc-1': 'child-doc-1' }, + }, + ], + }), + requestId: 'test', + }) + + const targetKey = `kb/fork-child-doc-1-${sha256Hex(Buffer.from('blob-bytes'))}` + expect(result.failed).toBe(1) + expect(mockRecordKnowledgeBaseFileOwnership).toHaveBeenCalledWith({ + key: targetKey, + userId: 'user-1', + workspaceId: 'child-ws', + originalName: 'report.pdf', + contentType: 'application/pdf', + size: 321, + }) + expect(mockRecordKnowledgeBaseFileOwnership.mock.invocationCallOrder[0]).toBeLessThan( + storageServiceMockFns.mockUploadFile.mock.invocationCallOrder[0] + ) + expect(storageServiceMockFns.mockDeleteFile).not.toHaveBeenCalled() + }) + it('does not resolve KB billing context for an empty document page', async () => { const result = await copyForkResourceContent({ contentPlan: basePlan({ @@ -283,9 +555,15 @@ describe('copyForkResourceContent', () => { }) it('does not resolve KB billing context when the page is fully finalized from a prior attempt', async () => { - dbChainMockFns.limit - .mockResolvedValueOnce([sourceDoc]) - .mockResolvedValueOnce([{ id: 'child-doc-1' }]) + dbChainMockFns.limit.mockResolvedValueOnce([sourceDoc]).mockResolvedValueOnce([ + { + id: 'child-doc-1', + knowledgeBaseId: 'child-kb', + storageKey: 'kb/fork-child-doc-1', + archivedAt: null, + deletedAt: null, + }, + ]) const result = await copyForkResourceContent({ contentPlan: basePlan({ @@ -306,13 +584,193 @@ describe('copyForkResourceContent', () => { expect(dbChainMockFns.transaction).not.toHaveBeenCalled() }) + it('adopts a finalized content-addressed document from a prior attempt', async () => { + dbChainMockFns.limit.mockResolvedValueOnce([sourceDoc]).mockResolvedValueOnce([ + { + id: 'child-doc-1', + knowledgeBaseId: 'child-kb', + storageKey: `kb/fork-child-doc-1-${'a'.repeat(64)}`, + archivedAt: null, + deletedAt: null, + }, + ]) + + const result = await copyForkResourceContent({ + contentPlan: basePlan({ + knowledgeBases: [ + { + sourceId: 'src-kb', + childId: 'child-kb', + documentIdMap: { 'doc-1': 'child-doc-1' }, + }, + ], + }), + requestId: 'test', + }) + + expect(result).toEqual({ copied: 1, failed: 0, failures: [] }) + expect(storageServiceMockFns.mockDownloadFile).not.toHaveBeenCalled() + expect(storageServiceMockFns.mockUploadFile).not.toHaveBeenCalled() + }) + + it('repairs a missing mapping for a full-KB document finalized by a prior attempt', async () => { + dbChainMockFns.limit.mockResolvedValueOnce([sourceDoc]).mockResolvedValueOnce([ + { + id: 'child-doc-1', + knowledgeBaseId: 'child-kb', + storageKey: 'kb/fork-child-doc-1', + archivedAt: null, + deletedAt: null, + }, + ]) + + const result = await copyForkResourceContent({ + contentPlan: basePlan({ + knowledgeBases: [ + { + sourceId: 'src-kb', + childId: 'child-kb', + documentIdMap: { 'doc-1': 'child-doc-1' }, + }, + ], + documentMappingContext: { + edgeChildWorkspaceId: 'edge-child-ws', + sourceIsParent: false, + }, + }), + requestId: 'test', + }) + + expect(result).toEqual({ copied: 1, failed: 0, failures: [] }) + expect(mockResolveStorageBillingContext).not.toHaveBeenCalled() + expect(mockPersistCopiedResourceMappings).toHaveBeenCalledWith({ + executor: expect.anything(), + edgeChildWorkspaceId: 'edge-child-ws', + userId: 'user-1', + sourceIsParent: false, + entries: [ + { + resourceType: 'knowledge_document', + parentResourceId: 'doc-1', + childResourceId: 'child-doc-1', + }, + ], + }) + }) + + it('rejects an active full-KB target with conflicting ownership before external I/O', async () => { + dbChainMockFns.limit.mockResolvedValueOnce([sourceDoc]).mockResolvedValueOnce([ + { + id: 'child-doc-1', + knowledgeBaseId: 'other-kb', + storageKey: 'kb/fork-child-doc-1', + archivedAt: null, + deletedAt: null, + }, + ]) + + const result = await copyForkResourceContent({ + contentPlan: basePlan({ + knowledgeBases: [ + { + sourceId: 'src-kb', + childId: 'child-kb', + documentIdMap: { 'doc-1': 'child-doc-1' }, + }, + ], + }), + requestId: 'test', + }) + + expect(result).toEqual({ + copied: 0, + failed: 1, + failures: [ + { kind: 'knowledge-base', childId: 'child-kb', documentChildIds: ['child-doc-1'] }, + ], + }) + expect(storageServiceMockFns.mockDownloadFile).not.toHaveBeenCalled() + expect(storageServiceMockFns.mockUploadFile).not.toHaveBeenCalled() + expect(mockPersistCopiedResourceMappings).not.toHaveBeenCalled() + }) + + it('rejects an archived full-KB target owned by another knowledge base before external I/O', async () => { + dbChainMockFns.limit.mockResolvedValueOnce([sourceDoc]).mockResolvedValueOnce([ + { + id: 'child-doc-1', + knowledgeBaseId: 'other-kb', + storageKey: null, + archivedAt: new Date('2026-08-06T00:00:00.000Z'), + deletedAt: null, + }, + ]) + + const result = await copyForkResourceContent({ + contentPlan: basePlan({ + knowledgeBases: [ + { + sourceId: 'src-kb', + childId: 'child-kb', + documentIdMap: { 'doc-1': 'child-doc-1' }, + }, + ], + }), + requestId: 'test', + }) + + expect(result.failed).toBe(1) + expect(storageServiceMockFns.mockDownloadFile).not.toHaveBeenCalled() + expect(storageServiceMockFns.mockUploadFile).not.toHaveBeenCalled() + }) + + it('rejects an archived full-KB target with a different storage key before external I/O', async () => { + dbChainMockFns.limit.mockResolvedValueOnce([sourceDoc]).mockResolvedValueOnce([ + { + id: 'child-doc-1', + knowledgeBaseId: 'child-kb', + storageKey: 'kb/unrelated', + archivedAt: new Date('2026-08-06T00:00:00.000Z'), + deletedAt: null, + }, + ]) + + const result = await copyForkResourceContent({ + contentPlan: basePlan({ + knowledgeBases: [ + { + sourceId: 'src-kb', + childId: 'child-kb', + documentIdMap: { 'doc-1': 'child-doc-1' }, + }, + ], + }), + requestId: 'test', + }) + + expect(result.failed).toBe(1) + expect(storageServiceMockFns.mockDownloadFile).not.toHaveBeenCalled() + expect(storageServiceMockFns.mockUploadFile).not.toHaveBeenCalled() + }) + it('keeps finalization authoritative when another attempt activates after the page replay guard', async () => { dbChainMockFns.limit .mockResolvedValueOnce([sourceDoc]) .mockResolvedValueOnce([]) .mockResolvedValueOnce([sourceDoc]) .mockResolvedValueOnce([]) - .mockResolvedValueOnce([{ id: 'child-doc-1' }]) + .mockResolvedValueOnce([ + { + id: 'child-doc-1', + knowledgeBaseId: 'child-kb', + storageKey: 'kb/fork-child-doc-1', + filename: 'winner.pdf', + mimeType: 'application/pdf', + fileSize: 456, + uploadedBy: 'winner-user', + archivedAt: null, + deletedAt: null, + }, + ]) dbChainMockFns.returning.mockResolvedValueOnce([]) const result = await copyForkResourceContent({ @@ -331,7 +789,22 @@ describe('copyForkResourceContent', () => { expect(result).toEqual({ copied: 1, failed: 0, failures: [] }) expect(storageServiceMockFns.mockUploadFile).toHaveBeenCalledTimes(1) expect(mockResolveStorageBillingContext).toHaveBeenCalledTimes(1) + expect(mockRecordKnowledgeBaseFileOwnership).toHaveBeenCalledWith( + { + key: 'kb/fork-child-doc-1', + userId: 'winner-user', + workspaceId: 'child-ws', + originalName: 'winner.pdf', + contentType: 'application/pdf', + size: 456, + }, + expect.anything() + ) expect(mockIncrementStorageUsageInTx).not.toHaveBeenCalled() + expect(storageServiceMockFns.mockDeleteFile).toHaveBeenCalledWith({ + key: `kb/fork-child-doc-1-${sha256Hex(Buffer.from('blob-bytes'))}`, + context: 'knowledge-base', + }) }) it('#4 re-reads a copied skill body post-commit and rewrites it via db.update (never from payload)', async () => { @@ -421,21 +894,10 @@ describe('copyForkResourceContent', () => { }) it('U-docs: fills a document copied into an existing target KB (blob re-key + placeholder update)', async () => { + queueMappedDocumentCopy() + const result = await copyForkResourceContent({ - contentPlan: basePlan({ - documents: [ - { - sourceDocId: 'doc-1', - childDocId: 'child-doc-1', - childKnowledgeBaseId: 'existing-target-kb', - storageKey: 'kb/source-key', - fileUrl: '/api/files/serve/kb%2Fsource-key', - fileSize: 321, - filename: 'report.pdf', - mimeType: 'application/pdf', - }, - ], - }), + contentPlan: mappedDocumentPlan(), requestId: 'test', }) @@ -444,61 +906,187 @@ describe('copyForkResourceContent', () => { // The blob is re-keyed and the pre-created placeholder row's blob fields are updated. expect(storageServiceMockFns.mockUploadFile).toHaveBeenCalledTimes(1) expect(dbChainMockFns.update).toHaveBeenCalledTimes(1) + expect(dbChainMockFns.set).toHaveBeenCalledWith( + expect.objectContaining({ secretProvenanceVersion: null }) + ) + expect(dbChainMockFns.values).not.toHaveBeenCalledWith( + expect.objectContaining({ documentId: 'child-doc-1' }) + ) + }) + + it('U-docs: rebinds tracked document provenance through the shared document copier', async () => { + const source = { + ...sourceDoc, + ...createKnowledgeDocumentSourceValue(sourceDoc), + secretProvenanceVersion: 1, + } + const sourceValue = createKnowledgeDocumentSourceValue(source) + const provenance = bindKnowledgeDocumentFieldSecretProvenance( + { + status: 'exact', + entries: [{ name: 'DOCUMENT_NAME', encryptedValue: 'encrypted-name' }], + }, + 'filename', + source.filename + ) + queueMappedDocumentCopy(source, { + ...source, + provenanceSourceHash: hashDurableSecretProvenanceValue(sourceValue), + status: 'exact', + entries: provenance.status === 'exact' ? provenance.entries : [], + }) + + const result = await copyForkResourceContent({ + contentPlan: mappedDocumentPlan(), + requestId: 'test', + }) + + expect(result).toEqual({ copied: 1, failed: 0, failures: [] }) + expect(dbChainMockFns.set).toHaveBeenCalledWith( + expect.objectContaining({ secretProvenanceVersion: 1 }) + ) + expect(dbChainMockFns.values).toHaveBeenCalledWith( + expect.objectContaining({ + documentId: 'child-doc-1', + status: 'exact', + entries: [ + expect.objectContaining({ + name: 'DOCUMENT_NAME', + encryptedValue: 'encrypted-name', + sourceValueHash: expect.any(String), + }), + ], + }) + ) + }) + + it('U-docs: keeps exact-empty provenance tracked instead of turning it into legacy state', async () => { + const source = { + ...sourceDoc, + ...createKnowledgeDocumentSourceValue(sourceDoc), + secretProvenanceVersion: 1, + } + const sourceValue = createKnowledgeDocumentSourceValue(source) + queueMappedDocumentCopy(source, { + ...source, + provenanceSourceHash: hashDurableSecretProvenanceValue(sourceValue), + status: 'exact', + entries: [], + }) + + const result = await copyForkResourceContent({ + contentPlan: mappedDocumentPlan(), + requestId: 'test', + }) + + expect(result).toEqual({ copied: 1, failed: 0, failures: [] }) + expect(dbChainMockFns.values).toHaveBeenCalledWith( + expect.objectContaining({ + documentId: 'child-doc-1', + status: 'exact', + entries: [], + }) + ) + }) + + it('U-docs: preserves tracked unknown provenance instead of laundering it as legacy', async () => { + const source = { + ...sourceDoc, + ...createKnowledgeDocumentSourceValue(sourceDoc), + secretProvenanceVersion: 1, + } + queueMappedDocumentCopy(source, { + ...source, + provenanceSourceHash: null, + status: null, + entries: null, + }) + + const result = await copyForkResourceContent({ + contentPlan: mappedDocumentPlan(), + requestId: 'test', + }) + + expect(result).toEqual({ copied: 1, failed: 0, failures: [] }) + expect(dbChainMockFns.set).toHaveBeenCalledWith( + expect.objectContaining({ secretProvenanceVersion: 1 }) + ) + expect(dbChainMockFns.values).toHaveBeenCalledWith( + expect.objectContaining({ + documentId: 'child-doc-1', + status: 'unknown', + entries: [], + }) + ) }) it('U-docs: a failed document fill is reported as a knowledge-document failure (for cleanup)', async () => { + queueMappedDocumentCopy() + // The placeholder blob update throws; the doc fails on its own without touching its KB. dbChainMockFns.set.mockImplementationOnce(() => { throw new Error('update failed') }) const result = await copyForkResourceContent({ - contentPlan: basePlan({ - documents: [ - { - sourceDocId: 'doc-1', - childDocId: 'child-doc-1', - childKnowledgeBaseId: 'existing-target-kb', - storageKey: 'kb/source-key', - fileUrl: '/api/files/serve/kb%2Fsource-key', - fileSize: 321, - filename: 'report.pdf', - mimeType: 'application/pdf', - }, - ], - }), + contentPlan: { + ...mappedDocumentPlan(), + documentMappingContext: { + edgeChildWorkspaceId: 'edge-child-ws', + sourceIsParent: false, + }, + }, requestId: 'test', }) expect(result.copied).toBe(0) expect(result.failed).toBe(1) expect(result.failures).toEqual([{ kind: 'knowledge-document', childId: 'child-doc-1' }]) + expect(mockDeleteCopiedResourceMappingsByTargets).toHaveBeenCalledWith({ + executor: expect.anything(), + edgeChildWorkspaceId: 'edge-child-ws', + sourceIsParent: false, + targets: [{ resourceType: 'knowledge_document', resourceId: 'child-doc-1' }], + }) }) it('U-docs: refuses to charge when the target knowledge base moved workspaces', async () => { + queueMappedDocumentCopy() dbChainMockFns.for.mockResolvedValueOnce([{ workspaceId: 'other-workspace' }]) const result = await copyForkResourceContent({ - contentPlan: basePlan({ - documents: [ - { - sourceDocId: 'doc-1', - childDocId: 'child-doc-1', - childKnowledgeBaseId: 'existing-target-kb', - storageKey: 'kb/source-key', - fileUrl: '/api/files/serve/kb%2Fsource-key', - fileSize: 321, - filename: 'report.pdf', - mimeType: 'application/pdf', - }, - ], - }), + contentPlan: mappedDocumentPlan(), requestId: 'test', }) expect(result.failures).toEqual([{ kind: 'knowledge-document', childId: 'child-doc-1' }]) expect(mockIncrementStorageUsageInTx).not.toHaveBeenCalled() }) + + it('U-docs: rejects an active target owned by another knowledge base', async () => { + dbChainMockFns.limit.mockResolvedValueOnce([ + { + id: 'child-doc-1', + knowledgeBaseId: 'other-kb', + storageKey: 'kb/fork-child-doc-1', + archivedAt: null, + deletedAt: null, + }, + ]) + + const result = await copyForkResourceContent({ + contentPlan: mappedDocumentPlan(), + requestId: 'test', + }) + + expect(result).toEqual({ + copied: 0, + failed: 1, + failures: [{ kind: 'knowledge-document', childId: 'child-doc-1' }], + }) + expect(storageServiceMockFns.mockDownloadFile).not.toHaveBeenCalled() + expect(storageServiceMockFns.mockUploadFile).not.toHaveBeenCalled() + }) }) describe('copyForkResourceContainers custom-tool code env rewrite', () => { @@ -537,6 +1125,7 @@ describe('copyForkResourceContainers custom-tool code env rewrite', () => { now: new Date(), selection: customToolSelection, workflowIdMap: new Map(), + documentMappingContext: { edgeChildWorkspaceId: 'child-ws', sourceIsParent: true }, resolveEnvName: (key) => (key === 'SLACK_API_KEY' ? 'SLACK_API_KEY_TEST' : key), }) expect(inserted).toHaveLength(1) @@ -557,6 +1146,7 @@ describe('copyForkResourceContainers custom-tool code env rewrite', () => { now: new Date(), selection: customToolSelection, workflowIdMap: new Map(), + documentMappingContext: { edgeChildWorkspaceId: 'child-ws', sourceIsParent: true }, }) expect(inserted[0].code).toBe('fetch("{{SLACK_API_KEY}}")') }) @@ -615,6 +1205,7 @@ describe('copyForkResourceContainers external MCP server copy', () => { knowledgeBases: [], }, workflowIdMap: new Map(), + documentMappingContext: { edgeChildWorkspaceId: 'child-ws', sourceIsParent: true }, }) expect(inserted).toHaveLength(1) @@ -702,6 +1293,7 @@ describe('copyForkResourceContainers skill copy', () => { now: new Date(), selection: skillSelection, workflowIdMap: new Map(), + documentMappingContext: { edgeChildWorkspaceId: 'child-ws', sourceIsParent: true }, }) expect(inserted).toHaveLength(1) @@ -735,6 +1327,7 @@ describe('copyForkResourceContainers skill copy', () => { now: new Date(), selection: skillSelection, workflowIdMap: new Map(), + documentMappingContext: { edgeChildWorkspaceId: 'child-ws', sourceIsParent: true }, }) const childSkill = inserted[0] @@ -807,6 +1400,7 @@ describe('copyForkResourceContainers knowledge-base tag definitions', () => { now: new Date(), selection: kbSelection, workflowIdMap: new Map(), + documentMappingContext: { edgeChildWorkspaceId: 'child-ws', sourceIsParent: true }, }) const childKbId = result.idMap.get('knowledge_base')?.get('kb-1') @@ -837,6 +1431,7 @@ describe('copyForkResourceContainers knowledge-base tag definitions', () => { now: new Date(), selection: kbSelection, workflowIdMap: new Map(), + documentMappingContext: { edgeChildWorkspaceId: 'child-ws', sourceIsParent: true }, }) // Only the KB row itself is inserted - no empty tag-definition insert. @@ -845,6 +1440,9 @@ describe('copyForkResourceContainers knowledge-base tag definitions', () => { }) describe('planForkMappedKbDocumentCopies', () => { + const now = new Date('2026-08-07T00:00:00.000Z') + const copiedId = (sourceId: string) => + `fork_document_${sha256Hex(`document:target-kb:${sourceId}`).slice(0, 40)}` const sourceRow = (id: string, knowledgeBaseId: string) => ({ id, knowledgeBaseId, @@ -858,13 +1456,22 @@ describe('planForkMappedKbDocumentCopies', () => { archivedAt: null, }) - function makeTx(docs: ReturnType[]) { + function makeTx( + docs: ReturnType[], + existingTargets: Array<{ + id: string + knowledgeBaseId: string + storageKey: string | null + archivedAt: Date | null + deletedAt: Date | null + }> = [] + ) { const inserted: Array> = [] - let selectCalled = false + let selectCalls = 0 const tx = { select: () => { - selectCalled = true - return { from: () => ({ where: () => Promise.resolve(docs) }) } + const rows = selectCalls++ === 0 ? docs : existingTargets + return { from: () => ({ where: () => Promise.resolve(rows) }) } }, insert: () => ({ values: (rows: Array>) => { @@ -873,7 +1480,7 @@ describe('planForkMappedKbDocumentCopies', () => { }, }), } - return { tx: tx as unknown as DbOrTx, inserted, wasSelectCalled: () => selectCalled } + return { tx: tx as unknown as DbOrTx, inserted, selectCalls: () => selectCalls } } const mappedKbResolver: ForkReferenceResolver = (kind, id) => @@ -886,6 +1493,7 @@ describe('planForkMappedKbDocumentCopies', () => { resolver: mappedKbResolver, referencedDocumentIds: ['doc-1'], alreadyCopiedSourceDocIds: new Set(), + now, }) const childId = result.docIdMap.get('doc-1') @@ -924,6 +1532,7 @@ describe('planForkMappedKbDocumentCopies', () => { resolver: mappedKbResolver, referencedDocumentIds: ['doc-1'], alreadyCopiedSourceDocIds: new Set(), + now, }) expect(inserted).toHaveLength(0) expect(result.docIdMap.size).toBe(0) @@ -931,27 +1540,174 @@ describe('planForkMappedKbDocumentCopies', () => { }) it('skips a doc already placed under a copied KB this sync (no duplicate query)', async () => { - const { tx, wasSelectCalled } = makeTx([sourceRow('doc-1', 'src-kb')]) + const { tx, selectCalls } = makeTx([sourceRow('doc-1', 'src-kb')]) const result = await planForkMappedKbDocumentCopies({ tx, resolver: mappedKbResolver, referencedDocumentIds: ['doc-1'], alreadyCopiedSourceDocIds: new Set(['doc-1']), + now, }) expect(result.documents).toHaveLength(0) - expect(wasSelectCalled()).toBe(false) + expect(selectCalls()).toBe(0) }) it('skips a doc that already resolves (mapped by a prior sync)', async () => { - const { tx, wasSelectCalled } = makeTx([sourceRow('doc-1', 'src-kb')]) + const { tx, selectCalls } = makeTx([sourceRow('doc-1', 'src-kb')]) const result = await planForkMappedKbDocumentCopies({ tx, resolver: (kind, id) => kind === 'knowledge-document' && id === 'doc-1' ? 'existing-child-doc' : null, referencedDocumentIds: ['doc-1'], alreadyCopiedSourceDocIds: new Set(), + now, + }) + expect(result.documents).toHaveLength(0) + expect(selectCalls()).toBe(0) + }) + + it('adopts an already-active deterministic target without copying its content again', async () => { + const childDocId = copiedId('doc-1') + const { tx, inserted } = makeTx( + [sourceRow('doc-1', 'src-kb')], + [ + { + id: childDocId, + knowledgeBaseId: 'target-kb', + storageKey: `kb/fork-${childDocId}`, + archivedAt: null, + deletedAt: null, + }, + ] + ) + + const result = await planForkMappedKbDocumentCopies({ + tx, + resolver: mappedKbResolver, + referencedDocumentIds: ['doc-1'], + alreadyCopiedSourceDocIds: new Set(), + now, + }) + + expect(inserted).toHaveLength(0) + expect(result.documents).toHaveLength(0) + expect(result.docIdMap.get('doc-1')).toBe(childDocId) + expect(result.mappingEntries).toEqual([ + { + resourceType: 'knowledge_document', + parentResourceId: 'doc-1', + childResourceId: childDocId, + }, + ]) + }) + + it('adopts a legacy active target without a blob after the source gains stored content', async () => { + const childDocId = copiedId('doc-1') + const { tx, inserted } = makeTx( + [sourceRow('doc-1', 'src-kb')], + [ + { + id: childDocId, + knowledgeBaseId: 'target-kb', + storageKey: null, + archivedAt: null, + deletedAt: null, + }, + ] + ) + + const result = await planForkMappedKbDocumentCopies({ + tx, + resolver: mappedKbResolver, + referencedDocumentIds: ['doc-1'], + alreadyCopiedSourceDocIds: new Set(), + now, }) + + expect(inserted).toHaveLength(0) expect(result.documents).toHaveLength(0) - expect(wasSelectCalled()).toBe(false) + expect(result.docIdMap.get('doc-1')).toBe(childDocId) + }) + + it('adopts an archived deterministic placeholder and schedules its bounded content fill', async () => { + const childDocId = copiedId('doc-1') + const { tx, inserted } = makeTx( + [sourceRow('doc-1', 'src-kb')], + [ + { + id: childDocId, + knowledgeBaseId: 'target-kb', + storageKey: null, + archivedAt: new Date('2026-08-06T00:00:00.000Z'), + deletedAt: null, + }, + ] + ) + + const result = await planForkMappedKbDocumentCopies({ + tx, + resolver: mappedKbResolver, + referencedDocumentIds: ['doc-1'], + alreadyCopiedSourceDocIds: new Set(), + now, + }) + + expect(inserted).toHaveLength(0) + expect(result.documents).toEqual([ + expect.objectContaining({ sourceDocId: 'doc-1', childDocId }), + ]) + expect(result.mappingEntries).toHaveLength(1) + }) + + it('rejects a deterministic target identity owned by another knowledge base', async () => { + const childDocId = copiedId('doc-1') + const { tx } = makeTx( + [sourceRow('doc-1', 'src-kb')], + [ + { + id: childDocId, + knowledgeBaseId: 'other-kb', + storageKey: `kb/fork-${childDocId}`, + archivedAt: null, + deletedAt: null, + }, + ] + ) + + await expect( + planForkMappedKbDocumentCopies({ + tx, + resolver: mappedKbResolver, + referencedDocumentIds: ['doc-1'], + alreadyCopiedSourceDocIds: new Set(), + now, + }) + ).rejects.toThrow(`Copied document ${childDocId} has conflicting storage identity`) + }) + + it('rejects an active deterministic target with a different storage key', async () => { + const childDocId = copiedId('doc-1') + const { tx } = makeTx( + [sourceRow('doc-1', 'src-kb')], + [ + { + id: childDocId, + knowledgeBaseId: 'target-kb', + storageKey: 'kb/unrelated', + archivedAt: null, + deletedAt: null, + }, + ] + ) + + await expect( + planForkMappedKbDocumentCopies({ + tx, + resolver: mappedKbResolver, + referencedDocumentIds: ['doc-1'], + alreadyCopiedSourceDocIds: new Set(), + now, + }) + ).rejects.toThrow(`Copied document ${childDocId} has conflicting storage`) }) }) diff --git a/apps/sim/ee/workspace-forking/lib/copy/copy-resources.ts b/apps/sim/ee/workspace-forking/lib/copy/copy-resources.ts index 2582cf8ec85..94f9d9402d9 100644 --- a/apps/sim/ee/workspace-forking/lib/copy/copy-resources.ts +++ b/apps/sim/ee/workspace-forking/lib/copy/copy-resources.ts @@ -14,13 +14,26 @@ import { userTableRowSecretProvenance, userTableRows, workflowMcpServer, + workspaceFiles, } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { sha256Hex } from '@sim/security/hash' import { getErrorMessage } from '@sim/utils/errors' import { generateId } from '@sim/utils/id' import { omit } from '@sim/utils/object' -import { and, asc, eq, gt, inArray, isNotNull, isNull, type SQL, sql } from 'drizzle-orm' +import { + and, + asc, + eq, + exists, + gt, + inArray, + isNotNull, + isNull, + or, + type SQL, + sql, +} from 'drizzle-orm' import { decrementStorageUsageForBillingContextInTx, incrementStorageUsageForBillingContextInTx, @@ -29,7 +42,10 @@ import { } from '@/lib/billing/storage' import { mapWithConcurrency } from '@/lib/core/utils/concurrency' import type { DbOrTx } from '@/lib/db/types' -import type { DurableSecretProvenance } from '@/lib/execution/durable-secret-provenance' +import { + type DurableSecretProvenance, + hashDurableSecretProvenanceValue, +} from '@/lib/execution/durable-secret-provenance' import { createKnowledgeDocumentSourceValue, type KnowledgeDocumentSourceValue, @@ -50,11 +66,17 @@ import { headObject, uploadFile, } from '@/lib/uploads/core/storage-service' +import { + type KnowledgeBaseFileOwnership, + recordKnowledgeBaseFileOwnership, +} from '@/lib/uploads/server/metadata' import { MAX_FILE_SIZE } from '@/lib/uploads/utils/validation' import { isRecord } from '@/lib/workflows/persistence/remap-internal-ids' -import type { - ForkMappingUpsert, - ForkResourceType, +import { + deleteCopiedResourceMappingsByTargets, + type ForkMappingUpsert, + type ForkResourceType, + persistCopiedResourceMappings, } from '@/ee/workspace-forking/lib/mapping/mapping-store' import type { ForkBlockIdResolver } from '@/ee/workspace-forking/lib/remap/block-identity' import { @@ -97,6 +119,7 @@ function isForkProvenancePageWithinBudget(sidecars: readonly { entries: unknown * processes one page at a time, so peak concurrency stays at this cap regardless of KB size. */ const KB_DOCUMENT_COPY_CONCURRENCY = 5 +export const FORK_DOCUMENT_ID_PATTERN = '^fork_document_[0-9a-f]{40}$' function deriveCopyIdentity( kind: 'document' | 'embedding', @@ -107,9 +130,50 @@ function deriveCopyIdentity( return `fork_${kind}_${digest}` } -/** Stable object key so a replay overwrites or reuses the same copied KB blob. */ -function deriveKbDocumentStorageKey(childDocumentId: string): string { - return `kb/fork-${childDocumentId}` +/** + * Stable legacy key prefix for a copied KB document. New blobs append their content digest so + * retries of different source snapshots cannot overwrite one another; the unsuffixed form remains + * valid for copies finalized by an older worker during a rolling deployment. + */ +function deriveKbDocumentStorageKey(childDocumentId: string, contentHash?: string): string { + const prefix = `kb/fork-${childDocumentId}` + return contentHash ? `${prefix}-${contentHash}` : prefix +} + +function isKbDocumentStorageKey(key: string, childDocumentId: string): boolean { + const prefix = deriveKbDocumentStorageKey(childDocumentId) + if (key === prefix) return true + if (!key.startsWith(`${prefix}-`)) return false + return /^[0-9a-f]{64}$/.test(key.slice(prefix.length + 1)) +} + +interface TargetDocumentExpectation { + childDocumentId: string + childKnowledgeBaseId: string +} + +interface TargetDocumentState { + id: string + knowledgeBaseId: string + storageKey: string | null + archivedAt: Date | null + deletedAt: Date | null +} + +function validateTargetDocumentState( + row: TargetDocumentState, + expected: TargetDocumentExpectation +): 'active' | 'archived' { + if (row.id !== expected.childDocumentId) { + throw new Error(`Copied document ${row.id} has an unexpected identity`) + } + if (row.knowledgeBaseId !== expected.childKnowledgeBaseId || row.deletedAt) { + throw new Error(`Copied document ${row.id} has conflicting storage identity`) + } + if (row.storageKey !== null && !isKbDocumentStorageKey(row.storageKey, row.id)) { + throw new Error(`Copied document ${row.id} has conflicting storage`) + } + return row.archivedAt ? 'archived' : 'active' } /** @@ -157,6 +221,13 @@ export interface CopyResourcesParams { * omits it, defaulting to the deterministic derive (a fresh child has no pairs). */ resolveBlockId?: ForkBlockIdResolver + /** Canonical fork-edge orientation for document identities completed by the background copy. */ + documentMappingContext: ForkDocumentMappingContext +} + +export interface ForkDocumentMappingContext { + edgeChildWorkspaceId: string + sourceIsParent: boolean } export interface ForkContentPlanEntry { @@ -198,7 +269,10 @@ export interface ForkContentDocumentEntry { sourceDocId: string childDocId: string childKnowledgeBaseId: string - /** Source blob fields captured at placeholder time, for the post-commit blob re-key. */ + /** + * Source blob fields retained in the serialized payload for rolling-deploy and queued-job + * compatibility. Current workers re-read the live source row before copying it. + */ storageKey: string | null fileUrl: string fileSize: number @@ -217,6 +291,11 @@ export interface ForkContentPlan { skills: ForkContentSkillEntry[] /** Documents copied into an already-existing target KB (sync-only; empty at fork create). */ documents: ForkContentDocumentEntry[] + /** + * Optional only so workers deployed during a rollout can still consume already-queued payloads. + * Every newly planned fork/sync includes it. + */ + documentMappingContext?: ForkDocumentMappingContext } /** @@ -301,6 +380,7 @@ export async function copyForkResourceContainers( knowledgeBases: [], skills: [], documents: [], + documentMappingContext: params.documentMappingContext, } const names: ForkCopiedResourceNames = { tables: [], @@ -739,12 +819,13 @@ export async function planForkMappedKbDocumentCopies(params: { resolver: ForkReferenceResolver referencedDocumentIds: string[] alreadyCopiedSourceDocIds: Set + now: Date }): Promise<{ documents: ForkContentDocumentEntry[] docIdMap: Map mappingEntries: ForkMappingUpsert[] }> { - const { tx, resolver, referencedDocumentIds, alreadyCopiedSourceDocIds } = params + const { tx, resolver, referencedDocumentIds, alreadyCopiedSourceDocIds, now } = params const documents: ForkContentDocumentEntry[] = [] const docIdMap = new Map() const mappingEntries: ForkMappingUpsert[] = [] @@ -768,41 +849,74 @@ export async function planForkMappedKbDocumentCopies(params: { ) ) + const planned = docs.flatMap((doc) => { + const targetKbId = resolver('knowledge-base', doc.knowledgeBaseId) + if (targetKbId == null) return [] + return [{ doc, targetKbId, childDocId: deriveCopyIdentity('document', targetKbId, doc.id) }] + }) + const existingTargets = + planned.length === 0 + ? [] + : await tx + .select({ + id: document.id, + knowledgeBaseId: document.knowledgeBaseId, + storageKey: document.storageKey, + archivedAt: document.archivedAt, + deletedAt: document.deletedAt, + }) + .from(document) + .where( + inArray( + document.id, + planned.map(({ childDocId }) => childDocId) + ) + ) + const existingTargetById = new Map(existingTargets.map((target) => [target.id, target])) const inserts: (typeof document.$inferInsert)[] = [] - for (const doc of docs) { + for (const { doc, targetKbId, childDocId } of planned) { // The parent KB must already exist in the target. The resolver returns a target KB id only // for a mapped, still-existing KB (validTargetIdsByKind), so this is FK-safe; a doc whose KB // isn't mapped resolves null here and is left for its reference to be cleared. - const targetKbId = resolver('knowledge-base', doc.knowledgeBaseId) - if (targetKbId == null) continue - const childDocId = deriveCopyIdentity('document', targetKbId, doc.id) - inserts.push({ - ...doc, - id: childDocId, - knowledgeBaseId: targetKbId, - connectorId: null, - storageKey: null, - fileUrl: '', - fileSize: 0, - deletedAt: null, - archivedAt: new Date(), - }) + const existingTarget = existingTargetById.get(childDocId) + const expectedTarget = { + childDocumentId: childDocId, + childKnowledgeBaseId: targetKbId, + } + const existingTargetState = existingTarget + ? validateTargetDocumentState(existingTarget, expectedTarget) + : null + if (!existingTarget) { + inserts.push({ + ...doc, + id: childDocId, + knowledgeBaseId: targetKbId, + connectorId: null, + storageKey: null, + fileUrl: '', + fileSize: 0, + deletedAt: null, + archivedAt: now, + }) + } docIdMap.set(doc.id, childDocId) mappingEntries.push({ resourceType: 'knowledge_document', parentResourceId: doc.id, childResourceId: childDocId, }) - documents.push({ - sourceDocId: doc.id, - childDocId, - childKnowledgeBaseId: targetKbId, - storageKey: doc.storageKey, - fileUrl: doc.fileUrl, - fileSize: doc.fileSize, - filename: doc.filename, - mimeType: doc.mimeType, - }) + if (!existingTarget || existingTargetState === 'archived') { + documents.push({ + sourceDocId: doc.id, + childDocId, + childKnowledgeBaseId: targetKbId, + storageKey: doc.storageKey, + fileUrl: doc.fileUrl, + fileSize: doc.fileSize, + filename: doc.filename, + mimeType: doc.mimeType, + }) + } } if (inserts.length > 0) await tx.insert(document).values(inserts) return { documents, docIdMap, mappingEntries } @@ -1002,15 +1116,18 @@ export async function copyForkResourceContent(params: { kb.documentIdMap[source.id] ?? deriveCopyIdentity('document', kb.childId, source.id), })) const activeTargetDocumentIds = await getActiveTargetDocumentIds( - documentCopies.map(({ childDocumentId }) => childDocumentId) + documentCopies.map(({ childDocumentId }) => ({ + childDocumentId, + childKnowledgeBaseId: kb.childId, + })) ) const documentsToCopy = documentCopies.filter( ({ childDocumentId }) => !activeTargetDocumentIds.has(childDocumentId) ) // Copy the page's documents with bounded concurrency. The mapper never rejects - // (it captures its error), so all in-flight work settles before this resolves - no - // orphaned writes survive a failure - and a captured error is rethrown after to keep - // the KB ALL-OR-NOTHING (any failed doc fails the whole KB -> cleanup below). + // (it captures its error), so all in-flight work settles before this resolves and a + // captured error is rethrown after to keep the KB ALL-OR-NOTHING (any failed doc fails + // the whole KB -> cleanup below). if (documentsToCopy.length > 0) { const resolvedBillingContext = await getBillingContext() const docErrors = await mapWithConcurrency( @@ -1035,6 +1152,22 @@ export async function copyForkResourceContent(params: { const docError = docErrors.find((error) => error != null) if (docError) throw docError } + const mappingContext = contentPlan.documentMappingContext + if (mappingContext) { + await db.transaction(async (tx) => { + await persistCopiedResourceMappings({ + executor: tx, + edgeChildWorkspaceId: mappingContext.edgeChildWorkspaceId, + userId, + sourceIsParent: mappingContext.sourceIsParent, + entries: documentCopies.map(({ source, childDocumentId }) => ({ + resourceType: 'knowledge_document', + parentResourceId: source.id, + childResourceId: childDocumentId, + })), + }) + }) + } afterDocId = docs[docs.length - 1].id if (docs.length < CONTENT_PAGE) break } @@ -1052,6 +1185,19 @@ export async function copyForkResourceContent(params: { { cause: rollbackError } ) } + if (contentPlan.documentMappingContext) { + try { + await deleteFailedKnowledgeBaseDocumentMappings( + kb.childId, + contentPlan.documentMappingContext + ) + } catch (mappingCleanupError) { + logger.error(`[${requestId}] Failed to clean mappings for a failed copied KB`, { + childKnowledgeBaseId: kb.childId, + error: getErrorMessage(mappingCleanupError), + }) + } + } failedResources += 1 failures.push({ kind: 'knowledge-base', @@ -1072,50 +1218,54 @@ export async function copyForkResourceContent(params: { // own documents are never touched. for (const docEntry of contentPlan.documents) { try { - const active = await isActiveTargetDocument(docEntry.childDocId) + const active = await isActiveTargetDocument({ + childDocumentId: docEntry.childDocId, + childKnowledgeBaseId: docEntry.childKnowledgeBaseId, + }) if (active) { copiedResources += 1 continue } + const [source] = await db + .select() + .from(document) + .where( + and( + eq(document.id, docEntry.sourceDocId), + isNull(document.deletedAt), + isNull(document.archivedAt) + ) + ) + .limit(1) + if (!source) { + throw new Error(`Source document ${docEntry.sourceDocId} is missing`) + } const resolvedBillingContext = await getBillingContext() - const blob = await copyKbDocumentBlob( - { - storageKey: docEntry.storageKey, - filename: docEntry.filename, - mimeType: docEntry.mimeType, - }, + await copyKbDocument({ + source, + childDocumentId: docEntry.childDocId, + childKnowledgeBaseId: docEntry.childKnowledgeBaseId, childWorkspaceId, userId, - docEntry.childDocId - ) - try { - await copyDocumentEmbeddings( - docEntry.sourceDocId, - docEntry.childDocId, - docEntry.childKnowledgeBaseId - ) - await finalizeKbDocument({ - childDocumentId: docEntry.childDocId, - childKnowledgeBaseId: docEntry.childKnowledgeBaseId, - billingContext: resolvedBillingContext, - bytes: blob ? docEntry.fileSize : 0, - values: { - knowledgeBaseId: docEntry.childKnowledgeBaseId, - connectorId: null, - storageKey: blob?.storageKey ?? null, - fileUrl: blob?.fileUrl ?? docEntry.fileUrl, - fileSize: docEntry.fileSize, - archivedAt: null, - deletedAt: null, - uploadedBy: userId, - }, - }) - } catch (error) { - if (blob) await cleanupCopiedKbBlob(blob.storageKey) - throw error - } + billingContext: resolvedBillingContext, + }) copiedResources += 1 } catch (error) { + if (contentPlan.documentMappingContext) { + try { + await deleteCopiedResourceMappingsByTargets({ + executor: db, + edgeChildWorkspaceId: contentPlan.documentMappingContext.edgeChildWorkspaceId, + sourceIsParent: contentPlan.documentMappingContext.sourceIsParent, + targets: [{ resourceType: 'knowledge_document', resourceId: docEntry.childDocId }], + }) + } catch (mappingCleanupError) { + logger.error(`[${requestId}] Failed to clean mapping for a failed copied document`, { + childDocumentId: docEntry.childDocId, + error: getErrorMessage(mappingCleanupError), + }) + } + } failedResources += 1 failures.push({ kind: 'knowledge-document', childId: docEntry.childDocId }) logger.warn(`[${requestId}] Failed to copy document into mapped KB during sync`, { @@ -1172,24 +1322,38 @@ export async function copyForkResourceContent(params: { return { copied: copiedResources, failed: failedResources, failures } } -async function getActiveTargetDocumentIds(childDocumentIds: string[]): Promise> { - if (childDocumentIds.length === 0) return new Set() - const active = await db - .select({ id: document.id }) +async function getActiveTargetDocumentIds( + expectations: TargetDocumentExpectation[] +): Promise> { + if (expectations.length === 0) return new Set() + const expectedById = new Map(expectations.map((expected) => [expected.childDocumentId, expected])) + const existing = await db + .select({ + id: document.id, + knowledgeBaseId: document.knowledgeBaseId, + storageKey: document.storageKey, + archivedAt: document.archivedAt, + deletedAt: document.deletedAt, + }) .from(document) .where( - and( - inArray(document.id, childDocumentIds), - isNull(document.deletedAt), - isNull(document.archivedAt) + inArray( + document.id, + expectations.map(({ childDocumentId }) => childDocumentId) ) ) - .limit(childDocumentIds.length) - return new Set(active.map((row) => row.id)) + .limit(expectations.length) + const activeIds = new Set() + for (const row of existing) { + const expected = expectedById.get(row.id) + if (!expected) throw new Error(`Copied document ${row.id} was not requested`) + if (validateTargetDocumentState(row, expected) === 'active') activeIds.add(row.id) + } + return activeIds } -async function isActiveTargetDocument(childDocumentId: string): Promise { - return (await getActiveTargetDocumentIds([childDocumentId])).has(childDocumentId) +async function isActiveTargetDocument(expectation: TargetDocumentExpectation): Promise { + return (await getActiveTargetDocumentIds([expectation])).has(expectation.childDocumentId) } /** @@ -1231,19 +1395,21 @@ async function finalizeKbDocument(params: { billingContext: StorageBillingContext bytes: number values: Partial + fileOwnership?: KnowledgeBaseFileOwnership secretProvenance?: DurableSecretProvenance provenanceSource?: KnowledgeDocumentSourceValue -}): Promise { +}): Promise { const { childDocumentId, childKnowledgeBaseId, billingContext, bytes, values, + fileOwnership, secretProvenance, provenanceSource, } = params - await db.transaction(async (tx) => { + return db.transaction(async (tx) => { const [lockedKnowledgeBase] = await tx .select({ workspaceId: knowledgeBase.workspaceId }) .from(knowledgeBase) @@ -1257,6 +1423,11 @@ async function finalizeKbDocument(params: { `Copied document knowledge base ${childKnowledgeBaseId} moved from workspace ${billingContext.workspaceId}; refusing stale storage charge` ) } + if (fileOwnership && fileOwnership.workspaceId !== lockedKnowledgeBase.workspaceId) { + throw new Error( + `Copied document ${childDocumentId} ownership does not match its knowledge base workspace` + ) + } const [activated] = await tx .update(document) @@ -1264,6 +1435,7 @@ async function finalizeKbDocument(params: { .where( and( eq(document.id, childDocumentId), + eq(document.knowledgeBaseId, childKnowledgeBaseId), isNull(document.deletedAt), isNotNull(document.archivedAt) ) @@ -1272,7 +1444,15 @@ async function finalizeKbDocument(params: { if (!activated) { const [active] = await tx - .select({ id: document.id }) + .select({ + id: document.id, + knowledgeBaseId: document.knowledgeBaseId, + storageKey: document.storageKey, + filename: document.filename, + mimeType: document.mimeType, + fileSize: document.fileSize, + uploadedBy: document.uploadedBy, + }) .from(document) .where( and( @@ -1282,8 +1462,32 @@ async function finalizeKbDocument(params: { ) ) .limit(1) - if (active) return - throw new Error(`Copied document placeholder ${childDocumentId} is missing`) + if (!active) throw new Error(`Copied document placeholder ${childDocumentId} is missing`) + if (active.knowledgeBaseId !== childKnowledgeBaseId) { + throw new Error(`Copied document ${childDocumentId} has conflicting active storage`) + } + if (fileOwnership) { + const activeStorageKey = active.storageKey + if (!activeStorageKey || !isKbDocumentStorageKey(activeStorageKey, childDocumentId)) { + throw new Error(`Copied document ${childDocumentId} has conflicting active storage`) + } + await recordKnowledgeBaseFileOwnership( + { + key: activeStorageKey, + userId: active.uploadedBy ?? fileOwnership.userId, + workspaceId: fileOwnership.workspaceId, + originalName: active.filename, + contentType: active.mimeType, + size: active.fileSize, + }, + tx + ) + } + return active.storageKey + } + + if (fileOwnership) { + await recordKnowledgeBaseFileOwnership(fileOwnership, tx) } if (secretProvenance && provenanceSource) { @@ -1296,6 +1500,7 @@ async function finalizeKbDocument(params: { } await incrementStorageUsageForBillingContextInTx(tx, billingContext, bytes) + return fileOwnership?.key ?? null }) } @@ -1320,51 +1525,77 @@ async function copyKbDocument(params: { userId, billingContext, } = params - await ensureKbDocumentPlaceholder(source, childDocumentId, childKnowledgeBaseId, userId) const sourceSecretContext = await loadKnowledgeDocumentDurableSecretProvenance(source.id) + const sourceSnapshotHash = hashDurableSecretProvenanceValue( + createKnowledgeDocumentSourceValue(source) + ) + const provenanceSnapshotHash = hashDurableSecretProvenanceValue(sourceSecretContext.source) + if (!sourceSnapshotHash || sourceSnapshotHash !== provenanceSnapshotHash) { + throw new Error(`Knowledge document ${source.id} changed while preparing its fork copy`) + } + await ensureKbDocumentPlaceholder(source, childDocumentId, childKnowledgeBaseId, userId) const blob = await copyKbDocumentBlob(source, childWorkspaceId, userId, childDocumentId) - try { - await copyDocumentEmbeddings(source.id, childDocumentId, childKnowledgeBaseId) - const copiedValues = { - ...omit(source, ['id', 'knowledgeBaseId']), - knowledgeBaseId: childKnowledgeBaseId, - connectorId: null, - storageKey: blob?.storageKey ?? null, - fileUrl: blob?.fileUrl ?? source.fileUrl, - archivedAt: null, - deletedAt: null, - uploadedBy: userId, - secretProvenanceVersion: sourceSecretContext.tracked ? 1 : null, + await copyDocumentEmbeddings(source.id, childDocumentId, childKnowledgeBaseId) + const copiedValues = { + ...omit(source, ['id', 'knowledgeBaseId']), + knowledgeBaseId: childKnowledgeBaseId, + connectorId: null, + storageKey: blob?.storageKey ?? null, + fileUrl: blob?.fileUrl ?? source.fileUrl, + archivedAt: null, + deletedAt: null, + uploadedBy: userId, + secretProvenanceVersion: sourceSecretContext.tracked ? 1 : null, + } + const copiedSource = createKnowledgeDocumentSourceValue(copiedValues) + const finalizedStorageKey = await finalizeKbDocument({ + childDocumentId, + childKnowledgeBaseId, + billingContext, + bytes: blob ? source.fileSize : 0, + values: copiedValues, + ...(blob + ? { + fileOwnership: { + key: blob.storageKey, + userId, + workspaceId: childWorkspaceId, + originalName: source.filename, + contentType: source.mimeType, + size: source.fileSize, + }, + } + : {}), + ...(sourceSecretContext.tracked + ? { + secretProvenance: rebindKnowledgeDocumentSecretProvenance( + sourceSecretContext.provenance, + sourceSecretContext.source, + copiedSource + ), + provenanceSource: copiedSource, + } + : {}), + }) + if (blob && finalizedStorageKey !== blob.storageKey) { + try { + await deleteFile({ key: blob.storageKey, context: 'knowledge-base' }) + } catch (error) { + logger.warn(`Failed to remove an unreferenced losing fork document blob`, { + childDocumentId, + storageKey: blob.storageKey, + error: getErrorMessage(error), + }) } - const copiedSource = createKnowledgeDocumentSourceValue(copiedValues) - await finalizeKbDocument({ - childDocumentId, - childKnowledgeBaseId, - billingContext, - bytes: blob ? source.fileSize : 0, - values: copiedValues, - ...(sourceSecretContext.tracked - ? { - secretProvenance: rebindKnowledgeDocumentSecretProvenance( - sourceSecretContext.provenance, - sourceSecretContext.source, - copiedSource - ), - provenanceSource: copiedSource, - } - : {}), - }) - } catch (error) { - if (blob) await cleanupCopiedKbBlob(blob.storageKey) - throw error } } /** * Reverse any documents already activated for a KB when a later document fails, * preserving the existing all-or-nothing KB failure semantics without a long - * parent transaction. The aggregate keeps memory bounded regardless of KB size. + * parent transaction. Accounting reversal and archival are limited to reserved + * deterministic fork identities rather than every document in the target KB. */ async function rollbackCopiedKbDocuments( childKnowledgeBaseId: string, @@ -1389,6 +1620,7 @@ async function rollbackCopiedKbDocuments( .where( and( eq(document.knowledgeBaseId, childKnowledgeBaseId), + sql`${document.id} ~ ${FORK_DOCUMENT_ID_PATTERN}`, isNull(document.deletedAt), isNull(document.archivedAt), isNotNull(document.storageKey) @@ -1396,12 +1628,41 @@ async function rollbackCopiedKbDocuments( ) const bytes = Number(usage?.total ?? 0) await decrementStorageUsageForBillingContextInTx(tx, billingContext, bytes) + await tx + .update(workspaceFiles) + .set({ deletedAt: new Date() }) + .where( + and( + eq(workspaceFiles.workspaceId, childWorkspaceId), + eq(workspaceFiles.context, 'knowledge-base'), + isNull(workspaceFiles.deletedAt), + exists( + tx + .select({ id: document.id }) + .from(document) + .where( + and( + eq(document.knowledgeBaseId, childKnowledgeBaseId), + sql`${document.id} ~ ${FORK_DOCUMENT_ID_PATTERN}`, + isNull(document.deletedAt), + isNull(document.archivedAt), + eq(document.storageKey, workspaceFiles.key), + or( + eq(workspaceFiles.key, sql`'kb/fork-' || ${document.id}`), + sql`${workspaceFiles.key} ~ ('^kb/fork-' || ${document.id} || '-[0-9a-f]{64}$')` + ) + ) + ) + ) + ) + ) await tx .update(document) .set({ archivedAt: new Date() }) .where( and( eq(document.knowledgeBaseId, childKnowledgeBaseId), + sql`${document.id} ~ ${FORK_DOCUMENT_ID_PATTERN}`, isNull(document.deletedAt), isNull(document.archivedAt) ) @@ -1409,6 +1670,50 @@ async function rollbackCopiedKbDocuments( }) } +/** + * Remove the identity rows completed for earlier pages of a KB whose later page failed. Target + * document ids are keyset-paged from the failed KB, so cleanup never retains the whole KB in app + * memory. The target side is selected from the same serialized edge orientation used to write the + * mappings. + */ +async function deleteFailedKnowledgeBaseDocumentMappings( + childKnowledgeBaseId: string, + mappingContext: ForkDocumentMappingContext +): Promise { + let afterId: string | null = null + for (;;) { + const rows = await db + .select({ id: document.id }) + .from(document) + .where( + afterId == null + ? and( + eq(document.knowledgeBaseId, childKnowledgeBaseId), + sql`${document.id} ~ ${FORK_DOCUMENT_ID_PATTERN}` + ) + : and( + eq(document.knowledgeBaseId, childKnowledgeBaseId), + sql`${document.id} ~ ${FORK_DOCUMENT_ID_PATTERN}`, + gt(document.id, afterId) + ) + ) + .orderBy(asc(document.id)) + .limit(CONTENT_PAGE) + if (rows.length === 0) break + await deleteCopiedResourceMappingsByTargets({ + executor: db, + edgeChildWorkspaceId: mappingContext.edgeChildWorkspaceId, + sourceIsParent: mappingContext.sourceIsParent, + targets: rows.map(({ id }) => ({ + resourceType: 'knowledge_document' as const, + resourceId: id, + })), + }) + if (rows.length < CONTENT_PAGE) break + afterId = rows[rows.length - 1].id + } +} + async function copyDocumentEmbeddings( sourceDocumentId: string, childDocumentId: string, @@ -1488,48 +1793,50 @@ async function copyDocumentEmbeddings( * `verifyKBFileAccess` grants a child-workspace member - without it the copied object is * download-denied (no binding = deny). Returns the new `storageKey` + serve `fileUrl`, or null * when there is no internal blob to copy (external/`data:` docs have a null `storageKey`) or the - * copy fails. A stored source blob is required to copy successfully; callers - * keep the target placeholder archived and report the existing resource failure. + * copy fails. A stored source blob is required to copy successfully; callers keep the target + * placeholder archived and report the existing resource failure. The content digest in the key + * makes reuse safe for identical retries and prevents a later source snapshot from adopting or + * overwriting bytes left by an earlier failed attempt. Ownership is recorded before storage I/O, + * matching the presigned-upload lifecycle: successful finalization reuses the immutable binding, + * while the existing orphan-binding sweep eventually reclaims an abandoned object or reservation. */ async function copyKbDocumentBlob( - doc: { storageKey: string | null; filename: string; mimeType: string }, + doc: { storageKey: string | null; filename: string; mimeType: string; fileSize: number }, childWorkspaceId: string, userId: string, childDocumentId: string ): Promise<{ storageKey: string; fileUrl: string } | null> { if (!doc.storageKey) return null - const targetKey = deriveKbDocumentStorageKey(childDocumentId) - try { - const existing = await headObject(targetKey, 'knowledge-base') - if (!existing) { - const buffer = await downloadFile({ - key: doc.storageKey, - context: 'knowledge-base', - maxBytes: MAX_FILE_SIZE, - }) - await uploadFile({ - file: buffer, - fileName: doc.filename, - contentType: doc.mimeType, - context: 'knowledge-base', - customKey: targetKey, - preserveKey: true, - persistMetadata: false, - metadata: { - userId, - workspaceId: childWorkspaceId, - originalName: doc.filename, - }, - }) - } - } catch (error) { - await cleanupCopiedKbBlob(targetKey) - throw error + const buffer = await downloadFile({ + key: doc.storageKey, + context: 'knowledge-base', + maxBytes: MAX_FILE_SIZE, + }) + const targetKey = deriveKbDocumentStorageKey(childDocumentId, sha256Hex(buffer)) + await recordKnowledgeBaseFileOwnership({ + key: targetKey, + userId, + workspaceId: childWorkspaceId, + originalName: doc.filename, + contentType: doc.mimeType, + size: doc.fileSize, + }) + const existing = await headObject(targetKey, 'knowledge-base') + if (!existing) { + await uploadFile({ + file: buffer, + fileName: doc.filename, + contentType: doc.mimeType, + context: 'knowledge-base', + customKey: targetKey, + preserveKey: true, + persistMetadata: false, + metadata: { + userId, + workspaceId: childWorkspaceId, + originalName: doc.filename, + }, + }) } return { storageKey: targetKey, fileUrl: `/api/files/serve/${encodeURIComponent(targetKey)}` } } - -/** Best-effort orphan cleanup after DB finalization or embedding copy fails. */ -async function cleanupCopiedKbBlob(storageKey: string): Promise { - await deleteFile({ key: storageKey, context: 'knowledge-base' }).catch(() => {}) -} diff --git a/apps/sim/ee/workspace-forking/lib/create-fork.test.ts b/apps/sim/ee/workspace-forking/lib/create-fork.test.ts index 9e0c268fffd..6c165193d02 100644 --- a/apps/sim/ee/workspace-forking/lib/create-fork.test.ts +++ b/apps/sim/ee/workspace-forking/lib/create-fork.test.ts @@ -200,6 +200,14 @@ describe('createFork storage headroom gate', () => { bytes: 500, }) expect(dbChainMockFns.transaction).toHaveBeenCalledTimes(1) + expect(mockCopyForkResourceContainers).toHaveBeenCalledWith( + expect.objectContaining({ + documentMappingContext: { + edgeChildWorkspaceId: result.workspace.id, + sourceIsParent: true, + }, + }) + ) }) it('seeds identity mappings for copied FILES by storage key (a later sync must not re-offer them)', async () => { diff --git a/apps/sim/ee/workspace-forking/lib/create-fork.ts b/apps/sim/ee/workspace-forking/lib/create-fork.ts index 59c20ddb21f..1653dcb0448 100644 --- a/apps/sim/ee/workspace-forking/lib/create-fork.ts +++ b/apps/sim/ee/workspace-forking/lib/create-fork.ts @@ -257,6 +257,10 @@ export async function createFork(params: CreateForkParams): Promise { + it('keeps fork/pull source-parent mappings in canonical orientation', () => { + expect(orientCopiedResourceMappings(true, [copiedEntry])).toEqual({ + entries: [copiedEntry], + deleteKeys: [], + }) + }) + + it('swaps push source-child mappings and removes the prior row keyed by that child', () => { + expect(orientCopiedResourceMappings(false, [copiedEntry])).toEqual({ + entries: [ + { + resourceType: 'knowledge_document', + parentResourceId: 'runtime-target-doc', + childResourceId: 'runtime-source-doc', + }, + ], + deleteKeys: [{ resourceType: 'knowledge_document', childResourceId: 'runtime-source-doc' }], + }) + }) + + it('does not produce a push mapping for an unmapped null target', () => { + expect( + orientCopiedResourceMappings(false, [{ ...copiedEntry, childResourceId: null }]) + ).toEqual({ entries: [], deleteKeys: [] }) + }) +}) + describe('buildForkResolver', () => { it('resolves source->target for a pull (source is parent)', () => { const resolve = buildForkResolver([credentialRow], { sourceIsParent: true }) diff --git a/apps/sim/ee/workspace-forking/lib/mapping/mapping-store.ts b/apps/sim/ee/workspace-forking/lib/mapping/mapping-store.ts index f9d1e938275..61151f76961 100644 --- a/apps/sim/ee/workspace-forking/lib/mapping/mapping-store.ts +++ b/apps/sim/ee/workspace-forking/lib/mapping/mapping-store.ts @@ -29,6 +29,21 @@ export interface ForkMappingUpsert { childResourceId: string | null } +export interface PersistCopiedResourceMappingsParams { + executor: DbOrTx + edgeChildWorkspaceId: string + userId: string + /** Whether the runtime copy source is the canonical parent side of the fork edge. */ + sourceIsParent: boolean + /** Runtime source -> runtime target identities produced by the copy operation. */ + entries: ForkMappingUpsert[] +} + +export interface OrientedCopiedResourceMappings { + entries: ForkMappingUpsert[] + deleteKeys: Array<{ resourceType: ForkResourceType; childResourceId: string }> +} + const RESOURCE_TYPE_TO_FORK_KIND: Record = { workflow: null, oauth_credential: 'credential', @@ -216,6 +231,56 @@ export async function upsertEdgeMappings( } } +/** + * Orient runtime source -> target copy identities into the edge's canonical parent -> child + * storage shape. Pull/fork copies already have that orientation. Push copies run child -> parent, + * so their pairs are swapped and the old row keyed by the source-child identity is removed before + * the replacement is upserted. + */ +export function orientCopiedResourceMappings( + sourceIsParent: boolean, + entries: ForkMappingUpsert[] +): OrientedCopiedResourceMappings { + if (sourceIsParent) return { entries, deleteKeys: [] } + + const oriented: ForkMappingUpsert[] = [] + const deleteKeys: OrientedCopiedResourceMappings['deleteKeys'] = [] + for (const entry of entries) { + if (entry.childResourceId == null) continue + oriented.push({ + resourceType: entry.resourceType, + parentResourceId: entry.childResourceId, + childResourceId: entry.parentResourceId, + }) + deleteKeys.push({ + resourceType: entry.resourceType, + childResourceId: entry.parentResourceId, + }) + } + return { entries: oriented, deleteKeys } +} + +/** + * Persist identities created by a copy operation through one shared orientation boundary. Used by + * both the promote transaction and the post-commit full-KB document copier so those paths cannot + * disagree about parent/child direction. + */ +export async function persistCopiedResourceMappings({ + executor, + edgeChildWorkspaceId, + userId, + sourceIsParent, + entries, +}: PersistCopiedResourceMappingsParams): Promise { + if (entries.length === 0) return + const oriented = orientCopiedResourceMappings(sourceIsParent, entries) + if (oriented.entries.length === 0) return + if (oriented.deleteKeys.length > 0) { + await deleteEdgeMappingsByChildResources(executor, edgeChildWorkspaceId, oriented.deleteKeys) + } + await upsertEdgeMappings(executor, edgeChildWorkspaceId, userId, oriented.entries) +} + /** * Remove mapping rows matched by their child-side (source) resource id, grouped by * resource type into a single OR-of-INs - one query for the whole push save (the @@ -226,19 +291,53 @@ export async function deleteEdgeMappingsByChildResources( tx: DbOrTx, childWorkspaceId: string, pairs: Array<{ resourceType: ForkResourceType; childResourceId: string }> +): Promise { + await deleteEdgeMappingsByResourceIds( + tx, + childWorkspaceId, + 'child', + pairs.map(({ resourceType, childResourceId }) => ({ + resourceType, + resourceId: childResourceId, + })) + ) +} + +/** Remove copy mappings whose runtime target resources are being discarded after a failed fill. */ +export async function deleteCopiedResourceMappingsByTargets(params: { + executor: DbOrTx + edgeChildWorkspaceId: string + sourceIsParent: boolean + targets: Array<{ resourceType: ForkResourceType; resourceId: string }> +}): Promise { + const { executor, edgeChildWorkspaceId, sourceIsParent, targets } = params + await deleteEdgeMappingsByResourceIds( + executor, + edgeChildWorkspaceId, + sourceIsParent ? 'child' : 'parent', + targets + ) +} + +async function deleteEdgeMappingsByResourceIds( + tx: DbOrTx, + childWorkspaceId: string, + side: 'parent' | 'child', + pairs: Array<{ resourceType: ForkResourceType; resourceId: string }> ): Promise { if (pairs.length === 0) return const idsByType = new Map() - for (const { resourceType, childResourceId } of pairs) { + for (const { resourceType, resourceId } of pairs) { const list = idsByType.get(resourceType) - if (list) list.push(childResourceId) - else idsByType.set(resourceType, [childResourceId]) + if (list) list.push(resourceId) + else idsByType.set(resourceType, [resourceId]) } + const resourceColumn = + side === 'parent' + ? workspaceForkResourceMap.parentResourceId + : workspaceForkResourceMap.childResourceId const conditions = Array.from(idsByType, ([resourceType, ids]) => - and( - eq(workspaceForkResourceMap.resourceType, resourceType), - inArray(workspaceForkResourceMap.childResourceId, ids) - ) + and(eq(workspaceForkResourceMap.resourceType, resourceType), inArray(resourceColumn, ids)) ) await tx .delete(workspaceForkResourceMap) diff --git a/apps/sim/ee/workspace-forking/lib/promote/copy-unmapped.test.ts b/apps/sim/ee/workspace-forking/lib/promote/copy-unmapped.test.ts index 8f792f3df95..250c5fc85d6 100644 --- a/apps/sim/ee/workspace-forking/lib/promote/copy-unmapped.test.ts +++ b/apps/sim/ee/workspace-forking/lib/promote/copy-unmapped.test.ts @@ -9,22 +9,19 @@ import { import type { DbOrTx } from '@/lib/db/types' const { - mockUpsertEdgeMappings, - mockDeleteEdgeMappingsByChildResources, + mockPersistCopiedResourceMappings, mockCopyForkResourceContainers, mockPlanForkMappedKbDocumentCopies, mockPlanForkFileCopies, } = vi.hoisted(() => ({ - mockUpsertEdgeMappings: vi.fn(), - mockDeleteEdgeMappingsByChildResources: vi.fn(), + mockPersistCopiedResourceMappings: vi.fn(), mockCopyForkResourceContainers: vi.fn(), mockPlanForkMappedKbDocumentCopies: vi.fn(), mockPlanForkFileCopies: vi.fn(), })) vi.mock('@/ee/workspace-forking/lib/mapping/mapping-store', () => ({ - upsertEdgeMappings: mockUpsertEdgeMappings, - deleteEdgeMappingsByChildResources: mockDeleteEdgeMappingsByChildResources, + persistCopiedResourceMappings: mockPersistCopiedResourceMappings, resourceTypeToForkKind: vi.fn(), })) @@ -40,14 +37,12 @@ vi.mock('@/ee/workspace-forking/lib/copy/copy-files', () => ({ })) import type { ForkEdge } from '@/ee/workspace-forking/lib/lineage/lineage' -import type { ForkMappingUpsert } from '@/ee/workspace-forking/lib/mapping/mapping-store' import { augmentForkResolver, buildPromoteCopySelection, copyPromoteUnmappedResources, FORK_COPYABLE_KIND_TO_SELECTION_KEY, hasPromoteCopySelection, - persistPromoteCopiedMappings, } from '@/ee/workspace-forking/lib/promote/copy-unmapped' import { isForkCopyableKind } from '@/ee/workspace-forking/lib/promote/promote-plan' import type { ForkRemapKind } from '@/ee/workspace-forking/lib/remap/remap-references' @@ -237,51 +232,6 @@ describe('augmentForkResolver', () => { }) }) -describe('persistPromoteCopiedMappings', () => { - const tx = {} as DbOrTx - const entry: ForkMappingUpsert = { - resourceType: 'knowledge_base', - parentResourceId: 'src-kb', - childResourceId: 'dst-kb', - } - - beforeEach(() => { - vi.clearAllMocks() - }) - - it('pull keeps the source(parent)->target(child) orientation as-is', async () => { - await persistPromoteCopiedMappings(tx, 'edge-child', 'user-1', 'pull', [entry]) - expect(mockUpsertEdgeMappings).toHaveBeenCalledWith(tx, 'edge-child', 'user-1', [entry]) - expect(mockDeleteEdgeMappingsByChildResources).not.toHaveBeenCalled() - }) - - it('push swaps to target(parent)->source(child) and deletes the prior row keyed on the source child', async () => { - await persistPromoteCopiedMappings(tx, 'edge-child', 'user-1', 'push', [entry]) - // Delete keys on the source child resource (the swapped child id = the original parent id). - expect(mockDeleteEdgeMappingsByChildResources).toHaveBeenCalledWith(tx, 'edge-child', [ - { resourceType: 'knowledge_base', childResourceId: 'src-kb' }, - ]) - // The swap flips parent/child: the new copy (dst) becomes the parent side on push. - expect(mockUpsertEdgeMappings).toHaveBeenCalledWith(tx, 'edge-child', 'user-1', [ - { resourceType: 'knowledge_base', parentResourceId: 'dst-kb', childResourceId: 'src-kb' }, - ]) - }) - - it('push skips an entry with a null child id (the narrowing guard, no bogus mapping)', async () => { - await persistPromoteCopiedMappings(tx, 'edge-child', 'user-1', 'push', [ - { resourceType: 'knowledge_base', parentResourceId: 'src-kb', childResourceId: null }, - ]) - expect(mockDeleteEdgeMappingsByChildResources).not.toHaveBeenCalled() - expect(mockUpsertEdgeMappings).not.toHaveBeenCalled() - }) - - it('returns without writing when there are no entries', async () => { - await persistPromoteCopiedMappings(tx, 'edge-child', 'user-1', 'push', []) - expect(mockDeleteEdgeMappingsByChildResources).not.toHaveBeenCalled() - expect(mockUpsertEdgeMappings).not.toHaveBeenCalled() - }) -}) - describe('copyPromoteUnmappedResources - files + folder content-refs', () => { const tx = {} as DbOrTx // Only edge.childWorkspaceId is read by the copy path. @@ -320,6 +270,46 @@ describe('copyPromoteUnmappedResources - files + folder content-refs', () => { }) }) + it('threads push orientation through the shared container and mapping boundaries', async () => { + await copyPromoteUnmappedResources({ + tx, + edge, + sourceWorkspaceId: 'child-source-ws', + targetWorkspaceId: 'parent-target-ws', + direction: 'push', + userId: 'user-1', + now: new Date(), + selection: { + customTools: [], + skills: [], + tables: [], + knowledgeBases: [], + files: [], + mcpServers: [], + }, + workflowIdMap: new Map(), + folderIdMap: new Map(), + resolver: () => null, + resolveBlockId, + referencedDocumentIds: [], + }) + + expect(mockCopyForkResourceContainers).toHaveBeenCalledWith( + expect.objectContaining({ + documentMappingContext: { + edgeChildWorkspaceId: 'edge-child', + sourceIsParent: false, + }, + }) + ) + expect(mockPersistCopiedResourceMappings).toHaveBeenCalledWith( + expect.objectContaining({ + edgeChildWorkspaceId: 'edge-child', + sourceIsParent: false, + }) + ) + }) + it('copies selected files (keyMap + blobTasks), persists the file mapping, and threads file + folder content-ref maps', async () => { mockPlanForkFileCopies.mockResolvedValue({ keyMap: new Map([['workspace/SRC/a.png', 'workspace/DST/a.png']]), @@ -351,6 +341,7 @@ describe('copyPromoteUnmappedResources - files + folder content-refs', () => { tables: [], knowledgeBases: [], files: ['workspace/SRC/a.png'], + mcpServers: [], }, workflowIdMap: new Map(), folderIdMap: new Map([['fld-src', 'fld-dst']]), @@ -371,13 +362,19 @@ describe('copyPromoteUnmappedResources - files + folder content-refs', () => { ) // The file mapping is persisted (pull keeps source(parent)->target(child) orientation) so a // re-sync resolves the copy instead of re-copying it. - expect(mockUpsertEdgeMappings).toHaveBeenCalledWith(tx, 'edge-child', 'user-1', [ - { - resourceType: 'file', - parentResourceId: 'workspace/SRC/a.png', - childResourceId: 'workspace/DST/a.png', - }, - ]) + expect(mockPersistCopiedResourceMappings).toHaveBeenCalledWith({ + executor: tx, + edgeChildWorkspaceId: 'edge-child', + userId: 'user-1', + sourceIsParent: true, + entries: [ + { + resourceType: 'file', + parentResourceId: 'workspace/SRC/a.png', + childResourceId: 'workspace/DST/a.png', + }, + ], + }) // The folder map AND the file key/id maps reach the in-content rewriter. expect(result.contentRefMaps.folders).toEqual({ 'fld-src': 'fld-dst' }) expect(result.contentRefMaps.fileKeys).toEqual({ 'workspace/SRC/a.png': 'workspace/DST/a.png' }) @@ -430,6 +427,7 @@ describe('copyPromoteUnmappedResources - files + folder content-refs', () => { tables: ['tbl-unref'], knowledgeBases: [], files: [], + mcpServers: [], }, workflowIdMap: new Map(), folderIdMap: new Map(), @@ -438,9 +436,15 @@ describe('copyPromoteUnmappedResources - files + folder content-refs', () => { referencedDocumentIds: [], }) - expect(mockUpsertEdgeMappings).toHaveBeenCalledWith(tx, 'edge-child', 'user-1', [ - { resourceType: 'table', parentResourceId: 'tbl-unref', childResourceId: 'tbl-copy' }, - ]) + expect(mockPersistCopiedResourceMappings).toHaveBeenCalledWith({ + executor: tx, + edgeChildWorkspaceId: 'edge-child', + userId: 'user-1', + sourceIsParent: true, + entries: [ + { resourceType: 'table', parentResourceId: 'tbl-unref', childResourceId: 'tbl-copy' }, + ], + }) }) it('threads the plan-provided referencedDocumentIds into both doc-copy paths (no in-tx re-scan)', async () => { @@ -478,10 +482,17 @@ describe('copyPromoteUnmappedResources - files + folder content-refs', () => { // The promote-built block-id resolver reaches the table remap unchanged, so copied // tables' workflow-group outputs use the persisted-pair ids, not the derive. resolveBlockId, + documentMappingContext: { + edgeChildWorkspaceId: 'edge-child', + sourceIsParent: true, + }, }) ) expect(mockPlanForkMappedKbDocumentCopies).toHaveBeenCalledWith( - expect.objectContaining({ referencedDocumentIds: ['doc-1', 'doc-2'] }) + expect.objectContaining({ + referencedDocumentIds: ['doc-1', 'doc-2'], + now: expect.any(Date), + }) ) }) }) diff --git a/apps/sim/ee/workspace-forking/lib/promote/copy-unmapped.ts b/apps/sim/ee/workspace-forking/lib/promote/copy-unmapped.ts index 40b5fcaba49..4cd8f703258 100644 --- a/apps/sim/ee/workspace-forking/lib/promote/copy-unmapped.ts +++ b/apps/sim/ee/workspace-forking/lib/promote/copy-unmapped.ts @@ -16,10 +16,9 @@ import { } from '@/ee/workspace-forking/lib/copy/copy-resources' import type { ForkEdge } from '@/ee/workspace-forking/lib/lineage/lineage' import { - deleteEdgeMappingsByChildResources, type ForkMappingUpsert, + persistCopiedResourceMappings, resourceTypeToForkKind, - upsertEdgeMappings, } from '@/ee/workspace-forking/lib/mapping/mapping-store' import type { ForkBlockIdResolver } from '@/ee/workspace-forking/lib/remap/block-identity' import type { @@ -230,6 +229,10 @@ export async function copyPromoteUnmappedResources(params: { // rewritten through the same plan resolver that remaps subblock-value env refs. resolveEnvName: (key) => resolver('env-var', key), resolveBlockId, + documentMappingContext: { + edgeChildWorkspaceId: edge.childWorkspaceId, + sourceIsParent: direction === 'pull', + }, }) // Copy the selected workspace files (keyed by storage key) - metadata inserts in the tx, blob @@ -259,6 +262,7 @@ export async function copyPromoteUnmappedResources(params: { resolver, referencedDocumentIds, alreadyCopiedSourceDocIds: new Set(containerDocMap.keys()), + now, }) result.contentPlan.documents.push(...mappedKbDocs.documents) @@ -272,11 +276,13 @@ export async function copyPromoteUnmappedResources(params: { childResourceId: child, }) ) - await persistPromoteCopiedMappings(tx, edge.childWorkspaceId, userId, direction, [ - ...result.mappingEntries, - ...fileMappingEntries, - ...mappedKbDocs.mappingEntries, - ]) + await persistCopiedResourceMappings({ + executor: tx, + edgeChildWorkspaceId: edge.childWorkspaceId, + userId, + sourceIsParent: direction === 'pull', + entries: [...result.mappingEntries, ...fileMappingEntries, ...mappedKbDocs.mappingEntries], + }) const copyIdMapByKind = new Map>() for (const [resourceType, sourceToTarget] of result.idMap) { @@ -313,44 +319,3 @@ export async function copyPromoteUnmappedResources(params: { blobTasks: fileResult.blobTasks, } } - -/** - * Persist the copied resources' id mappings for the edge. The copy returns entries oriented - * source(parent)->target(child); a pull matches that orientation directly (fill-null upsert), a - * push swaps it (the parent side is the new TARGET) and first drops any prior row keyed on the - * source child resource so a changed target can't leak a second mapping. - */ -export async function persistPromoteCopiedMappings( - tx: DbOrTx, - childWorkspaceId: string, - userId: string, - direction: 'push' | 'pull', - entries: ForkMappingUpsert[] -): Promise { - if (entries.length === 0) return - if (direction === 'pull') { - await upsertEdgeMappings(tx, childWorkspaceId, userId, entries) - return - } - // Push: re-key on the source child resource. Skip any entry with a null child id (copy entries - // always carry one; the guard narrows the type so neither the swap nor the delete needs a cast). - // After the swap every childResourceId is the original (non-null) parent id, keyed for the - // delete-then-insert that prevents a changed target from leaking a second mapping. - const swapped: ForkMappingUpsert[] = [] - const deleteKeys: Array<{ - resourceType: ForkMappingUpsert['resourceType'] - childResourceId: string - }> = [] - for (const entry of entries) { - if (entry.childResourceId == null) continue - swapped.push({ - resourceType: entry.resourceType, - parentResourceId: entry.childResourceId, - childResourceId: entry.parentResourceId, - }) - deleteKeys.push({ resourceType: entry.resourceType, childResourceId: entry.parentResourceId }) - } - if (swapped.length === 0) return - await deleteEdgeMappingsByChildResources(tx, childWorkspaceId, deleteKeys) - await upsertEdgeMappings(tx, childWorkspaceId, userId, swapped) -} diff --git a/apps/sim/executor/execution/block-executor.test.ts b/apps/sim/executor/execution/block-executor.test.ts index 74755b004f1..b6ac826c78b 100644 --- a/apps/sim/executor/execution/block-executor.test.ts +++ b/apps/sim/executor/execution/block-executor.test.ts @@ -5,6 +5,7 @@ import { loggerMock } from '@sim/testing' import { beforeEach, describe, expect, it, vi } from 'vitest' import { clearLargeValueCacheForTests } from '@/lib/execution/payloads/cache' import { isLargeArrayManifest } from '@/lib/execution/payloads/large-array-manifest-metadata' +import { isLargeValueRef } from '@/lib/execution/payloads/large-value-ref' import { projectTraceSpansForSecrets } from '@/lib/logs/execution/trace-secret-projection' import { buildTraceSpans } from '@/lib/logs/execution/trace-spans/trace-spans' import { BlockType, EDGE } from '@/executor/constants' @@ -146,6 +147,64 @@ describe('BlockExecutor', () => { }) }) + it('carries complete encrypted candidates through large-output compaction', async () => { + const block = createBlock() + const workflow: SerializedWorkflow = { + version: '1', + blocks: [block], + connections: [], + loops: {}, + parallels: {}, + } + const state = new ExecutionState() + const resolver = new VariableResolver(workflow, {}, state) + const onBlockComplete = vi.fn(async () => {}) + const registry = new ResolvedSecretTraceRegistry( + [{ name: 'API_KEY', plaintext: 'secret-value', encryptedValue: 'encrypted-secret' }], + { userId: 'user-1', workspaceId: 'workspace-1' } + ) + const handler: BlockHandler = { + canHandle: () => true, + execute: async (blockContext) => { + blockContext.resolvedSecretTraceRegistry?.recordResolved('API_KEY', 'secret-value') + return { + result: { + huge: 'p'.repeat(9 * 1024 * 1024), + public: 'ok', + secret: 'secret-value', + }, + } + }, + } + const executor = new BlockExecutor([handler], resolver, { onBlockComplete }, state) + const ctx = createContext(state) + ctx.resolvedSecretTraceRegistry = registry + + await executor.execute(ctx, createNode(block), block) + await vi.waitFor(() => expect(onBlockComplete).toHaveBeenCalledOnce()) + + const storedOutput = state.getBlockOutput(block.id) + const storedResult = storedOutput?.result as Record + expect(isLargeValueRef(storedResult.huge)).toBe(true) + expect(storedResult.public).toBe('ok') + expect(storedResult.secret).toBe('secret-value') + + const expectedProvenance = { + version: 1, + complete: true, + entries: [{ name: 'API_KEY', encryptedValue: 'encrypted-secret' }], + scope: { userId: 'user-1', workspaceId: 'workspace-1' }, + } + expect(state.getBlockState(block.id)?.resolvedSecretTraceProvenance).toEqual(expectedProvenance) + expect(onBlockComplete.mock.calls[0]?.[3]?.resolvedSecretTraceProvenance).toEqual( + expectedProvenance + ) + expect(onBlockComplete.mock.calls[0]?.[3]?.displayResolvedSecretTraceProvenance).toEqual( + expectedProvenance + ) + expect(JSON.stringify(expectedProvenance)).not.toContain('secret-value') + }) + it('persists stable outer-branch aliases for completed parallel branch outputs', async () => { const block = createBlock() const workflow: SerializedWorkflow = { @@ -327,7 +386,7 @@ describe('BlockExecutor', () => { it('projects lifecycle callback diagnostics without changing block execution', async () => { const secret = 'lifecycle-secret-7f3a91' - const startError = new Error(`start failed ${secret} __var_API_KEY`) + const startError = new Error('start failed __var_API_KEY') const completionError = new Error(`completion failed ${secret} __sim_code_6_binding_0`) const block = createBlock() const workflow: SerializedWorkflow = { @@ -341,7 +400,15 @@ describe('BlockExecutor', () => { const resolver = new VariableResolver(workflow, {}, state) const output = { result: `raw ${secret}` } const executor = new BlockExecutor( - [{ canHandle: () => true, execute: async () => output }], + [ + { + canHandle: () => true, + execute: async (blockContext) => { + blockContext.resolvedSecretTraceRegistry?.recordResolved('API_KEY', secret) + return output + }, + }, + ], resolver, { workspaceId: 'workspace-1', @@ -382,8 +449,7 @@ describe('BlockExecutor', () => { expect.objectContaining({ blockId: block.id, blockType: BlockType.FUNCTION, - errorType: 'error', - hasStack: true, + error: 'completion failed {{API_KEY}} [RUNTIME_BINDING]', }) ) }) @@ -392,7 +458,7 @@ describe('BlockExecutor', () => { expect.objectContaining({ blockId: block.id, blockType: BlockType.FUNCTION, - error: 'start failed {{API_KEY}} {{API_KEY}}', + error: 'start failed [REDACTED_SECRET]', }) ) const loggerPayload = JSON.stringify(executionLogger?.warn.mock.calls) @@ -400,7 +466,7 @@ describe('BlockExecutor', () => { expect(loggerPayload).not.toContain(secret) expect(loggerPayload).not.toContain('__var_') expect(loggerPayload).not.toContain('__sim_') - expect(startError.message).toContain(secret) + expect(startError.message).toContain('__var_API_KEY') expect(completionError.message).toContain(secret) }) @@ -795,7 +861,8 @@ describe('BlockExecutor', () => { const resolver = new VariableResolver(workflow, {}, state) const handler: BlockHandler = { canHandle: () => true, - execute: async () => { + execute: async (blockContext) => { + blockContext.resolvedSecretTraceRegistry?.recordResolved('API_KEY', secret) throw new Error(rawError) }, } @@ -900,10 +967,17 @@ describe('BlockExecutor streaming pump', () => { failAfterText?: string streamError?: Error onFullContent?: (content: string) => void | Promise + resolvedSecret?: { name: string; value: string } }): BlockHandler { return { canHandle: () => true, - execute: async () => { + execute: async (blockContext) => { + if (options.resolvedSecret) { + blockContext.resolvedSecretTraceRegistry?.recordResolved( + options.resolvedSecret.name, + options.resolvedSecret.value + ) + } const timeSegment: Record = { type: 'model', name: 'claude-test', @@ -1031,6 +1105,7 @@ describe('BlockExecutor streaming pump', () => { const handler = createAgentEventsStreamingHandler({ failAfterText: 'partial', streamError: rawError, + resolvedSecret: { name: 'API_KEY', value: secret }, }) const { executor, block, state } = createExecutor(handler) const ctx = createContext(state) @@ -1079,6 +1154,7 @@ describe('BlockExecutor streaming pump', () => { onFullContent: async () => { throw callbackError }, + resolvedSecret: { name: 'API_KEY', value: secret }, }) const { executor, block, state } = createExecutor(handler) block.config.params = { responseFormat: 'json' } diff --git a/apps/sim/executor/execution/block-executor.ts b/apps/sim/executor/execution/block-executor.ts index 0397420dbef..c43299d85ee 100644 --- a/apps/sim/executor/execution/block-executor.ts +++ b/apps/sim/executor/execution/block-executor.ts @@ -101,8 +101,9 @@ export class BlockExecutor { } const parentResolvedSecretTraceRegistry = ctx.resolvedSecretTraceRegistry - const blockResolvedSecretTraceRegistry = - parentResolvedSecretTraceRegistry?.forkForToolInputValues([]) + const blockResolvedSecretTraceRegistry = parentResolvedSecretTraceRegistry?.forkForInputPaths( + [] + ) const blockCtx = blockResolvedSecretTraceRegistry ? { ...ctx, resolvedSecretTraceRegistry: blockResolvedSecretTraceRegistry } : ctx diff --git a/apps/sim/executor/execution/engine.test.ts b/apps/sim/executor/execution/engine.test.ts index f8bb0aaf4e8..0d5d4dd4c0a 100644 --- a/apps/sim/executor/execution/engine.test.ts +++ b/apps/sim/executor/execution/engine.test.ts @@ -1009,10 +1009,12 @@ describe('ExecutionEngine', () => { startNode.outgoingEdges.set('edge1', { target: 'error-node' }) const dag = createMockDAG([startNode, errorNode]) + const registry = new ResolvedSecretTraceRegistry([ + { name: 'API_KEY', plaintext: secret, encryptedValue: 'encrypted-api-key' }, + ]) + registry.recordResolved('API_KEY', secret) const context = createMockContext({ - resolvedSecretTraceRegistry: new ResolvedSecretTraceRegistry([ - { name: 'API_KEY', plaintext: secret, encryptedValue: 'encrypted-api-key' }, - ]), + resolvedSecretTraceRegistry: registry, }) const edgeManager = createMockEdgeManager((node) => { if (node.id === 'start') return ['error-node'] diff --git a/apps/sim/executor/execution/types.ts b/apps/sim/executor/execution/types.ts index b7b7cbc5ff4..bcfbf83210b 100644 --- a/apps/sim/executor/execution/types.ts +++ b/apps/sim/executor/execution/types.ts @@ -106,7 +106,7 @@ export interface SerializableExecutionState { workflowVariableResolvedSecretTraceProvenance?: Record /** Exact-value provenance for the persisted workflow input. Absence means legacy/untracked. */ workflowInputResolvedSecretTraceProvenance?: ResolvedSecretTraceProvenanceV1 - /** Exact-value provenance for the persisted terminal output. Absence means legacy/untracked. */ + /** Encrypted candidates for the persisted terminal output. Absence means legacy/untracked. */ finalOutputResolvedSecretTraceProvenance?: ResolvedSecretTraceProvenanceV1 /** Presence distinguishes current checkpoints from legacy states that predate provenance. */ resolvedSecretTraceCheckpointVersion?: 1 @@ -170,11 +170,11 @@ export interface BlockCompletionCallbackData { input?: unknown output: NormalizedBlockOutput /** - * Encrypted provenance filtered to this exact block output. Internal durable - * consumers use it when the raw output crosses a storage boundary. + * Encrypted candidates active in this block call. Internal durable consumers + * filter them against the exact value that crosses a storage boundary. */ resolvedSecretTraceProvenance?: ResolvedSecretTraceProvenanceV1 - /** Internal display-only provenance filtered to this callback's input/output envelope. */ + /** Internal encrypted candidates filtered against the display envelope during projection. */ displayResolvedSecretTraceProvenance?: ResolvedSecretTraceProvenanceV1 executionTime: number startedAt: string diff --git a/apps/sim/executor/handlers/agent/agent-handler.test.ts b/apps/sim/executor/handlers/agent/agent-handler.test.ts index 19cb8423617..cd9bb304b4f 100644 --- a/apps/sim/executor/handlers/agent/agent-handler.test.ts +++ b/apps/sim/executor/handlers/agent/agent-handler.test.ts @@ -27,6 +27,7 @@ import { ResolvedSecretTraceRegistry } from '@/executor/utils/resolved-secret-tr import { executeProviderRequest } from '@/providers' import { installStreamingCostPolicy } from '@/providers/cost-policy' import { SIM_AUTO_MODEL_ID } from '@/providers/models' +import { getProviderToolInputProvenance } from '@/providers/tool-input-provenance' import { getProviderFromModel, transformBlockTool } from '@/providers/utils' import type { SerializedBlock, SerializedWorkflow } from '@/serializer/types' import { executeTool } from '@/tools' @@ -34,6 +35,15 @@ import { ToolSchemaEnrichmentError } from '@/tools/params' process.env.NEXT_PUBLIC_APP_URL = 'http://localhost:3000' +const { mockImportWorkspaceFileSecretProvenanceForModelView } = vi.hoisted(() => ({ + mockImportWorkspaceFileSecretProvenanceForModelView: vi.fn().mockResolvedValue(true), +})) + +vi.mock('@/lib/uploads/contexts/workspace/workspace-file-secret-provenance', () => ({ + importWorkspaceFileSecretProvenanceForModelView: + mockImportWorkspaceFileSecretProvenanceForModelView, +})) + vi.mock('@/providers/utils', () => ({ isFunctionToolCall: (toolCall: unknown) => typeof toolCall === 'object' && @@ -140,6 +150,7 @@ describe('AgentBlockHandler', () => { beforeEach(() => { handler = new AgentBlockHandler() vi.clearAllMocks() + mockImportWorkspaceFileSecretProvenanceForModelView.mockResolvedValue(true) resetDbChainMock() // The MCP server lookup awaits select().from(mcpServers).where(...) directly; // queue a set per lookup so the structural where spy keeps its default wiring. @@ -489,6 +500,247 @@ describe('AgentBlockHandler', () => { }) }) + it('projects a resolver-recorded document name only after raw file hydration', async () => { + const registry = new ResolvedSecretTraceRegistry([ + { name: 'FILE_NAME', plaintext: 'classified.txt', encryptedValue: 'encrypted-name' }, + ]) + registry.recordResolvedAtInputPath('FILE_NAME', 'classified.txt', ['files', '0', 'name']) + registry.recordResolvedInputProjection( + ['files', '0', 'name'], + 'classified.txt', + '{{FILE_NAME}}' + ) + mockContext.resolvedSecretTraceRegistry = registry + mockGetProviderFromModel.mockReturnValue('openai') + + const inputs = { + model: 'gpt-4o', + userPrompt: 'Analyze this file', + files: [ + { + id: 'file-1', + key: 'workspace/ws-1/classified.txt', + name: 'classified.txt', + size: 5, + type: 'text/plain', + base64: 'aW1hZ2U=', + }, + ], + apiKey: 'test-api-key', + } + const rawInputs = structuredClone(inputs) + + await handler.execute(mockContext, mockBlock, inputs) + + expect(mockExecuteProviderRequest.mock.calls[0][1].messages.at(-1)?.files).toEqual([ + expect.objectContaining({ name: '{{FILE_NAME}}.txt', base64: 'aW1hZ2U=' }), + ]) + expect(inputs).toEqual(rawInputs) + }) + + it('rejects resolver-derived inline attachment bytes instead of corrupting base64', async () => { + const registry = new ResolvedSecretTraceRegistry([ + { name: 'FILE_BYTES', plaintext: 'aW1hZ2U=', encryptedValue: 'encrypted-bytes' }, + ]) + const inputPath = ['files', '0', 'base64'] as const + registry.recordResolvedAtInputPath('FILE_BYTES', 'aW1hZ2U=', inputPath) + registry.recordResolvedInputProjection(inputPath, 'aW1hZ2U=', '{{FILE_BYTES}}') + mockContext.resolvedSecretTraceRegistry = registry + + await expect( + handler.execute(mockContext, mockBlock, { + model: 'gpt-4o', + userPrompt: 'Analyze this file', + files: [ + { + id: 'file-1', + key: 'workspace/ws-1/example.png', + name: 'example.png', + size: 5, + type: 'image/png', + base64: 'aW1hZ2U=', + }, + ], + }) + ).rejects.toThrow('Agent inline file content cannot contain secret references') + expect(mockExecuteProviderRequest).not.toHaveBeenCalled() + }) + + it('keeps ordinary direct file fields unchanged without resolver-recorded lineage', async () => { + mockContext.resolvedSecretTraceRegistry = new ResolvedSecretTraceRegistry([ + { name: 'UNUSED_NAME', plaintext: 'example.png', encryptedValue: 'encrypted-name' }, + { name: 'UNUSED_BYTES', plaintext: 'aW1hZ2U=', encryptedValue: 'encrypted-bytes' }, + ]) + mockGetProviderFromModel.mockReturnValue('openai') + + await handler.execute(mockContext, mockBlock, { + model: 'gpt-4o', + userPrompt: 'Analyze this file', + files: [ + { + id: 'file-1', + key: 'workspace/ws-1/example.png', + name: 'example.png', + size: 5, + type: 'image/png', + base64: 'aW1hZ2U=', + }, + ], + apiKey: 'test-api-key', + }) + + expect(mockExecuteProviderRequest.mock.calls[0][1].messages.at(-1)?.files).toEqual([ + expect.objectContaining({ name: 'example.png', base64: 'aW1hZ2U=' }), + ]) + }) + + it('projects a resolver-recorded name inside a persisted serialized file input', async () => { + const rawFiles = JSON.stringify([ + { + id: 'file-1', + key: 'workspace/ws-1/private.pdf', + name: 'private.pdf', + size: 5, + type: 'application/pdf', + base64: 'JVBERi0=', + }, + ]) + const projectedFiles = JSON.stringify([ + { + id: 'file-1', + key: 'workspace/ws-1/private.pdf', + name: '{{FILE_NAME}}', + size: 5, + type: 'application/pdf', + base64: 'JVBERi0=', + }, + ]) + const registry = new ResolvedSecretTraceRegistry([ + { name: 'FILE_NAME', plaintext: 'private.pdf', encryptedValue: 'encrypted-name' }, + ]) + registry.recordResolvedAtInputPath('FILE_NAME', 'private.pdf', ['files']) + registry.recordResolvedInputProjection(['files'], rawFiles, projectedFiles) + mockContext.resolvedSecretTraceRegistry = registry + mockGetProviderFromModel.mockReturnValue('openai') + + await handler.execute(mockContext, mockBlock, { + model: 'gpt-4o', + userPrompt: 'Analyze this file', + files: rawFiles, + apiKey: 'test-api-key', + }) + + expect(mockExecuteProviderRequest.mock.calls[0][1].messages.at(-1)?.files).toEqual([ + expect.objectContaining({ name: '{{FILE_NAME}}.pdf', base64: 'JVBERi0=' }), + ]) + }) + + it('rejects resolver-derived inline bytes inside a persisted serialized file input', async () => { + const rawFiles = JSON.stringify([ + { + id: 'file-1', + key: 'workspace/ws-1/example.png', + name: 'example.png', + size: 5, + type: 'image/png', + base64: 'aW1hZ2U=', + }, + ]) + const projectedFiles = JSON.stringify([ + { + id: 'file-1', + key: 'workspace/ws-1/example.png', + name: 'example.png', + size: 5, + type: 'image/png', + base64: '{{FILE_BYTES}}', + }, + ]) + const registry = new ResolvedSecretTraceRegistry([ + { name: 'FILE_BYTES', plaintext: 'aW1hZ2U=', encryptedValue: 'encrypted-bytes' }, + ]) + registry.recordResolvedAtInputPath('FILE_BYTES', 'aW1hZ2U=', ['files']) + registry.recordResolvedInputProjection(['files'], rawFiles, projectedFiles) + mockContext.resolvedSecretTraceRegistry = registry + + await expect( + handler.execute(mockContext, mockBlock, { + model: 'gpt-4o', + userPrompt: 'Analyze this file', + files: rawFiles, + apiKey: 'test-api-key', + }) + ).rejects.toThrow('Agent inline file content cannot contain secret references') + expect(mockExecuteProviderRequest).not.toHaveBeenCalled() + }) + + it('keeps a serialized file input unchanged without resolver-recorded lineage', async () => { + const files = JSON.stringify([ + { + id: 'file-1', + key: 'workspace/ws-1/example.png', + name: 'example.png', + size: 5, + type: 'image/png', + base64: 'aW1hZ2U=', + }, + ]) + mockContext.resolvedSecretTraceRegistry = new ResolvedSecretTraceRegistry([ + { name: 'UNUSED_NAME', plaintext: 'example.png', encryptedValue: 'encrypted-name' }, + { name: 'UNUSED_BYTES', plaintext: 'aW1hZ2U=', encryptedValue: 'encrypted-bytes' }, + ]) + mockGetProviderFromModel.mockReturnValue('openai') + + await handler.execute(mockContext, mockBlock, { + model: 'gpt-4o', + userPrompt: 'Analyze this file', + files, + apiKey: 'test-api-key', + }) + + expect(mockExecuteProviderRequest.mock.calls[0][1].messages.at(-1)?.files).toEqual([ + expect.objectContaining({ name: 'example.png', base64: 'aW1hZ2U=' }), + ]) + }) + + it('projects an inbound message document name without mutating the raw message', async () => { + const registry = new ResolvedSecretTraceRegistry([ + { name: 'FILE_NAME', plaintext: 'private.pdf', encryptedValue: 'encrypted-name' }, + ]) + const inputPath = ['messages', '0', 'files', '0', 'name'] as const + registry.recordResolvedAtInputPath('FILE_NAME', 'private.pdf', inputPath) + registry.recordResolvedInputProjection(inputPath, 'private.pdf', '{{FILE_NAME}}') + mockContext.resolvedSecretTraceRegistry = registry + mockGetProviderFromModel.mockReturnValue('openai') + const inputs = { + model: 'gpt-4o', + messages: [ + { + role: 'user' as const, + content: 'Read this document', + files: [ + { + id: 'file-1', + key: 'workspace/ws-1/private.pdf', + name: 'private.pdf', + size: 5, + type: 'application/pdf', + base64: 'JVBERi0=', + }, + ], + }, + ], + } + const rawInputs = structuredClone(inputs) + + await handler.execute(mockContext, mockBlock, inputs) + + expect(mockExecuteProviderRequest.mock.calls[0][1].messages[0].files).toEqual([ + expect.objectContaining({ name: '{{FILE_NAME}}.pdf', base64: 'JVBERi0=' }), + ]) + expect(inputs).toEqual(rawInputs) + }) + it('normalizes the persisted workspace-picker shape before provider execution', async () => { const key = 'workspace/ws-1/example.png' const hydrationSpy = vi @@ -530,6 +782,56 @@ describe('AgentBlockHandler', () => { } }) + it('omits only a generated document whose embedded contributor is not model-safe', async () => { + const key = 'workspace/ws-1/report.pdf' + mockContext.workspaceId = 'ws-1' + const hydrationSpy = vi + .spyOn(userFileBase64, 'hydrateUserFilesWithBase64') + .mockImplementationOnce(async (files, options) => { + await options.onServableFileContributors?.(files[0], [ + { + fileId: 'image-1', + key: 'workspace/ws-1/image-1.png', + context: 'workspace', + contentUpdatedAt: new Date('2026-08-06T00:00:00.000Z'), + }, + ]) + return files.map((file) => ({ ...file, base64: 'JVBERi0=' })) + }) + mockImportWorkspaceFileSecretProvenanceForModelView.mockResolvedValueOnce(false) + + try { + mockGetProviderFromModel.mockReturnValue('openai') + + await handler.execute(mockContext, mockBlock, { + model: 'gpt-4o', + userPrompt: 'Analyze this document', + files: [ + { + id: 'file-1', + name: 'report.pdf', + path: `/api/files/serve/${encodeURIComponent(key)}?context=workspace`, + key, + size: 128, + type: 'text/x-python-pdf', + }, + ], + apiKey: 'test-api-key', + }) + + expect(mockExecuteProviderRequest.mock.calls[0][1].messages.at(-1)?.files).toEqual([]) + expect(mockImportWorkspaceFileSecretProvenanceForModelView).toHaveBeenCalledWith( + expect.objectContaining({ + workspaceId: mockContext.workspaceId, + view: 'opaque', + identity: expect.objectContaining({ fileId: 'image-1' }), + }) + ) + } finally { + hydrationSpy.mockRestore() + } + }) + it('should reject files for providers without attachment support', async () => { const inputs = { model: 'deepseek-chat', @@ -833,6 +1135,490 @@ describe('AgentBlockHandler', () => { expect(mockExecuteProviderRequest).toHaveBeenCalled() }) + it('projects only resolver-recorded Agent text and keeps equal public text unchanged', async () => { + const registry = new ResolvedSecretTraceRegistry([ + { name: 'TOKEN', plaintext: 'x', encryptedValue: 'encrypted-token' }, + ]) + registry.recordResolvedAtInputPath('TOKEN', 'x', ['userPrompt']) + registry.recordResolvedInputProjection(['userPrompt'], 'Box x', 'Box {{TOKEN}}') + mockContext.resolvedSecretTraceRegistry = registry + + await handler.execute(mockContext, mockBlock, { + model: 'gpt-4o', + systemPrompt: 'Box eSign stays public', + userPrompt: 'Box x', + }) + + const [, providerRequest, runtimeContext] = mockExecuteProviderRequest.mock.calls[0] + expect(providerRequest.messages).toEqual([ + { role: 'system', content: 'Box eSign stays public' }, + { role: 'user', content: 'Box {{TOKEN}}' }, + ]) + expect(runtimeContext.resolvedSecretTraceRegistry.getActiveMatches()).toEqual([ + { plaintext: 'x', replacement: '{{TOKEN}}' }, + ]) + }) + + it('projects exact message call arguments without mutating protocol structure or raw input', async () => { + const registry = new ResolvedSecretTraceRegistry([ + { name: 'FUNCTION_ARG', plaintext: 'first-secret', encryptedValue: 'encrypted-first' }, + { name: 'TOOL_ARG', plaintext: 'second-secret', encryptedValue: 'encrypted-second' }, + { name: 'UNUSED', plaintext: 'x', encryptedValue: 'encrypted-unused' }, + ]) + const functionPath = ['messages', '0', 'function_call', 'arguments'] as const + const toolPath = ['messages', '0', 'tool_calls', '0', 'function', 'arguments'] as const + registry.recordResolvedAtInputPath('FUNCTION_ARG', 'first-secret', functionPath) + registry.recordResolvedInputProjection( + functionPath, + '{"token":"first-secret","public":"x"}', + '{"token":"{{FUNCTION_ARG}}","public":"x"}' + ) + registry.recordResolvedAtInputPath('TOOL_ARG', 'second-secret', toolPath) + registry.recordResolvedInputProjection( + toolPath, + '{"token":"second-secret"}', + '{"token":"{{TOOL_ARG}}"}' + ) + mockContext.resolvedSecretTraceRegistry = registry + const inputs = { + model: 'gpt-4o', + messages: [ + { + role: 'assistant' as const, + content: 'Public x stays unchanged', + function_call: { + name: 'legacy_lookup', + arguments: '{"token":"first-secret","public":"x"}', + }, + tool_calls: [ + { + id: 'call-1', + type: 'function' as const, + function: { name: 'lookup', arguments: '{"token":"second-secret"}' }, + }, + ], + }, + ], + } + const rawInputs = structuredClone(inputs) + + await handler.execute(mockContext, mockBlock, inputs) + + expect(mockExecuteProviderRequest.mock.calls[0][1].messages[0]).toEqual({ + role: 'assistant', + content: 'Public x stays unchanged', + function_call: { + name: 'legacy_lookup', + arguments: '{"token":"{{FUNCTION_ARG}}","public":"x"}', + }, + tool_calls: [ + { + id: 'call-1', + type: 'function', + function: { name: 'lookup', arguments: '{"token":"{{TOOL_ARG}}"}' }, + }, + ], + }) + expect(inputs).toEqual(rawInputs) + }) + + it('rejects an exact secret-derived message protocol identifier', async () => { + const registry = new ResolvedSecretTraceRegistry([ + { name: 'CALL_ID', plaintext: 'private-call', encryptedValue: 'encrypted-call-id' }, + ]) + const inputPath = ['messages', '0', 'tool_calls', '0', 'id'] as const + registry.recordResolvedAtInputPath('CALL_ID', 'private-call', inputPath) + registry.recordResolvedInputProjection(inputPath, 'private-call', '{{CALL_ID}}') + mockContext.resolvedSecretTraceRegistry = registry + + await expect( + handler.execute(mockContext, mockBlock, { + model: 'gpt-4o', + messages: [ + { + role: 'assistant', + content: '', + tool_calls: [ + { + id: 'private-call', + type: 'function', + function: { name: 'lookup', arguments: '{}' }, + }, + ], + }, + ], + }) + ).rejects.toThrow('Agent structural model inputs cannot contain secret references') + expect(mockExecuteProviderRequest).not.toHaveBeenCalled() + }) + + it('binds a resolved tool preset to the exact formatted provider tool instance', async () => { + const registry = new ResolvedSecretTraceRegistry([ + { name: 'API_KEY', plaintext: 'x', encryptedValue: 'encrypted-api-key' }, + ]) + const inputPath = ['tools', '0', 'params', 'apiKey'] as const + registry.recordResolvedAtInputPath('API_KEY', 'x', inputPath) + registry.recordResolvedInputProjection(inputPath, 'x', '{{API_KEY}}') + mockContext.resolvedSecretTraceRegistry = registry + + await handler.execute(mockContext, mockBlock, { + model: 'gpt-4o', + userPrompt: 'Use the configured tool.', + tools: [ + { + type: 'custom-tool', + title: 'lookup', + schema: { + function: { + name: 'lookup', + parameters: { type: 'object', properties: {} }, + }, + }, + params: { apiKey: 'x' }, + }, + ], + }) + + const [, providerRequest, runtimeContext] = mockExecuteProviderRequest.mock.calls[0] + const providerTool = providerRequest.tools[0] + expect(providerTool.params).toEqual({ apiKey: 'x' }) + expect(providerTool).not.toHaveProperty('__resolvedSecretTraceProvenance') + expect(getProviderToolInputProvenance(providerTool)).toEqual({ + registry: runtimeContext.resolvedSecretTraceRegistry, + sourcePath: ['tools', '0', 'params'], + projectedParams: { apiKey: '{{API_KEY}}' }, + }) + }) + + it('omits a tool with unknown hidden preset provenance without blocking the public prompt', async () => { + const registry = new ResolvedSecretTraceRegistry() + await registry.importProvenanceForValueAtInputPath( + { version: 1 }, + 'unknown-value', + ['tools', '0', 'params', 'apiKey'], + { trusted: true } + ) + mockContext.resolvedSecretTraceRegistry = registry + + await handler.execute(mockContext, mockBlock, { + model: 'gpt-4o', + userPrompt: 'Use the configured tool.', + tools: [ + { + type: 'custom-tool', + title: 'lookup', + schema: { + function: { + name: 'lookup', + parameters: { type: 'object', properties: {} }, + }, + }, + params: { apiKey: 'unknown-value' }, + }, + ], + }) + + expect(mockExecuteProviderRequest).toHaveBeenCalledOnce() + const [, providerRequest, runtimeContext] = mockExecuteProviderRequest.mock.calls[0] + expect(providerRequest.messages).toEqual([ + { role: 'user', content: 'Use the configured tool.' }, + ]) + expect(providerRequest.tools).toEqual([]) + expect(runtimeContext.resolvedSecretTraceRegistry.isComplete()).toBe(true) + }) + + it('does not let an unrelated unknown input path block public Agent inputs', async () => { + const registry = new ResolvedSecretTraceRegistry() + await registry.importProvenanceForValueAtInputPath( + { version: 1 }, + 'unknown-value', + ['unusedInput'], + { trusted: true } + ) + mockContext.resolvedSecretTraceRegistry = registry + + await handler.execute(mockContext, mockBlock, { + model: 'gpt-4o', + userPrompt: 'Public prompt', + }) + + expect(mockExecuteProviderRequest).toHaveBeenCalledOnce() + const [, providerRequest, runtimeContext] = mockExecuteProviderRequest.mock.calls[0] + expect(providerRequest.messages).toEqual([{ role: 'user', content: 'Public prompt' }]) + expect(runtimeContext.resolvedSecretTraceRegistry.isComplete()).toBe(true) + }) + + it('projects only resolver-recorded inline and cached tool metadata for the model', async () => { + const registry = new ResolvedSecretTraceRegistry([ + { + name: 'CUSTOM_DESCRIPTION', + plaintext: 'custom-secret', + encryptedValue: 'encrypted-custom-description', + }, + { + name: 'CUSTOM_PARAMETER', + plaintext: 'custom-parameter-secret', + encryptedValue: 'encrypted-custom-parameter', + }, + { + name: 'MCP_PARAMETER', + plaintext: 'mcp-parameter-secret', + encryptedValue: 'encrypted-mcp-parameter', + }, + { + name: 'MCP_SERVER_LABEL', + plaintext: 'private-label', + encryptedValue: 'encrypted-mcp-server-label', + }, + { name: 'UNUSED', plaintext: 'x', encryptedValue: 'encrypted-unused' }, + ]) + const projections = [ + { + name: 'CUSTOM_DESCRIPTION', + plaintext: 'custom-secret', + path: ['tools', '0', 'schema', 'function', 'description'], + raw: 'Use custom-secret for Box', + projected: 'Use {{CUSTOM_DESCRIPTION}} for Box', + }, + { + name: 'CUSTOM_PARAMETER', + plaintext: 'custom-parameter-secret', + path: [ + 'tools', + '0', + 'schema', + 'function', + 'parameters', + 'properties', + 'query', + 'description', + ], + raw: 'Query custom-parameter-secret', + projected: 'Query {{CUSTOM_PARAMETER}}', + }, + { + name: 'MCP_PARAMETER', + plaintext: 'mcp-parameter-secret', + path: ['tools', '1', 'schema', 'properties', 'query', 'description'], + raw: 'Search mcp-parameter-secret', + projected: 'Search {{MCP_PARAMETER}}', + }, + { + name: 'MCP_SERVER_LABEL', + plaintext: 'private-label', + path: ['tools', '1', 'params', 'serverName'], + raw: 'Docs private-label', + projected: 'Docs {{MCP_SERVER_LABEL}}', + }, + ] as const + for (const projection of projections) { + registry.recordResolvedAtInputPath(projection.name, projection.plaintext, projection.path) + registry.recordResolvedInputProjection( + projection.path, + projection.raw, + projection.projected + ) + } + registry.recordResolved('UNUSED', 'x') + mockContext.resolvedSecretTraceRegistry = registry + mockContext.workspaceId = 'test-workspace-123' + + const tools = [ + { + type: 'custom-tool', + title: 'lookup', + schema: { + function: { + name: 'lookup', + description: 'Use custom-secret for Box', + parameters: { + type: 'object', + properties: { + query: { + type: 'string', + description: 'Query custom-parameter-secret', + enum: ['x', 'safe'], + }, + }, + required: ['query'], + }, + }, + }, + }, + { + type: 'mcp', + schema: { + type: 'object', + properties: { + query: { type: 'string', description: 'Search mcp-parameter-secret' }, + }, + required: ['query'], + }, + params: { + serverId: 'mcp-search-server', + toolName: 'search_files', + serverName: 'Docs private-label', + }, + }, + ] + const rawTools = structuredClone(tools) + + await handler.execute(mockContext, mockBlock, { + model: 'gpt-4o', + userPrompt: 'Use Box without changing it.', + tools, + }) + + const [, providerRequest, runtimeContext] = mockExecuteProviderRequest.mock.calls[0] + expect(providerRequest.tools).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + id: 'custom_lookup', + description: 'Use {{CUSTOM_DESCRIPTION}} for Box', + parameters: expect.objectContaining({ + properties: { + query: { + type: 'string', + description: 'Query {{CUSTOM_PARAMETER}}', + enum: ['x', 'safe'], + }, + }, + }), + }), + expect.objectContaining({ + name: 'search_files', + description: 'MCP tool search_files from Docs {{MCP_SERVER_LABEL}}', + parameters: expect.objectContaining({ + properties: { + query: { type: 'string', description: 'Search {{MCP_PARAMETER}}' }, + }, + }), + }), + ]) + ) + expect(tools).toEqual(rawTools) + expect(runtimeContext.resolvedSecretTraceRegistry.getActiveMatches()).not.toContainEqual( + expect.objectContaining({ plaintext: 'x' }) + ) + }) + + it('rejects an enabled custom tool whose title resolved from a secret', async () => { + const registry = new ResolvedSecretTraceRegistry([ + { + name: 'TOOL_TITLE', + plaintext: 'private-title', + encryptedValue: 'encrypted-tool-title', + }, + ]) + const titlePath = ['tools', '0', 'title'] as const + registry.recordResolvedAtInputPath('TOOL_TITLE', 'private-title', titlePath) + registry.recordResolvedInputProjection(titlePath, 'private-title', '{{TOOL_TITLE}}') + mockContext.resolvedSecretTraceRegistry = registry + + await expect( + handler.execute(mockContext, mockBlock, { + model: 'gpt-4o', + userPrompt: 'Use the tool.', + tools: [ + { + type: 'custom-tool', + title: 'private-title', + schema: { + function: { + name: 'lookup', + parameters: { type: 'object', properties: {} }, + }, + }, + }, + ], + }) + ).rejects.toThrow('Agent structural model inputs cannot contain secret references') + expect(mockExecuteProviderRequest).not.toHaveBeenCalled() + }) + + it('rejects an inline custom function name resolved from a secret', async () => { + const registry = new ResolvedSecretTraceRegistry([ + { + name: 'TOOL_NAME', + plaintext: 'private_name', + encryptedValue: 'encrypted-tool-name', + }, + ]) + const namePath = ['tools', '0', 'schema', 'function', 'name'] as const + registry.recordResolvedAtInputPath('TOOL_NAME', 'private_name', namePath) + registry.recordResolvedInputProjection(namePath, 'private_name', '{{TOOL_NAME}}') + mockContext.resolvedSecretTraceRegistry = registry + + await expect( + handler.execute(mockContext, mockBlock, { + model: 'gpt-4o', + userPrompt: 'Use the tool.', + tools: [ + { + type: 'custom-tool', + title: 'lookup', + schema: { + function: { + name: 'private_name', + parameters: { type: 'object', properties: {} }, + }, + }, + }, + ], + }) + ).rejects.toThrow('Agent structural model inputs cannot contain secret references') + expect(mockExecuteProviderRequest).not.toHaveBeenCalled() + }) + + it('rejects a resolver-recorded semantic schema value instead of changing the contract', async () => { + const registry = new ResolvedSecretTraceRegistry([ + { + name: 'ENUM_VALUE', + plaintext: 'private-option', + encryptedValue: 'encrypted-enum-value', + }, + ]) + const enumPath = [ + 'tools', + '0', + 'schema', + 'function', + 'parameters', + 'properties', + 'description', + 'enum', + '0', + ] as const + registry.recordResolvedAtInputPath('ENUM_VALUE', 'private-option', enumPath) + registry.recordResolvedInputProjection(enumPath, 'private-option', '{{ENUM_VALUE}}') + mockContext.resolvedSecretTraceRegistry = registry + + await expect( + handler.execute(mockContext, mockBlock, { + model: 'gpt-4o', + userPrompt: 'Use the tool.', + tools: [ + { + type: 'custom-tool', + title: 'lookup', + schema: { + function: { + name: 'lookup', + parameters: { + type: 'object', + properties: { + description: { type: 'string', enum: ['private-option'] }, + }, + }, + }, + }, + }, + ], + }) + ).rejects.toThrow('Agent structural model inputs cannot contain secret references') + expect(mockExecuteProviderRequest).not.toHaveBeenCalled() + }) + it('should execute with standard block tools', async () => { const inputs = { model: 'gpt-4o', @@ -957,6 +1743,218 @@ describe('AgentBlockHandler', () => { }) }) + it('keeps an ordinary response format unchanged without resolver-recorded lineage', async () => { + const responseFormat = { + name: 'response_schema', + schema: { + type: 'object', + properties: { answer: { type: 'string', description: 'x' } }, + }, + strict: true, + } + mockContext.resolvedSecretTraceRegistry = new ResolvedSecretTraceRegistry([ + { name: 'UNUSED', plaintext: 'x', encryptedValue: 'encrypted-unused' }, + ]) + + await handler.execute(mockContext, mockBlock, { + model: 'gpt-4o', + userPrompt: 'Return an answer.', + responseFormat, + }) + + expect(mockExecuteProviderRequest.mock.calls[0][1].responseFormat).toEqual(responseFormat) + }) + + it('projects a resolver-recorded nested response format leaf before provider execution', async () => { + const responseFormat = { + name: 'response_schema', + schema: { + type: 'object', + properties: { answer: { type: 'string', description: 'classified' } }, + }, + strict: true, + } + const registry = new ResolvedSecretTraceRegistry([ + { name: 'DESCRIPTION', plaintext: 'classified', encryptedValue: 'encrypted-description' }, + ]) + const inputPath = ['responseFormat', 'schema', 'properties', 'answer', 'description'] as const + registry.recordResolvedAtInputPath('DESCRIPTION', 'classified', inputPath) + registry.recordResolvedInputProjection(inputPath, 'classified', '{{DESCRIPTION}}') + mockContext.resolvedSecretTraceRegistry = registry + + await handler.execute(mockContext, mockBlock, { + model: 'gpt-4o', + userPrompt: 'Return an answer.', + responseFormat, + }) + + expect(mockExecuteProviderRequest.mock.calls[0][1].responseFormat).toEqual({ + ...responseFormat, + schema: { + ...responseFormat.schema, + properties: { + answer: { type: 'string', description: '{{DESCRIPTION}}' }, + }, + }, + }) + }) + + it('projects a resolver-recorded annotation inside a persisted JSON response format', async () => { + const rawResponseFormat = JSON.stringify({ + type: 'object', + properties: { answer: { type: 'string', description: 'classified' } }, + }) + const projectedResponseFormat = JSON.stringify({ + type: 'object', + properties: { answer: { type: 'string', description: '{{DESCRIPTION}}' } }, + }) + const registry = new ResolvedSecretTraceRegistry([ + { name: 'DESCRIPTION', plaintext: 'classified', encryptedValue: 'encrypted-description' }, + ]) + registry.recordResolvedAtInputPath('DESCRIPTION', 'classified', ['responseFormat']) + registry.recordResolvedInputProjection( + ['responseFormat'], + rawResponseFormat, + projectedResponseFormat + ) + mockContext.resolvedSecretTraceRegistry = registry + + await handler.execute(mockContext, mockBlock, { + model: 'gpt-4o', + userPrompt: 'Return an answer.', + responseFormat: rawResponseFormat, + }) + + expect(mockExecuteProviderRequest.mock.calls[0][1].responseFormat).toEqual({ + name: 'response_schema', + schema: JSON.parse(projectedResponseFormat), + strict: true, + }) + }) + + it('rejects a resolver-derived enum inside a persisted JSON response format', async () => { + const rawResponseFormat = JSON.stringify({ + type: 'object', + properties: { answer: { type: 'string', enum: ['classified'] } }, + }) + const projectedResponseFormat = JSON.stringify({ + type: 'object', + properties: { answer: { type: 'string', enum: ['{{ENUM_VALUE}}'] } }, + }) + const registry = new ResolvedSecretTraceRegistry([ + { name: 'ENUM_VALUE', plaintext: 'classified', encryptedValue: 'encrypted-enum' }, + ]) + registry.recordResolvedAtInputPath('ENUM_VALUE', 'classified', ['responseFormat']) + registry.recordResolvedInputProjection( + ['responseFormat'], + rawResponseFormat, + projectedResponseFormat + ) + mockContext.resolvedSecretTraceRegistry = registry + + await expect( + handler.execute(mockContext, mockBlock, { + model: 'gpt-4o', + userPrompt: 'Return an answer.', + responseFormat: rawResponseFormat, + }) + ).rejects.toThrow('Agent model input could not be safely projected') + expect(mockExecuteProviderRequest).not.toHaveBeenCalled() + }) + + it('does not send a resolver-recorded whole response format value to the provider', async () => { + const responseFormat = { type: 'object', properties: { answer: { type: 'string' } } } + const registry = new ResolvedSecretTraceRegistry([ + { + name: 'RESPONSE_FORMAT', + plaintext: JSON.stringify(responseFormat), + encryptedValue: 'encrypted-response-format', + }, + ]) + registry.recordResolvedAtInputPath('RESPONSE_FORMAT', JSON.stringify(responseFormat), [ + 'responseFormat', + ]) + registry.recordResolvedInputProjection( + ['responseFormat'], + responseFormat, + '{{RESPONSE_FORMAT}}' + ) + mockContext.resolvedSecretTraceRegistry = registry + + await expect( + handler.execute(mockContext, mockBlock, { + model: 'gpt-4o', + userPrompt: 'Return an answer.', + responseFormat, + }) + ).rejects.toThrow('Agent model input could not be safely projected') + expect(mockExecuteProviderRequest).not.toHaveBeenCalled() + }) + + it('rejects a resolver-derived response schema enum instead of changing the contract', async () => { + const responseFormat = { + name: 'response_schema', + schema: { + type: 'object', + properties: { + description: { type: 'string', enum: ['private-option'] }, + }, + }, + strict: true, + } + const registry = new ResolvedSecretTraceRegistry([ + { + name: 'ENUM_VALUE', + plaintext: 'private-option', + encryptedValue: 'encrypted-option', + }, + ]) + const inputPath = [ + 'responseFormat', + 'schema', + 'properties', + 'description', + 'enum', + '0', + ] as const + registry.recordResolvedAtInputPath('ENUM_VALUE', 'private-option', inputPath) + registry.recordResolvedInputProjection(inputPath, 'private-option', '{{ENUM_VALUE}}') + mockContext.resolvedSecretTraceRegistry = registry + + await expect( + handler.execute(mockContext, mockBlock, { + model: 'gpt-4o', + userPrompt: 'Return an answer.', + responseFormat, + }) + ).rejects.toThrow('Agent structural model inputs cannot contain secret references') + expect(mockExecuteProviderRequest).not.toHaveBeenCalled() + }) + + it('rejects a resolver-derived response format name', async () => { + const responseFormat = { + name: 'private-schema', + schema: { type: 'object', properties: {} }, + strict: true, + } + const registry = new ResolvedSecretTraceRegistry([ + { name: 'FORMAT_NAME', plaintext: 'private-schema', encryptedValue: 'encrypted-name' }, + ]) + const inputPath = ['responseFormat', 'name'] as const + registry.recordResolvedAtInputPath('FORMAT_NAME', 'private-schema', inputPath) + registry.recordResolvedInputProjection(inputPath, 'private-schema', '{{FORMAT_NAME}}') + mockContext.resolvedSecretTraceRegistry = registry + + await expect( + handler.execute(mockContext, mockBlock, { + model: 'gpt-4o', + userPrompt: 'Return an answer.', + responseFormat, + }) + ).rejects.toThrow('Agent structural model inputs cannot contain secret references') + expect(mockExecuteProviderRequest).not.toHaveBeenCalled() + }) + it('should handle responseFormat when it is an empty string', async () => { mockExecuteProviderRequest.mockResolvedValueOnce({ content: 'Regular text response', diff --git a/apps/sim/executor/handlers/agent/agent-handler.ts b/apps/sim/executor/handlers/agent/agent-handler.ts index a567d899c30..cb5cbf32e05 100644 --- a/apps/sim/executor/handlers/agent/agent-handler.ts +++ b/apps/sim/executor/handlers/agent/agent-handler.ts @@ -7,6 +7,12 @@ import { isPlainRecord } from '@sim/utils/object' import { truncate } from '@sim/utils/string' import { and, eq, inArray, isNull } from 'drizzle-orm' import { normalizeStringRecord, normalizeWorkflowVariables } from '@/lib/core/utils/records' +import { + projectModelSchemaAnnotations, + projectResolvedModelInput, + selectModelSchemaInputPaths, +} from '@/lib/execution/model-input-provenance' +import type { McpToolSchema } from '@/lib/mcp/types' import { createMcpToolId } from '@/lib/mcp/utils' import { type AutoMediaKind, @@ -15,15 +21,19 @@ import { resolveAutoModel, SIM_AUTO_SYSTEM_PREAMBLE, } from '@/lib/model-router/resolve' +import { importWorkspaceFileSecretProvenanceForModelView } from '@/lib/uploads/contexts/workspace/workspace-file-secret-provenance' import { + getFileExtension, MODEL_SUPPORTED_IMAGE_MIME_TYPES, processFilesToUserFiles, type RawFileInput, } from '@/lib/uploads/utils/file-utils' +import { selectModelBoundFileInputPaths } from '@/lib/uploads/utils/model-input' import { hydrateUserFilesWithBase64 } from '@/lib/uploads/utils/user-file-base64.server' import { resolveCustomBlockToolBinding } from '@/lib/workflows/custom-blocks/operations' import { getCustomToolById } from '@/lib/workflows/custom-tools/operations' import { getAllBlocks } from '@/blocks' +import { assembleCustomBlockInputMapping, isCustomBlockType } from '@/blocks/custom/build-config' import type { BlockOutput } from '@/blocks/types' import { normalizeFileInput } from '@/blocks/utils' import { @@ -52,11 +62,17 @@ import { collectBlockData } from '@/executor/utils/block-data' 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 type { + ResolvedSecretInputPath, + ResolvedSecretTraceRegistry, +} from '@/executor/utils/resolved-secret-trace-registry' import { resolveVertexCredential } from '@/executor/utils/vertex-credential' import { executeProviderRequest } from '@/providers' import { formatAttachmentSizes, getProviderFileStrategy, + isProviderAttachmentFilenameModelBound, shouldUseLargeFilePath, supportsFileAttachments, } from '@/providers/attachments' @@ -65,6 +81,11 @@ import { getInlineHydrationMaxBytes, } from '@/providers/file-attachments.server' import { isAutoModel, SIM_AUTO_MODEL_ID } from '@/providers/models' +import { + type ProviderToolInputProvenance, + registerProviderToolInputProvenance, +} from '@/providers/tool-input-provenance' +import type { ProviderToolConfig } from '@/providers/types' import { getProviderFromModel, transformBlockTool } from '@/providers/utils' import type { SerializedBlock } from '@/serializer/types' import { filterSchemaForLLM, type ToolSchema, ToolSchemaEnrichmentError } from '@/tools/params' @@ -73,6 +94,24 @@ import { getToolAsync } from '@/tools/utils.server' const logger = createLogger('AgentBlockHandler') +interface IndexedToolInput { + tool: ToolInput + toolIndex: number +} + +interface FormattedAgentTools { + tools: ProviderToolConfig[] + inputProvenance: Map> + sourcePaths: ResolvedSecretInputPath[] +} + +class AgentToolInputSafetyError extends Error { + constructor(message: string) { + super(message) + this.name = 'AgentToolInputSafetyError' + } +} + function projectAgentDiagnosticMetadata( ctx: ExecutionContext, metadata: Record, @@ -160,10 +199,38 @@ export class AgentBlockHandler implements BlockHandler { const filteredTools = await this.filterUnavailableMcpTools(ctx, inputs.tools || []) const filteredInputs = { ...inputs, tools: filteredTools } + this.assertInputPathsDoNotResolveSecrets( + ctx, + this.getMessageStructuralInputPaths(filteredInputs), + 'Agent structural model inputs cannot contain secret references' + ) + const responseFormatProjection = this.projectResponseFormatForModel(ctx, filteredInputs) + const fileProjection = this.projectFileNamesForModel(ctx, filteredInputs) + const coreModelInputPaths = this.getModelInputPaths(filteredInputs) + const modelInputProjection = projectResolvedModelInput( + ctx.resolvedSecretTraceRegistry, + { + systemPrompt: filteredInputs.systemPrompt, + userPrompt: filteredInputs.userPrompt, + messages: filteredInputs.messages, + memories: filteredInputs.memories, + }, + coreModelInputPaths + ) + if (!modelInputProjection.complete) { + throw new Error('Agent model input could not be safely projected') + } + const modelInputs: AgentInputs = { + ...filteredInputs, + ...modelInputProjection.value, + responseFormat: responseFormatProjection.value, + } + const modelInputPaths = [...coreModelInputPaths, ...responseFormatProjection.inputPaths] + const projectedToolInputs = this.projectToolInputsForProvenance(ctx, inputs.tools || []) await this.validateToolPermissions(ctx, filteredInputs.tools || []) - const responseFormat = parseResponseFormat(filteredInputs.responseFormat) + const responseFormat = parseResponseFormat(modelInputs.responseFormat) const configuredModel = filteredInputs.model || AGENT.DEFAULT_MODEL let model = configuredModel @@ -172,7 +239,16 @@ export class AgentBlockHandler implements BlockHandler { autoRouting = await resolveAutoModel({ ctx, blockId: block.id, - signals: this.buildAutoRoutingSignals(filteredInputs, responseFormat), + signals: this.buildAutoRoutingSignals( + { + ...modelInputs, + systemPrompt: filteredInputs.systemPrompt ? modelInputs.systemPrompt : undefined, + userPrompt: filteredInputs.userPrompt + ? modelInputs.userPrompt + : filteredInputs.userPrompt, + }, + responseFormat + ), fallbackModel: AGENT.DEFAULT_MODEL, }) model = autoRouting.model @@ -197,7 +273,7 @@ export class AgentBlockHandler implements BlockHandler { // keeps pool models in English by default and off the topic of which // underlying model they are. Applied after signal building so the // preamble never influences classification. - filteredInputs.systemPrompt = [SIM_AUTO_SYSTEM_PREAMBLE, filteredInputs.systemPrompt] + modelInputs.systemPrompt = [SIM_AUTO_SYSTEM_PREAMBLE, modelInputs.systemPrompt] .filter(Boolean) .join('\n\n') } @@ -205,11 +281,12 @@ export class AgentBlockHandler implements BlockHandler { await validateModelProvider(ctx.userId, ctx.workspaceId, model, ctx) const providerId = getProviderFromModel(model) - const formattedTools = await this.formatTools( + const formatted = await this.formatTools( ctx, filteredInputs.tools || [], block.canonicalModes, - toolIndexByRef + toolIndexByRef, + projectedToolInputs ) const skillInputs = filteredInputs.skills ?? [] @@ -219,21 +296,26 @@ export class AgentBlockHandler implements BlockHandler { skillMetadata = await resolveSkillMetadata(skillInputs, ctx.workspaceId) if (skillMetadata.length > 0) { const skillNames = skillMetadata.map((s) => s.name) - formattedTools.push(buildLoadSkillTool(skillNames)) + formatted.tools.push(buildLoadSkillTool(skillNames)) } } const streamingConfig = this.getStreamingConfig(ctx, block) - const messages = await this.buildMessages(ctx, filteredInputs, skillMetadata) + const messages = await this.buildMessages(ctx, filteredInputs, modelInputs, skillMetadata) const messagesWithInputFiles = this.attachFilesToLastUserMessage( ctx, messages, - filteredInputs.files + filteredInputs.files, + fileProjection.projectedFiles, + fileProjection.projectedNameByFile, + fileProjection.directNameInputPaths ) const messagesWithFiles = await this.hydrateMessageFilesForProvider( ctx, messagesWithInputFiles, - providerId + providerId, + fileProjection.projectedNameByFile, + fileProjection.modelBoundInputPaths ) const providerRequest = this.buildProviderRequest({ @@ -241,13 +323,32 @@ export class AgentBlockHandler implements BlockHandler { providerId, model, messages: messagesWithFiles, - inputs: filteredInputs, - formattedTools, + inputs: modelInputs, + formattedTools: formatted.tools, responseFormat, streaming: streamingConfig.shouldUseStreaming ?? false, }) - const result = await this.executeProviderRequest(ctx, providerRequest, block, responseFormat) + const modelRuntimeRegistry = ctx.resolvedSecretTraceRegistry?.forkForInputPaths([ + ...modelInputPaths, + ...fileProjection.modelBoundInputPaths, + ...formatted.sourcePaths, + ]) + if (modelRuntimeRegistry) { + for (const [tool, provenance] of formatted.inputProvenance) { + registerProviderToolInputProvenance(tool, { + ...provenance, + registry: modelRuntimeRegistry, + }) + } + } + const result = await this.executeProviderRequest( + ctx, + providerRequest, + block, + responseFormat, + modelRuntimeRegistry + ) if (autoRouting && autoRouting.billableRoutingCost > 0) { this.applyRoutingCost(result, autoRouting.billableRoutingCost) @@ -492,24 +593,91 @@ export class AgentBlockHandler implements BlockHandler { * original position across the mcp-availability filter and the mcp/other split below, both of * which would otherwise renumber tools by their post-filter position. */ + private projectToolInputsForProvenance( + ctx: ExecutionContext, + inputTools: ToolInput[] + ): ToolInput[] | undefined { + const registry = ctx.resolvedSecretTraceRegistry + if (!registry?.hasResolvedInputProjections() || inputTools.length === 0) return undefined + + 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') + } + return projection.value.tools as ToolInput[] + } + private async formatTools( ctx: ExecutionContext, inputTools: ToolInput[], canonicalModes?: Record, - toolIndexByRef?: Map - ): Promise { - if (!Array.isArray(inputTools)) return [] + toolIndexByRef?: Map, + projectedToolInputs?: ToolInput[] + ): Promise { + if (!Array.isArray(inputTools)) { + return { tools: [], inputProvenance: new Map(), sourcePaths: [] } + } const filtered = inputTools .map((tool, localIndex) => ({ tool, toolIndex: toolIndexByRef?.get(tool) ?? localIndex })) .filter(({ tool }) => (tool.usageControl || 'auto') !== 'none') - const mcpTools: ToolInput[] = [] - const otherTools: Array<{ tool: ToolInput; toolIndex: number }> = [] + this.assertInputPathsDoNotResolveSecrets( + ctx, + filtered.flatMap(({ tool, toolIndex }) => { + const root = ['tools', String(toolIndex)] as const + const paths: ResolvedSecretInputPath[] = [[...root, 'type']] + if (tool.operation !== undefined) paths.push([...root, 'operation']) + if (tool.customToolId !== undefined) paths.push([...root, 'customToolId']) + if (tool.type === 'mcp') { + paths.push([...root, 'params', 'serverId'], [...root, 'params', 'toolName']) + } + if (tool.type === 'custom-tool' && !tool.customToolId) { + paths.push([...root, 'title'], [...root, 'schema', 'function', 'name']) + } + return paths + }), + 'Agent structural model inputs cannot contain secret references' + ) + + const mcpTools: IndexedToolInput[] = [] + const otherTools: IndexedToolInput[] = [] + const inputProvenance = new Map< + ProviderToolConfig, + Omit + >() + const sourcePaths: ResolvedSecretInputPath[] = [] + + const trackInputProvenance = ( + formattedTool: ProviderToolConfig | null, + entry: IndexedToolInput + ): ProviderToolConfig | null => { + if (!formattedTool) return null + const sourcePath = ['tools', String(entry.toolIndex), 'params'] + const sourceProvenance = + ctx.resolvedSecretTraceRegistry?.exportCommittedProvenanceForInputPaths([sourcePath]) + if (sourceProvenance && !sourceProvenance.complete) { + return null + } + if (!sourceProvenance || sourceProvenance.entries.length === 0) { + return formattedTool + } + const projectedInput = projectedToolInputs?.[entry.toolIndex] + inputProvenance.set(formattedTool, { + sourcePath, + projectedParams: this.getProjectedProviderToolParams( + entry.tool, + projectedInput, + formattedTool + ), + }) + sourcePaths.push(sourcePath) + return formattedTool + } for (const entry of filtered) { if (entry.tool.type === 'mcp') { - mcpTools.push(entry.tool) + mcpTools.push(entry) } else { otherTools.push(entry) } @@ -522,11 +690,31 @@ export class AgentBlockHandler implements BlockHandler { await validateBlockType(ctx.userId, ctx.workspaceId, tool.type, ctx) } if (tool.type === 'custom-tool' && (tool.schema || tool.customToolId)) { - return await this.createCustomTool(ctx, tool) + return trackInputProvenance( + await this.createCustomTool( + ctx, + tool, + projectedToolInputs?.[toolIndex], + toolIndex, + sourcePaths + ), + { + tool, + toolIndex, + } + ) } - return this.transformBlockTool(ctx, tool, canonicalModes, toolIndex) + return trackInputProvenance( + await this.transformBlockTool(ctx, tool, canonicalModes, toolIndex), + { tool, toolIndex } + ) } catch (error) { - if (error instanceof ToolSchemaEnrichmentError) throw error + if ( + error instanceof ToolSchemaEnrichmentError || + error instanceof AgentToolInputSafetyError + ) { + throw error + } logger.error( '[AgentHandler] Error creating tool', projectAgentDiagnosticMetadata( @@ -540,25 +728,92 @@ export class AgentBlockHandler implements BlockHandler { }) ) - const mcpResults = await this.processMcpToolsBatched(ctx, mcpTools) + const mcpResults = await this.processMcpToolsBatched( + ctx, + mcpTools, + trackInputProvenance, + projectedToolInputs, + sourcePaths + ) const allTools = [...otherResults, ...mcpResults] - return allTools.filter( - (tool): tool is NonNullable => tool !== null && tool !== undefined + const orderedSourcePaths = [...new Map(sourcePaths.map((path) => [JSON.stringify(path), path]))] + .sort(([left], [right]) => (left < right ? -1 : left > right ? 1 : 0)) + .map(([, path]) => path) + return { + tools: allTools.filter( + (tool): tool is ProviderToolConfig => tool !== null && tool !== undefined + ), + inputProvenance, + sourcePaths: orderedSourcePaths, + } + } + + private assertInputPathsDoNotResolveSecrets( + ctx: ExecutionContext, + inputPaths: readonly ResolvedSecretInputPath[], + errorMessage: string + ): void { + const registry = ctx.resolvedSecretTraceRegistry + if (!registry) return + + const provenance = registry.exportCommittedProvenanceForInputPaths(inputPaths) + if (!provenance.complete) { + throw new AgentToolInputSafetyError('Agent tool input could not be safely projected') + } + if (provenance.entries.length > 0) { + throw new AgentToolInputSafetyError(errorMessage) + } + } + + private getProjectedProviderToolParams( + tool: ToolInput, + projectedTool: ToolInput | undefined, + formattedTool: ProviderToolConfig + ): Record { + const projectedParams = projectedTool?.params ?? tool.params ?? {} + const formattedParams = formattedTool.params ?? {} + + if (isCustomBlockType(tool.type)) { + return { + ...formattedParams, + inputMapping: assembleCustomBlockInputMapping(projectedParams), + } + } + + const alignedParams = Object.fromEntries( + Object.keys(formattedParams).map((key) => [ + key, + Object.hasOwn(projectedParams, key) ? projectedParams[key] : formattedParams[key], + ]) ) + if (tool.type === 'mcp' || tool.type === 'custom-tool') return alignedParams + + const blockInputs = getAllBlocks().find((block) => block.type === tool.type)?.inputs + return prepareResolvedSecretProjectedInputs(alignedParams, blockInputs, formattedParams) } - private async createCustomTool(ctx: ExecutionContext, tool: ToolInput): Promise { + private async createCustomTool( + ctx: ExecutionContext, + tool: ToolInput, + projectedTool?: ToolInput, + toolIndex?: number, + modelInputPaths?: ResolvedSecretInputPath[] + ): Promise { const userProvidedParams = tool.params || {} let schema = tool.schema + let modelSchema = projectedTool?.schema ?? schema let title = tool.title + let usesInlineDefinition = true if (tool.customToolId) { const resolved = await this.fetchCustomToolById(ctx, tool.customToolId) if (resolved) { schema = resolved.schema + modelSchema = resolved.schema title = resolved.title + usesInlineDefinition = false } else if (!schema) { logger.error( 'Custom tool not found', @@ -572,6 +827,27 @@ export class AgentBlockHandler implements BlockHandler { } } + if (usesInlineDefinition && toolIndex !== undefined) { + const functionRoot = ['tools', String(toolIndex), 'schema', 'function'] as const + const schemaPaths = selectModelSchemaInputPaths(tool.schema?.function?.parameters, [ + ...functionRoot, + 'parameters', + ]) + this.assertInputPathsDoNotResolveSecrets( + ctx, + [ + ['tools', String(toolIndex), 'title'], + [...functionRoot, 'name'], + ...schemaPaths.semanticInputPaths, + ], + 'Agent structural model inputs cannot contain secret references' + ) + if (tool.schema?.function?.description !== undefined) { + modelInputPaths?.push([...functionRoot, 'description']) + } + modelInputPaths?.push(...schemaPaths.annotationInputPaths) + } + if (!schema?.function) { logger.error( 'Custom tool missing schema', @@ -583,18 +859,37 @@ export class AgentBlockHandler implements BlockHandler { ) return null } + if (!modelSchema?.function) { + throw new AgentToolInputSafetyError('Agent tool input could not be safely projected') + } + const parametersProjection = projectModelSchemaAnnotations( + schema.function.parameters, + modelSchema.function.parameters + ) + if (!parametersProjection.safe) { + throw new AgentToolInputSafetyError('Agent tool input could not be safely projected') + } + const rawDescription = schema.function.description + const projectedDescription = modelSchema.function.description + if ( + (rawDescription === undefined && projectedDescription !== undefined) || + (rawDescription !== undefined && projectedDescription === undefined) + ) { + throw new AgentToolInputSafetyError('Agent tool input could not be safely projected') + } - const filteredSchema = filterSchemaForLLM(schema.function.parameters, userProvidedParams) + const modelParameters = parametersProjection.value as ToolSchema + const filteredSchema = filterSchemaForLLM(modelParameters, userProvidedParams) const toolId = `${AGENT.CUSTOM_TOOL_PREFIX}${title}` const base: any = { id: toolId, name: schema.function.name, - description: schema.function.description || '', + description: projectedDescription || '', params: userProvidedParams, parameters: { ...filteredSchema, - type: schema.function.parameters.type, + type: modelParameters.type, }, usageControl: tool.usageControl || 'auto', } @@ -663,15 +958,22 @@ export class AgentBlockHandler implements BlockHandler { */ private async processMcpToolsBatched( ctx: ExecutionContext, - mcpTools: ToolInput[] - ): Promise { + mcpTools: IndexedToolInput[], + trackInputProvenance: ( + formattedTool: ProviderToolConfig | null, + entry: IndexedToolInput + ) => ProviderToolConfig | null, + projectedToolInputs?: ToolInput[], + modelInputPaths?: ResolvedSecretInputPath[] + ): Promise> { if (mcpTools.length === 0) return [] - const results: any[] = [] - const toolsWithSchema: ToolInput[] = [] - const toolsNeedingDiscovery: ToolInput[] = [] + const results: Array = [] + const toolsWithSchema: IndexedToolInput[] = [] + const toolsNeedingDiscovery: IndexedToolInput[] = [] - for (const tool of mcpTools) { + for (const entry of mcpTools) { + const { tool } = entry const serverId = tool.params?.serverId const toolName = tool.params?.toolName @@ -688,7 +990,7 @@ export class AgentBlockHandler implements BlockHandler { } if (tool.schema) { - toolsWithSchema.push(tool) + toolsWithSchema.push(entry) } else { logger.warn( 'MCP tool missing cached schema, will need discovery', @@ -698,15 +1000,23 @@ export class AgentBlockHandler implements BlockHandler { getToolDiagnosticFallback(tool) ) ) - toolsNeedingDiscovery.push(tool) + toolsNeedingDiscovery.push(entry) } } - for (const tool of toolsWithSchema) { + for (const entry of toolsWithSchema) { + const { tool } = entry try { - const created = await this.createMcpToolFromCachedSchema(ctx, tool) - if (created) results.push(created) + const created = await this.createMcpToolFromCachedSchema( + ctx, + tool, + projectedToolInputs?.[entry.toolIndex], + entry.toolIndex, + modelInputPaths + ) + if (created) results.push(trackInputProvenance(created, entry)) } catch (error) { + if (error instanceof AgentToolInputSafetyError) throw error logger.error( 'Error creating MCP tool from cached schema', projectAgentDiagnosticMetadata( @@ -719,7 +1029,11 @@ export class AgentBlockHandler implements BlockHandler { } if (toolsNeedingDiscovery.length > 0) { - const discoveredResults = await this.processMcpToolsWithDiscovery(ctx, toolsNeedingDiscovery) + const discoveredResults = await this.processMcpToolsWithDiscovery( + ctx, + toolsNeedingDiscovery, + trackInputProvenance + ) results.push(...discoveredResults) } @@ -731,15 +1045,52 @@ export class AgentBlockHandler implements BlockHandler { */ private async createMcpToolFromCachedSchema( ctx: ExecutionContext, - tool: ToolInput + tool: ToolInput, + projectedTool?: ToolInput, + toolIndex?: number, + modelInputPaths?: ResolvedSecretInputPath[] ): Promise { 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') + } + const schemaProjection = projectModelSchemaAnnotations(tool.schema, projectedSchema) + if (!schemaProjection.safe || !isPlainRecord(schemaProjection.value)) { + throw new AgentToolInputSafetyError('Agent tool input could not be safely projected') + } + 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') + } + const schema: McpToolSchema = { ...schemaProjection.value, type: 'object' } + const schemaDescription = + typeof schema.description === 'string' ? schema.description : undefined + if (toolIndex !== undefined) { + const schemaPaths = selectModelSchemaInputPaths(tool.schema, [ + 'tools', + String(toolIndex), + 'schema', + ]) + this.assertInputPathsDoNotResolveSecrets( + ctx, + schemaPaths.semanticInputPaths, + 'Agent structural model inputs cannot contain secret references' + ) + modelInputPaths?.push(...schemaPaths.annotationInputPaths) + if (!schemaDescription) { + modelInputPaths?.push(['tools', String(toolIndex), 'params', 'serverName']) + } + } return this.buildMcpTool({ serverId, toolName, description: - tool.schema?.description || `MCP tool ${toolName} from ${serverName || serverId}`, - schema: tool.schema || { type: 'object', properties: {} }, + schemaDescription || `MCP tool ${toolName} from ${projectedServerName || serverId}`, + schema, userProvidedParams, usageControl: tool.usageControl, }) @@ -750,15 +1101,20 @@ export class AgentBlockHandler implements BlockHandler { */ private async processMcpToolsWithDiscovery( ctx: ExecutionContext, - mcpTools: ToolInput[] - ): Promise { - const toolsByServer = new Map() - for (const tool of mcpTools) { + mcpTools: IndexedToolInput[], + trackInputProvenance: ( + formattedTool: ProviderToolConfig | null, + entry: IndexedToolInput + ) => ProviderToolConfig | null + ): Promise> { + const toolsByServer = new Map() + for (const entry of mcpTools) { + const { tool } = entry const serverId = tool.params?.serverId if (!toolsByServer.has(serverId)) { toolsByServer.set(serverId, []) } - toolsByServer.get(serverId)!.push(tool) + toolsByServer.get(serverId)!.push(entry) } const serverDiscoveryResults = await Promise.all( @@ -780,11 +1136,12 @@ export class AgentBlockHandler implements BlockHandler { }) ) - const results: any[] = [] + const results: Array = [] for (const { serverId, tools, discoveredTools, error } of serverDiscoveryResults) { if (error) continue - for (const tool of tools) { + for (const entry of tools) { + const { tool } = entry try { const toolName = tool.params?.toolName const mcpTool = discoveredTools.find((t: any) => t.name === toolName) @@ -802,7 +1159,7 @@ export class AgentBlockHandler implements BlockHandler { } const created = await this.createMcpToolFromDiscoveredData(ctx, tool, mcpTool, serverId) - if (created) results.push(created) + if (created) results.push(trackInputProvenance(created, entry)) } catch (error) { logger.error( 'Error creating MCP tool', @@ -919,7 +1276,7 @@ export class AgentBlockHandler implements BlockHandler { serverId: string toolName: string description: string - schema: ToolSchema + schema: McpToolSchema userProvidedParams: Record usageControl?: 'auto' | 'force' | 'none' }) { @@ -989,6 +1346,7 @@ export class AgentBlockHandler implements BlockHandler { private async buildMessages( ctx: ExecutionContext, inputs: AgentInputs, + modelInputs: AgentInputs, skillMetadata: Array<{ name: string; description: string }> = [] ): Promise { const messages: Message[] = [] @@ -996,8 +1354,10 @@ export class AgentBlockHandler implements BlockHandler { // 1. Extract and validate messages from messages-input subblock const inputMessages = this.extractValidMessages(inputs.messages) - const systemMessages = inputMessages.filter((m) => m.role === 'system') - const conversationMessages = inputMessages.filter((m) => m.role !== 'system') + const projectedInputMessages = this.extractValidMessages(modelInputs.messages) + const systemMessages = projectedInputMessages.filter((m) => m.role === 'system') + const conversationMessages = projectedInputMessages.filter((m) => m.role !== 'system') + const rawConversationMessages = inputMessages.filter((m) => m.role !== 'system') // 2. Handle native memory: seed on first run, then fetch and append new user input if (memoryEnabled && ctx.workspaceId) { @@ -1008,21 +1368,33 @@ export class AgentBlockHandler implements BlockHandler { const taggedMessages = conversationMessages.map((m) => m.role === 'user' ? { ...m, executionId: ctx.executionId } : m ) - await memoryService.seedMemory(ctx, inputs, taggedMessages) + const rawTaggedMessages = rawConversationMessages.map((m) => + m.role === 'user' ? { ...m, executionId: ctx.executionId } : m + ) + await memoryService.seedMemory(ctx, inputs, rawTaggedMessages) messages.push(...taggedMessages) } else { messages.push(...memoryMessages) if (hasExisting && conversationMessages.length > 0) { const latestUserFromInput = conversationMessages.filter((m) => m.role === 'user').pop() + const latestRawUserFromInput = rawConversationMessages + .filter((m) => m.role === 'user') + .pop() if (latestUserFromInput) { + if (!latestRawUserFromInput) { + throw new Error('Agent model input could not be safely projected') + } const userMessageInThisRun = memoryMessages.some( (m) => m.role === 'user' && m.executionId === ctx.executionId ) if (!userMessageInThisRun) { const taggedMessage = { ...latestUserFromInput, executionId: ctx.executionId } messages.push(taggedMessage) - await memoryService.appendToMemory(ctx, inputs, taggedMessage) + await memoryService.appendToMemory(ctx, inputs, { + ...latestRawUserFromInput, + executionId: ctx.executionId, + }) } } } @@ -1032,7 +1404,7 @@ export class AgentBlockHandler implements BlockHandler { // 3. Process legacy memories (backward compatibility - from Memory block) // These may include system messages which are preserved in their position if (inputs.memories) { - messages.push(...this.processMemories(inputs.memories)) + messages.push(...this.processMemories(modelInputs.memories)) } // 4. Add conversation messages from inputs.messages (if not using native memory) @@ -1046,19 +1418,22 @@ export class AgentBlockHandler implements BlockHandler { if (inputs.systemPrompt) { const hasSystem = systemMessages.length > 0 || messages.some((m) => m.role === 'system') if (!hasSystem) { - this.addSystemPrompt(messages, inputs.systemPrompt) + this.addSystemPrompt(messages, modelInputs.systemPrompt) } } // 6. Handle legacy userPrompt - this is NEW input each run if (inputs.userPrompt) { - this.addUserPrompt(messages, inputs.userPrompt) + this.addUserPrompt(messages, modelInputs.userPrompt) if (memoryEnabled) { const userMessages = messages.filter((m) => m.role === 'user') const lastUserMessage = userMessages[userMessages.length - 1] if (lastUserMessage) { - await memoryService.appendToMemory(ctx, inputs, lastUserMessage) + await memoryService.appendToMemory(ctx, inputs, { + ...lastUserMessage, + content: this.formatUserPrompt(inputs.userPrompt), + }) } } } @@ -1089,12 +1464,19 @@ export class AgentBlockHandler implements BlockHandler { private attachFilesToLastUserMessage( ctx: ExecutionContext, messages: Message[] | undefined, - filesInput: unknown + filesInput: unknown, + projectedFilesInput: unknown, + projectedNameByFile: WeakMap, + directNameInputPaths: readonly ResolvedSecretInputPath[] ): Message[] | undefined { const normalizedFiles = normalizeFileInput(filesInput) if (!normalizedFiles || normalizedFiles.length === 0) { return messages } + const projectedFiles = normalizeFileInput(projectedFilesInput) + if (!projectedFiles || projectedFiles.length !== normalizedFiles.length) { + throw new Error('Agent model input could not be safely projected') + } if (!messages || messages.length === 0) { throw new Error('Files require at least one user message in the agent prompt') @@ -1112,7 +1494,24 @@ export class AgentBlockHandler implements BlockHandler { } const requestId = ctx.executionId || ctx.workflowId || 'agent-files' - const userFiles = processFilesToUserFiles(normalizedFiles as RawFileInput[], requestId, logger) + const userFiles = normalizedFiles.flatMap((file, index) => { + const converted = processFilesToUserFiles([file] as RawFileInput[], requestId, logger) + const userFile = converted[0] + if (!userFile) return [] + + const projectedFile = projectedFiles[index] + if (!isPlainRecord(projectedFile) || typeof projectedFile.name !== 'string') { + throw new Error('Agent model input could not be safely projected') + } + const rawName = isPlainRecord(file) ? file.name : undefined + if (typeof rawName === 'string' && projectedFile.name !== rawName) { + projectedNameByFile.set(userFile, { + name: projectedFile.name, + inputPath: directNameInputPaths[index] ?? ['files', String(index), 'name'], + }) + } + return [userFile] + }) if (userFiles.length === 0) { throw new Error('Files must include at least one valid file object') } @@ -1130,7 +1529,9 @@ export class AgentBlockHandler implements BlockHandler { private async hydrateMessageFilesForProvider( ctx: ExecutionContext, messages: Message[] | undefined, - providerId: string + providerId: string, + projectedNameByFile: WeakMap, + modelBoundInputPaths: ResolvedSecretInputPath[] ): Promise { if (!messages?.some((message) => message.files?.length)) { return messages @@ -1151,6 +1552,7 @@ export class AgentBlockHandler implements BlockHandler { continue } + const unsafeGeneratedDocumentFiles = new Set() const hydratedFiles = await hydrateUserFilesWithBase64(message.files, { requestId, workspaceId: ctx.workspaceId, @@ -1163,9 +1565,60 @@ export class AgentBlockHandler implements BlockHandler { userId: ctx.userId, logger, maxBytes: inlineMaxBytes, + onServableFileContributors: async (file, contributors) => { + if (!ctx.workspaceId) return + for (const identity of contributors) { + const safe = await importWorkspaceFileSecretProvenanceForModelView({ + workspaceId: ctx.workspaceId, + identity, + registry: ctx.resolvedSecretTraceRegistry, + view: 'opaque', + }) + if (!safe) { + unsafeGeneratedDocumentFiles.add(`${file.key}:${file.id}`) + return + } + } + }, }) - const missingFile = hydratedFiles.find( + const modelSafeHydratedFiles = hydratedFiles.flatMap((file, fileIndex) => { + if (unsafeGeneratedDocumentFiles.has(`${file.key}:${file.id}`)) return [] + + const sourceFile = message.files?.[fileIndex] + const nameProjection = sourceFile ? projectedNameByFile.get(sourceFile) : undefined + if ( + !nameProjection || + !isProviderAttachmentFilenameModelBound(file, providerId, { + largeFilePathAvailable: canUseProviderLargeFilePath(providerId), + }) + ) { + return [file] + } + + modelBoundInputPaths.push(nameProjection.inputPath) + const extension = getFileExtension(file.name) + const suffix = extension ? `.${extension}` : '' + const keepsSuffix = + suffix !== '' && nameProjection.name.toLowerCase().endsWith(suffix.toLowerCase()) + return [ + { + ...file, + name: + suffix !== '' && !keepsSuffix + ? `${nameProjection.name}${suffix}` + : nameProjection.name, + }, + ] + }) + if (modelSafeHydratedFiles.length !== hydratedFiles.length) { + logger.warn('Omitting generated document attachments with unsafe contributor provenance', { + omittedCount: hydratedFiles.length - modelSafeHydratedFiles.length, + attachmentCount: hydratedFiles.length, + }) + } + + const missingFile = modelSafeHydratedFiles.find( (file) => !file.base64 && !(canUseProviderLargeFilePath(providerId) && shouldUseLargeFilePath(file, providerId)) @@ -1198,7 +1651,7 @@ export class AgentBlockHandler implements BlockHandler { nextMessages[messageIndex] = { ...message, - files: hydratedFiles, + files: modelSafeHydratedFiles, } } @@ -1293,16 +1746,322 @@ export class AgentBlockHandler implements BlockHandler { } private addUserPrompt(messages: Message[], userPrompt: any) { - let content: string + messages.push({ role: 'user', content: this.formatUserPrompt(userPrompt) }) + } + + private formatUserPrompt(userPrompt: any): string { if (typeof userPrompt === 'object' && userPrompt.input) { - content = String(userPrompt.input) - } else if (typeof userPrompt === 'object') { - content = JSON.stringify(userPrompt) - } else { - content = String(userPrompt) + return String(userPrompt.input) + } + return typeof userPrompt === 'object' ? JSON.stringify(userPrompt) : String(userPrompt) + } + + private projectResponseFormatForModel( + ctx: ExecutionContext, + inputs: AgentInputs + ): { value: AgentInputs['responseFormat']; inputPaths: ResolvedSecretInputPath[] } { + const responseFormat = inputs.responseFormat + if (responseFormat === undefined) return { value: undefined, inputPaths: [] } + + let annotationInputPaths: ResolvedSecretInputPath[] = [] + let structuralInputPaths: ResolvedSecretInputPath[] = [] + const isWrapper = + isPlainRecord(responseFormat) && + (Object.hasOwn(responseFormat, 'schema') || Object.hasOwn(responseFormat, 'name')) + + if (isPlainRecord(responseFormat)) { + const schema = isWrapper ? responseFormat.schema : responseFormat + const schemaRoot = isWrapper ? ['responseFormat', 'schema'] : ['responseFormat'] + const schemaPaths = selectModelSchemaInputPaths(schema, schemaRoot) + annotationInputPaths = schemaPaths.annotationInputPaths + structuralInputPaths = schemaPaths.semanticInputPaths + if (isWrapper) { + structuralInputPaths.push( + ...Object.keys(responseFormat) + .filter((key) => key !== 'schema') + .map((key) => ['responseFormat', key]) + ) + } } - messages.push({ role: 'user', content }) + this.assertInputPathsDoNotResolveSecrets( + ctx, + structuralInputPaths, + 'Agent structural model inputs cannot contain secret references' + ) + + const registry = ctx.resolvedSecretTraceRegistry + if (!registry) return { value: responseFormat, inputPaths: annotationInputPaths } + const projection = registry.projectResolvedInputSelection({ responseFormat }) + if (!projection.complete) { + throw new AgentToolInputSafetyError('Agent model input could not be safely projected') + } + const projectedResponseFormat = projection.value.responseFormat + + if (typeof responseFormat === 'string') { + if (Object.is(responseFormat, projectedResponseFormat)) { + return { value: responseFormat, inputPaths: annotationInputPaths } + } + if (typeof projectedResponseFormat !== 'string') { + throw new AgentToolInputSafetyError('Agent model input could not be safely projected') + } + try { + const rawParsed = JSON.parse(responseFormat) + const projectedParsed = JSON.parse(projectedResponseFormat) + if (!isPlainRecord(rawParsed)) { + throw new AgentToolInputSafetyError('Agent model input could not be safely projected') + } + this.projectResponseFormatObject(rawParsed, projectedParsed) + } catch (error) { + if (error instanceof AgentToolInputSafetyError) throw error + throw new AgentToolInputSafetyError('Agent model input could not be safely projected') + } + return { value: projectedResponseFormat, inputPaths: [['responseFormat']] } + } + + if (!isPlainRecord(responseFormat)) { + if (!Object.is(responseFormat, projectedResponseFormat)) { + throw new AgentToolInputSafetyError('Agent model input could not be safely projected') + } + return { value: responseFormat, inputPaths: annotationInputPaths } + } + return { + value: this.projectResponseFormatObject(responseFormat, projectedResponseFormat), + inputPaths: annotationInputPaths, + } + } + + private projectResponseFormatObject( + rawValue: Record, + projectedValue: unknown + ): Record { + if (!isPlainRecord(projectedValue)) { + throw new AgentToolInputSafetyError('Agent model input could not be safely projected') + } + 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') + } + return schemaProjection.value + } + + const rawKeys = Object.keys(rawValue) + if ( + rawKeys.length !== Object.keys(projectedValue).length || + rawKeys.some((key) => !Object.hasOwn(projectedValue, key)) + ) { + throw new AgentToolInputSafetyError('Agent model input could not be safely projected') + } + for (const key of rawKeys) { + if (key !== 'schema' && !Object.is(rawValue[key], projectedValue[key])) { + throw new AgentToolInputSafetyError('Agent model input could not be safely projected') + } + } + const schemaProjection = projectModelSchemaAnnotations(rawValue.schema, projectedValue.schema) + if (!schemaProjection.safe) { + throw new AgentToolInputSafetyError('Agent model input could not be safely projected') + } + return Object.hasOwn(rawValue, 'schema') + ? { ...rawValue, schema: schemaProjection.value } + : rawValue + } + + private getFileInputPaths( + inputs: AgentInputs, + field: 'base64' | 'name' + ): ResolvedSecretInputPath[] { + const paths = selectModelBoundFileInputPaths(inputs.files, ['files'], { + includeInlineBase64: true, + includeName: true, + }) + for (let messageIndex = 0; messageIndex < (inputs.messages?.length ?? 0); messageIndex++) { + paths.push( + ...selectModelBoundFileInputPaths( + inputs.messages?.[messageIndex]?.files, + ['messages', String(messageIndex), 'files'], + { includeInlineBase64: true, includeName: true } + ) + ) + } + return paths.filter((path) => path.at(-1) === field) + } + + private projectFileNamesForModel( + ctx: ExecutionContext, + inputs: AgentInputs + ): { + projectedFiles: unknown + projectedNameByFile: WeakMap + directNameInputPaths: ResolvedSecretInputPath[] + modelBoundInputPaths: ResolvedSecretInputPath[] + } { + const inputPaths = this.getFileInputPaths(inputs, 'name') + this.assertInputPathsDoNotResolveSecrets( + ctx, + this.getFileInputPaths(inputs, 'base64'), + 'Agent inline file content cannot contain secret references' + ) + const projection = projectResolvedModelInput( + ctx.resolvedSecretTraceRegistry, + { files: inputs.files, messages: inputs.messages }, + inputPaths + ) + if (!projection.complete) { + throw new AgentToolInputSafetyError('Agent model input could not be safely projected') + } + + let projectedFiles = projection.value.files + let directNameInputPaths: ResolvedSecretInputPath[] = Array.isArray(inputs.files) + ? inputs.files.map((_, index) => ['files', String(index), 'name']) + : isPlainRecord(inputs.files) + ? [['files', 'name']] + : [] + + if (typeof inputs.files === 'string' && ctx.resolvedSecretTraceRegistry) { + const serializedProjection = ctx.resolvedSecretTraceRegistry.projectResolvedInputSelection({ + files: inputs.files, + }) + if (!serializedProjection.complete) { + throw new AgentToolInputSafetyError('Agent model input could not be safely projected') + } + 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') + } + 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') + } + + 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') + } + if (!Object.is(rawFile.base64, projectedFile.base64)) { + throw new AgentToolInputSafetyError( + 'Agent inline file content cannot contain secret references' + ) + } + if (rawFile.name === undefined) return rawFile + if (typeof projectedFile.name !== 'string') { + throw new AgentToolInputSafetyError('Agent model input could not be safely projected') + } + return { ...rawFile, name: projectedFile.name } + }) + directNameInputPaths = rawFiles.map(() => ['files']) + } + } + + const projectedNameByFile = new WeakMap< + object, + { name: string; inputPath: ResolvedSecretInputPath } + >() + const projectedMessages = Array.isArray(projection.value.messages) + ? projection.value.messages + : [] + for (let messageIndex = 0; messageIndex < (inputs.messages?.length ?? 0); messageIndex++) { + const rawFiles = inputs.messages?.[messageIndex]?.files + const projectedMessage = projectedMessages[messageIndex] + 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') + } + for (let fileIndex = 0; fileIndex < rawFiles.length; fileIndex++) { + const rawFile = rawFiles[fileIndex] + const projectedFile = projectedFiles[fileIndex] + 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') + } + if (Object.is(rawFile.name, projectedFile.name)) continue + projectedNameByFile.set(rawFile, { + name: projectedFile.name, + inputPath: ['messages', String(messageIndex), 'files', String(fileIndex), 'name'], + }) + } + } + + return { + projectedFiles, + projectedNameByFile, + directNameInputPaths, + modelBoundInputPaths: [], + } + } + + private getMessageStructuralInputPaths(inputs: AgentInputs): ResolvedSecretInputPath[] { + const paths: ResolvedSecretInputPath[] = [] + for (let messageIndex = 0; messageIndex < (inputs.messages?.length ?? 0); messageIndex++) { + const message = inputs.messages?.[messageIndex] + if (!isPlainRecord(message)) continue + const messageRoot = ['messages', String(messageIndex)] as const + paths.push([...messageRoot, 'role']) + if (message.name !== undefined) paths.push([...messageRoot, 'name']) + if (message.tool_call_id !== undefined) paths.push([...messageRoot, 'tool_call_id']) + + if (isPlainRecord(message.function_call) && message.function_call.name !== undefined) { + paths.push([...messageRoot, 'function_call', 'name']) + } + if (!Array.isArray(message.tool_calls)) continue + for (let toolIndex = 0; toolIndex < message.tool_calls.length; toolIndex++) { + const toolCall = message.tool_calls[toolIndex] + if (!isPlainRecord(toolCall)) continue + const toolRoot = [...messageRoot, 'tool_calls', String(toolIndex)] + if (toolCall.id !== undefined) paths.push([...toolRoot, 'id']) + if (toolCall.type !== undefined) paths.push([...toolRoot, 'type']) + if (!isPlainRecord(toolCall.function)) continue + if (toolCall.function.name !== undefined) { + paths.push([...toolRoot, 'function', 'name']) + } + } + } + return paths + } + + private getModelInputPaths(inputs: AgentInputs): ResolvedSecretInputPath[] { + const paths: ResolvedSecretInputPath[] = [['systemPrompt'], ['userPrompt']] + for (let index = 0; index < (inputs.messages?.length ?? 0); index++) { + const message = inputs.messages?.[index] + const messageRoot = ['messages', String(index)] as const + paths.push([...messageRoot, 'content']) + if (isPlainRecord(message?.function_call) && message.function_call.arguments !== undefined) { + paths.push([...messageRoot, 'function_call', 'arguments']) + } + if (!Array.isArray(message?.tool_calls)) continue + for (let toolIndex = 0; toolIndex < message.tool_calls.length; toolIndex++) { + const toolCall = message.tool_calls[toolIndex] + if (!isPlainRecord(toolCall) || !isPlainRecord(toolCall.function)) continue + if (toolCall.function.arguments !== undefined) { + paths.push([...messageRoot, 'tool_calls', String(toolIndex), 'function', 'arguments']) + } + } + } + + const memories = inputs.memories + const memoryArray = Array.isArray(memories) + ? memories + : Array.isArray(memories?.memories) + ? memories.memories + : [] + const memoryRoot = Array.isArray(memories) ? ['memories'] : ['memories', 'memories'] + for (let memoryIndex = 0; memoryIndex < memoryArray.length; memoryIndex++) { + const memory = memoryArray[memoryIndex] + if (Array.isArray(memory?.data)) { + for (let messageIndex = 0; messageIndex < memory.data.length; messageIndex++) { + paths.push([...memoryRoot, String(memoryIndex), 'data', String(messageIndex), 'content']) + } + } else { + paths.push([...memoryRoot, String(memoryIndex), 'content']) + } + } + return paths } private buildProviderRequest(config: { @@ -1384,7 +2143,8 @@ export class AgentBlockHandler implements BlockHandler { ctx: ExecutionContext, providerRequest: any, block: SerializedBlock, - responseFormat: any + responseFormat: any, + modelRuntimeRegistry: ResolvedSecretTraceRegistry | undefined ): Promise { const providerId = providerRequest.provider const model = providerRequest.model @@ -1450,7 +2210,7 @@ export class AgentBlockHandler implements BlockHandler { abortSignal: ctx.abortSignal, }, { - resolvedSecretTraceRegistry: ctx.resolvedSecretTraceRegistry, + resolvedSecretTraceRegistry: modelRuntimeRegistry, } ) diff --git a/apps/sim/executor/handlers/agent/memory.test.ts b/apps/sim/executor/handlers/agent/memory.test.ts index d6a6e90c72c..1c127b925ce 100644 --- a/apps/sim/executor/handlers/agent/memory.test.ts +++ b/apps/sim/executor/handlers/agent/memory.test.ts @@ -266,20 +266,34 @@ describe('Memory', () => { expect(mockRedactObjectStrings).toHaveBeenCalledOnce() }) - it('does not write memory when projection fails closed', async () => { + it('persists raw memory with unknown lineage when provenance is unavailable', async () => { const registry = new ResolvedSecretTraceRegistry() registry.markIncomplete() const appendMessage = vi .spyOn(memoryService as any, 'appendMessage') .mockResolvedValue(undefined) - await expect( - memoryService.appendToMemory(createContext(registry) as never, inputs, { - role: 'user', - content: 'possibly secret', - }) - ).rejects.toThrow('Memory content could not be safely projected') - expect(appendMessage).not.toHaveBeenCalled() + const message = { role: 'user' as const, content: 'possibly secret' } + await memoryService.appendToMemory(createContext(registry) as never, inputs, message) + + expect(appendMessage).toHaveBeenCalledWith('workspace-1', 'conversation-1', message, { + status: 'unknown', + }) + }) + + it('seeds raw memory with unknown lineage when provenance is unavailable', async () => { + const registry = new ResolvedSecretTraceRegistry() + registry.markIncomplete() + const seedMemoryRecord = vi + .spyOn(memoryService as any, 'seedMemoryRecord') + .mockResolvedValue(undefined) + const message = { role: 'assistant' as const, content: 'possibly secret' } + + await memoryService.seedMemory(createContext(registry) as never, inputs, [message]) + + expect(seedMemoryRecord).toHaveBeenCalledWith('workspace-1', 'conversation-1', [message], { + status: 'unknown', + }) }) it('preserves legacy stored messages when no current resolution activated the value', async () => { @@ -351,7 +365,7 @@ describe('Memory', () => { } const projected = (memoryService as any).projectMessageForModel( - createContext(registry), + registry, message ) as Message @@ -387,9 +401,16 @@ describe('Memory', () => { messages: [message], provenance: { status: 'exact', - entries: [{ name: 'TOKEN', encryptedValue: 'ciphertext' }], + entries: [ + { + name: 'TOKEN', + encryptedValue: 'ciphertext', + sourceValueHash: hashDurableSecretProvenanceValue(message), + }, + ], }, }) + mockDecryptSecret.mockResolvedValue({ decrypted: secret }) const [fetched] = await memoryService.fetchMemoryMessages( createContext(registry) as never, inputs @@ -402,7 +423,7 @@ describe('Memory', () => { ) it.each(['name', 'functionName', 'toolCallId', 'toolName'] as const)( - 'rejects an active resolved secret in the %s control field', + 'does not plaintext-scan the %s control field', (field) => { const registry = new ResolvedSecretTraceRegistry([ { name: 'TOKEN', plaintext: 'control-secret', encryptedValue: 'ciphertext' }, @@ -428,12 +449,28 @@ describe('Memory', () => { ], } as Message - expect(() => - (memoryService as any).projectMessageForModel(createContext(registry), message) - ).toThrow('Memory content could not be safely projected') + expect((memoryService as any).projectMessageForModel(registry, message)).toEqual(message) } ) + it('does not project unrelated active secrets into legacy memory', async () => { + const registry = new ResolvedSecretTraceRegistry([ + { name: 'TOKEN', plaintext: 'x', encryptedValue: 'ciphertext' }, + ]) + registry.recordResolved('TOKEN', 'x') + vi.spyOn(memoryService as any, 'fetchMemory').mockResolvedValueOnce({ + messages: [{ role: 'assistant', content: 'Box' }], + provenance: { status: 'exact', entries: [] }, + }) + + const messages = await memoryService.fetchMemoryMessages( + createContext(registry) as never, + inputs + ) + + expect(messages).toEqual([{ role: 'assistant', content: 'Box' }]) + }) + it('does not activate provenance from a message dropped by the selected window', async () => { const oldSecretMessage: Message = { role: 'user', content: 'same-value' } const retainedPublicMessage: Message = { role: 'assistant', content: 'same-value' } diff --git a/apps/sim/executor/handlers/agent/memory.ts b/apps/sim/executor/handlers/agent/memory.ts index cd405c9e909..4007ae539a7 100644 --- a/apps/sim/executor/handlers/agent/memory.ts +++ b/apps/sim/executor/handlers/agent/memory.ts @@ -24,7 +24,7 @@ import { projectResolvedSecretModelContent, projectResolvedSecretModelJsonStrings, } from '@/executor/utils/resolved-secret-content-projection' -import type { ResolvedSecretTraceRegistry } from '@/executor/utils/resolved-secret-trace-registry' +import { ResolvedSecretTraceRegistry } from '@/executor/utils/resolved-secret-trace-registry' import { PROVIDER_DEFINITIONS } from '@/providers/models' const logger = createLogger('Memory') @@ -85,7 +85,21 @@ export class Memory { throw new Error('Memory content could not be safely projected') } - return messages.map((message) => this.projectMessageForModel(ctx, message)) + return Promise.all( + messages.map(async (message) => { + const messageProvenance = filterDurableSecretProvenanceBySourceValues(selectedProvenance, [ + message, + ]) + const modelRegistry = new ResolvedSecretTraceRegistry( + [], + ctx.resolvedSecretTraceRegistry?.exportProvenance().scope + ) + if (!(await importDurableSecretProvenance(modelRegistry, messageProvenance, message))) { + throw new Error('Memory content could not be safely projected') + } + return this.projectMessageForModel(modelRegistry, message) + }) + ) } private captureMessagesProvenance( @@ -122,9 +136,6 @@ export class Memory { const provenance = ctx.resolvedSecretTraceRegistry ? this.captureMessagesProvenance(ctx.resolvedSecretTraceRegistry, [message]) : undefined - if (provenance?.status === 'unknown') { - throw new Error('Memory content could not be safely projected') - } await this.appendMessage(workspaceId, key, message, provenance) @@ -172,9 +183,6 @@ export class Memory { const provenance = ctx.resolvedSecretTraceRegistry ? this.captureMessagesProvenance(ctx.resolvedSecretTraceRegistry, messagesToStore) : undefined - if (provenance?.status === 'unknown') { - throw new Error('Memory content could not be safely projected') - } await this.seedMemoryRecord(workspaceId, key, messagesToStore, provenance) logger.debug('Seeded memory', { @@ -204,23 +212,7 @@ export class Memory { } } - private projectMessageForModel(ctx: ExecutionContext, message: Message): Message { - const controlValues = this.readModelControlValues(message) - const controlProjection = projectResolvedSecretModelContent( - controlValues, - ctx.resolvedSecretTraceRegistry - ) - if ( - !controlProjection.safe || - !Array.isArray(controlProjection.value) || - controlProjection.value.length !== controlValues.length || - controlProjection.value.some( - (value, index) => typeof value !== 'string' || value !== controlValues[index] - ) - ) { - throw new Error('Memory content could not be safely projected') - } - + private projectMessageForModel(registry: ResolvedSecretTraceRegistry, message: Message): Message { const functionArguments = this.readFunctionCallArguments(message.function_call) const toolArguments = message.tool_calls?.map((toolCall) => { if (!isPlainRecord(toolCall)) { @@ -228,13 +220,10 @@ export class Memory { } return this.readFunctionCallArguments(toolCall.function) }) - const contentProjection = projectResolvedSecretModelContent( - message.content, - ctx.resolvedSecretTraceRegistry - ) + const contentProjection = projectResolvedSecretModelContent(message.content, registry) const argumentProjection = projectResolvedSecretModelJsonStrings( [functionArguments, ...(toolArguments ?? [])], - ctx.resolvedSecretTraceRegistry + registry ) if ( !contentProjection.safe || @@ -306,54 +295,6 @@ export class Memory { return functionCall.arguments } - private readModelControlValues(message: Message): string[] { - if (!isPlainRecord(message)) { - throw new Error('Memory content could not be safely projected') - } - const controls: string[] = [] - for (const key of ['name', 'tool_call_id'] as const) { - if (!(key in message)) continue - const value = message[key] - if (value !== undefined && value !== null) { - if (typeof value !== 'string') { - throw new Error('Memory content could not be safely projected') - } - controls.push(value) - } - } - - if (message.function_call !== undefined && message.function_call !== null) { - if (!isPlainRecord(message.function_call)) { - throw new Error('Memory content could not be safely projected') - } - const name = message.function_call.name - if (name !== undefined && name !== null) { - if (typeof name !== 'string') { - throw new Error('Memory content could not be safely projected') - } - controls.push(name) - } - } - - for (const toolCall of message.tool_calls ?? []) { - if (!isPlainRecord(toolCall)) { - throw new Error('Memory content could not be safely projected') - } - for (const value of [ - toolCall.id, - isPlainRecord(toolCall.function) ? toolCall.function.name : undefined, - ]) { - if (value !== undefined && value !== null) { - if (typeof value !== 'string') { - throw new Error('Memory content could not be safely projected') - } - controls.push(value) - } - } - } - return controls - } - private requireWorkspaceId(ctx: ExecutionContext): string { if (!ctx.workspaceId) { throw new Error('workspaceId is required for memory operations') diff --git a/apps/sim/executor/handlers/evaluator/evaluator-handler.test.ts b/apps/sim/executor/handlers/evaluator/evaluator-handler.test.ts index 1483c5d9fe0..10ac38d95cf 100644 --- a/apps/sim/executor/handlers/evaluator/evaluator-handler.test.ts +++ b/apps/sim/executor/handlers/evaluator/evaluator-handler.test.ts @@ -32,7 +32,11 @@ vi.mock('@/lib/model-router/resolve', () => ({ SIM_AUTO_SYSTEM_PREAMBLE: 'Sim auto system preamble', })) -import { PRIVATE_MODEL_INPUT_PROVENANCE_HEADER } from '@/lib/execution/model-input-provenance' +import { + PRIVATE_MODEL_INPUT_PROVENANCE_HEADER, + PRIVATE_MODEL_INPUT_STATE_HEADER, + PROJECTED_MODEL_INPUT_PATHS_V1, +} from '@/lib/execution/model-input-provenance' import { RESOLVED_SECRET_PROVENANCE_FIELD, RESOLVED_SECRET_PROVENANCE_METADATA_V1, @@ -223,8 +227,18 @@ describe('EvaluatorBlockHandler', () => { encryptedValue: 'encrypted-evaluator-credential', }, ]) - registry.recordResolved('CONTENT_SECRET', contentSecret) - registry.recordResolved('METRIC_SECRET', metricSecret) + registry.recordResolvedAtInputPath('CONTENT_SECRET', contentSecret, ['content']) + registry.recordResolvedInputProjection(['content'], contentSecret, '{{CONTENT_SECRET}}') + registry.recordResolvedAtInputPath('METRIC_SECRET', metricSecret, [ + 'metrics', + '0', + 'description', + ]) + registry.recordResolvedInputProjection( + ['metrics', '0', 'description'], + metricSecret, + '{{METRIC_SECRET}}' + ) registry.recordResolved('API_KEY', credentialSecret) mockContext.resolvedSecretTraceRegistry = registry @@ -246,6 +260,9 @@ describe('EvaluatorBlockHandler', () => { expect((request.headers as Headers).get(PRIVATE_MODEL_INPUT_PROVENANCE_HEADER)).toBe( RESOLVED_SECRET_PROVENANCE_METADATA_V1 ) + expect((request.headers as Headers).get(PRIVATE_MODEL_INPUT_STATE_HEADER)).toBe( + PROJECTED_MODEL_INPUT_PATHS_V1 + ) expect(requestBody[RESOLVED_SECRET_PROVENANCE_FIELD]).toEqual({ version: 1, complete: true, @@ -263,6 +280,96 @@ describe('EvaluatorBlockHandler', () => { expect(requestBody.apiKey).toBe(credentialSecret) }) + it('projects every model-bound metric leaf and maps the score back to the raw metric name', async () => { + const rawMetric = { + name: 'private-metric-key', + description: 'private metric instructions', + range: { min: 'private minimum', max: 'private maximum' }, + } + const projectedMetric = { + name: '{{METRIC_NAME_SECRET}}', + description: '{{METRIC_DESCRIPTION_SECRET}}', + range: { min: '{{METRIC_MIN_SECRET}}', max: '{{METRIC_MAX_SECRET}}' }, + } + const secrets = [ + { + name: 'METRIC_NAME_SECRET', + plaintext: rawMetric.name, + encryptedValue: 'encrypted-metric-name', + path: ['metrics', '0', 'name'], + projected: projectedMetric.name, + }, + { + name: 'METRIC_DESCRIPTION_SECRET', + plaintext: rawMetric.description, + encryptedValue: 'encrypted-metric-description', + path: ['metrics', '0', 'description'], + projected: projectedMetric.description, + }, + { + name: 'METRIC_MIN_SECRET', + plaintext: rawMetric.range.min, + encryptedValue: 'encrypted-metric-min', + path: ['metrics', '0', 'range', 'min'], + projected: projectedMetric.range.min, + }, + { + name: 'METRIC_MAX_SECRET', + plaintext: rawMetric.range.max, + encryptedValue: 'encrypted-metric-max', + path: ['metrics', '0', 'range', 'max'], + projected: projectedMetric.range.max, + }, + ] as const + const registry = new ResolvedSecretTraceRegistry([ + ...secrets.map(({ name, plaintext, encryptedValue }) => ({ + name, + plaintext, + encryptedValue, + })), + { name: 'UNUSED_SECRET', plaintext: 'x', encryptedValue: 'encrypted-unused' }, + ]) + for (const secret of secrets) { + registry.recordResolvedAtInputPath(secret.name, secret.plaintext, secret.path) + registry.recordResolvedInputProjection(secret.path, secret.plaintext, secret.projected) + } + mockContext.resolvedSecretTraceRegistry = registry + mockFetch.mockResolvedValueOnce({ + ok: true, + json: () => + Promise.resolve({ + content: JSON.stringify({ [projectedMetric.name.toLowerCase()]: 7 }), + model: 'mock-model', + tokens: {}, + cost: 0, + }), + }) + + const result = await handler.execute(mockContext, mockBlock, { + content: 'Public x remains public.', + metrics: [rawMetric], + model: 'gpt-4o', + apiKey: 'test-api-key', + }) + + const requestBody = JSON.parse(mockFetch.mock.calls[0][1].body) + const serializedRequest = JSON.stringify(requestBody) + for (const secret of secrets) { + expect(serializedRequest).not.toContain(secret.plaintext) + expect(requestBody.systemPrompt).toContain(secret.projected) + } + expect(requestBody.systemPrompt).toContain('Public x remains public.') + expect(requestBody.responseFormat.schema.properties).toEqual({ + [projectedMetric.name.toLowerCase()]: { type: 'number' }, + }) + expect( + requestBody[RESOLVED_SECRET_PROVENANCE_FIELD].entries + .map((entry: { name: string }) => entry.name) + .sort() + ).toEqual(secrets.map((secret) => secret.name).sort()) + expect(result).toMatchObject({ [rawMetric.name.toLowerCase()]: 7 }) + }) + it('keeps the evaluator request shape when no provenance registry exists', async () => { await handler.execute(mockContext, mockBlock, { content: 'Public evaluator content', @@ -275,6 +382,7 @@ describe('EvaluatorBlockHandler', () => { const requestBody = JSON.parse(request.body) expect(Object.hasOwn(requestBody, RESOLVED_SECRET_PROVENANCE_FIELD)).toBe(false) expect((request.headers as Headers).get(PRIVATE_MODEL_INPUT_PROVENANCE_HEADER)).toBeNull() + expect((request.headers as Headers).get(PRIVATE_MODEL_INPUT_STATE_HEADER)).toBeNull() }) it('resolves sim-auto before executing evaluator and preserves its public identity', async () => { diff --git a/apps/sim/executor/handlers/evaluator/evaluator-handler.ts b/apps/sim/executor/handlers/evaluator/evaluator-handler.ts index 8274bcccd9f..16ba1c8e831 100644 --- a/apps/sim/executor/handlers/evaluator/evaluator-handler.ts +++ b/apps/sim/executor/handlers/evaluator/evaluator-handler.ts @@ -2,6 +2,8 @@ import { createLogger } from '@sim/logger' import { addModelInputProvenanceToRequest, createModelInputProvenanceRequestMetadata, + markModelInputProjected, + projectResolvedModelInput, } from '@/lib/execution/model-input-provenance' import { type AutoRoutingResult, @@ -16,10 +18,12 @@ 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 type { ResolvedSecretTraceRegistry } from '@/executor/utils/resolved-secret-trace-registry' +import type { + ResolvedSecretInputPath, + ResolvedSecretTraceRegistry, +} from '@/executor/utils/resolved-secret-trace-registry' import { resolveVertexCredential } from '@/executor/utils/vertex-credential' import { resolveProxiedModelCost } from '@/providers/cost-policy' -import { collectProviderModelInputProvenanceValues } from '@/providers/model-input-provenance' import { isAutoModel, SIM_AUTO_MODEL_ID } from '@/providers/models' import type { ProviderRequest } from '@/providers/types' import { getProviderFromModel } from '@/providers/utils' @@ -51,8 +55,6 @@ export class EvaluatorBlockHandler implements BlockHandler { bedrockRegion: inputs.bedrockRegion, } - const processedContent = this.processContent(inputs.content) - let systemPromptObj: { systemPrompt: string; responseFormat: any } = { systemPrompt: '', responseFormat: null, @@ -64,15 +66,43 @@ export class EvaluatorBlockHandler implements BlockHandler { } else { metrics = [] } + const modelInputPaths: ResolvedSecretInputPath[] = [ + ['content'], + ...metrics.flatMap((_, index) => [ + ['metrics', String(index), 'name'], + ['metrics', String(index), 'description'], + ['metrics', String(index), 'range', 'min'], + ['metrics', String(index), 'range', 'max'], + ]), + ] + const modelInputProjection = projectResolvedModelInput( + ctx.resolvedSecretTraceRegistry, + { content: inputs.content, metrics: inputs.metrics }, + modelInputPaths + ) + if (!modelInputProjection.complete) { + throw new Error('Evaluator model input could not be safely projected') + } + const processedContent = this.processContent(modelInputProjection.value.content) + const projectedMetrics = Array.isArray(modelInputProjection.value.metrics) + ? modelInputProjection.value.metrics + : [] const metricDescriptions = metrics - .filter((m: any) => m?.name && m.range) - .map((m: any) => `"${m.name}" (${m.range.min}-${m.range.max}): ${m.description || ''}`) + .map((metric: any, index: number) => ({ metric, projected: projectedMetrics[index] })) + .filter(({ metric, projected }) => + Boolean(metric?.name && metric.range && projected?.name && projected.range) + ) + .map( + ({ projected }) => + `"${projected.name}" (${projected.range.min}-${projected.range.max}): ${projected.description || ''}` + ) .join('\n') const responseProperties: Record = {} metrics.forEach((m: any, metricIndex: number) => { - if (m?.name) { - responseProperties[m.name.toLowerCase()] = { type: 'number' } + const projectedMetric = projectedMetrics[metricIndex] + if (m?.name && projectedMetric?.name) { + responseProperties[projectedMetric.name.toLowerCase()] = { type: 'number' } } else { logger.warn('Skipping invalid metric entry during response format generation', { metricIndex, @@ -96,7 +126,10 @@ export class EvaluatorBlockHandler implements BlockHandler { schema: { type: 'object', properties: responseProperties, - required: metrics.filter((m: any) => m?.name).map((m: any) => m.name.toLowerCase()), + required: metrics.flatMap((m: any, metricIndex: number) => { + const projectedName = projectedMetrics[metricIndex]?.name + return m?.name && projectedName ? [projectedName.toLowerCase()] : [] + }), additionalProperties: false, }, strict: true, @@ -181,14 +214,16 @@ export class EvaluatorBlockHandler implements BlockHandler { } const headers = new Headers(await buildAuthHeaders(ctx.userId)) + const modelInputMetadata = createModelInputProvenanceRequestMetadata( + modelInputProjection.registry, + modelInputPaths + ) const requestBody = addModelInputProvenanceToRequest( { provider: providerId, ...providerRequest }, headers, - createModelInputProvenanceRequestMetadata( - ctx.resolvedSecretTraceRegistry, - collectProviderModelInputProvenanceValues(providerRequest, providerId) - ) + modelInputMetadata ) + if (modelInputMetadata) markModelInputProjected(headers) const response = await fetch(url.toString(), { method: 'POST', headers, @@ -207,7 +242,7 @@ export class EvaluatorBlockHandler implements BlockHandler { ctx.resolvedSecretTraceRegistry ) - const metricScores = this.extractMetricScores(parsedContent, inputs.metrics) + const metricScores = this.extractMetricScores(parsedContent, metrics, projectedMetrics) const inputTokens = result.tokens?.input || result.tokens?.prompt || DEFAULTS.TOKENS.PROMPT const outputTokens = @@ -297,7 +332,8 @@ export class EvaluatorBlockHandler implements BlockHandler { private extractMetricScores( parsedContent: Record, - metrics: any + metrics: any, + projectedMetrics: any ): Record { const metricScores: Record = {} let validMetrics: any[] @@ -316,6 +352,7 @@ export class EvaluatorBlockHandler implements BlockHandler { return metricScores } + const validProjectedMetrics = Array.isArray(projectedMetrics) ? projectedMetrics : [] validMetrics.forEach((metric: any, metricIndex: number) => { if (!metric?.name) { logger.warn('Skipping invalid metric entry', { @@ -325,7 +362,11 @@ export class EvaluatorBlockHandler implements BlockHandler { return } - const score = this.findMetricScore(parsedContent, metric.name) + const projectedName = validProjectedMetrics[metricIndex]?.name + const score = this.findMetricScore( + parsedContent, + typeof projectedName === 'string' && projectedName ? projectedName : metric.name + ) metricScores[metric.name.toLowerCase()] = score }) diff --git a/apps/sim/executor/handlers/function/function-handler.test.ts b/apps/sim/executor/handlers/function/function-handler.test.ts index 2c3a4cc93a5..c914d2f05f9 100644 --- a/apps/sim/executor/handlers/function/function-handler.test.ts +++ b/apps/sim/executor/handlers/function/function-handler.test.ts @@ -200,6 +200,49 @@ describe('FunctionBlockHandler', () => { } }) + it('forwards an explicit selected secret scope without changing legacy unset blocks', async () => { + await handler.execute(mockContext, mockBlock, { + code: 'return {{API_KEY}}', + secretScope: 'selected', + mountedSecrets: [' API_KEY ', 42, 'SECOND_KEY', '', 'API_KEY'], + }) + + expect(mockExecuteTool).toHaveBeenCalledWith( + 'function_execute', + expect.objectContaining({ + secretScope: 'selected', + mountedSecrets: ['API_KEY', 'SECOND_KEY'], + }), + { executionContext: mockContext } + ) + + vi.clearAllMocks() + mockExecuteTool.mockResolvedValue({ success: true, output: { result: 'Success' } }) + + await handler.execute(mockContext, mockBlock, { code: 'return {{API_KEY}}' }) + + const legacyParams = mockExecuteTool.mock.calls[0][1] + expect(legacyParams).not.toHaveProperty('secretScope') + expect(legacyParams).not.toHaveProperty('mountedSecrets') + }) + + it('fails closed for an invalid explicit secret scope', async () => { + await handler.execute(mockContext, mockBlock, { + code: 'return {{API_KEY}}', + secretScope: 'invalid', + mountedSecrets: ['API_KEY'], + }) + + expect(mockExecuteTool).toHaveBeenCalledWith( + 'function_execute', + expect.objectContaining({ + secretScope: 'selected', + mountedSecrets: [], + }), + { executionContext: mockContext } + ) + }) + it('should handle execution errors from the tool', async () => { const inputs = { code: 'throw new Error("Code failed");' } const errorResult = { success: false, error: 'Function execution failed: Code failed' } diff --git a/apps/sim/executor/handlers/function/function-handler.ts b/apps/sim/executor/handlers/function/function-handler.ts index 2f636bdfea6..71159c09107 100644 --- a/apps/sim/executor/handlers/function/function-handler.ts +++ b/apps/sim/executor/handlers/function/function-handler.ts @@ -1,3 +1,4 @@ +import { normalizeSecretMountPolicy } from '@/lib/copilot/secret-mount-policy' import { getRemainingExecutionMs } from '@/lib/core/execution-limits' import { normalizeRecord, @@ -68,6 +69,13 @@ export class FunctionBlockHandler implements BlockHandler { ? remainingExecutionMs : Math.min(requestedTimeout, remainingExecutionMs) ) + const secretMountPolicy = + inputs.secretScope === undefined + ? undefined + : normalizeSecretMountPolicy({ + secretScope: inputs.secretScope, + mountedSecrets: inputs.mountedSecrets, + }) const toolParams = { code: codeContent, @@ -75,6 +83,7 @@ export class FunctionBlockHandler implements BlockHandler { language: inputs.language || DEFAULT_CODE_LANGUAGE, timeout, ...(inputs.sandboxId ? { sandboxId: inputs.sandboxId } : {}), + ...(secretMountPolicy ?? {}), envVars: normalizeStringRecord(ctx.environmentVariables), workflowVariables: normalizeWorkflowVariables(ctx.workflowVariables), blockData: {}, diff --git a/apps/sim/executor/handlers/generic/generic-handler.test.ts b/apps/sim/executor/handlers/generic/generic-handler.test.ts index be3baec9aed..3efb074ecca 100644 --- a/apps/sim/executor/handlers/generic/generic-handler.test.ts +++ b/apps/sim/executor/handlers/generic/generic-handler.test.ts @@ -1,14 +1,19 @@ import '@sim/testing/mocks/executor' import { beforeEach, describe, expect, it, type Mock, vi } from 'vitest' +import { KnowledgeBlock } from '@/blocks/blocks/knowledge' +import { getBlock } from '@/blocks/index' import { BlockType } from '@/executor/constants' import { GenericBlockHandler } from '@/executor/handlers/generic/generic-handler' import type { ExecutionContext } from '@/executor/types' +import { ResolvedSecretTraceRegistry } from '@/executor/utils/resolved-secret-trace-registry' import type { SerializedBlock } from '@/serializer/types' import { executeTool } from '@/tools' +import { selectKnowledgeDocumentWriteSecretProvenance } from '@/tools/knowledge/secret-provenance' import type { ToolConfig } from '@/tools/types' import { getTool } from '@/tools/utils' +const mockGetBlock = vi.mocked(getBlock) const mockGetTool = vi.mocked(getTool) const mockExecuteTool = executeTool as Mock @@ -60,6 +65,7 @@ describe('GenericBlockHandler', () => { // Reset mocks using vi vi.clearAllMocks() + mockGetBlock.mockReturnValue(undefined) // Set up mockGetTool to return mockTool mockGetTool.mockImplementation((toolId) => { @@ -98,6 +104,392 @@ describe('GenericBlockHandler', () => { expect(result).toEqual(expectedOutput) }) + it('preserves exact secret provenance when block params rename a selected input', async () => { + mockTool.request.modelInput = { + mode: 'private-provenance', + inputPaths: () => [['filePath']], + } + mockGetBlock.mockReturnValue({ + tools: { + access: ['some_custom_tool'], + config: { + tool: () => 'some_custom_tool', + params: (params: Record) => ({ + filePath: String(params.document).trim(), + }), + }, + }, + inputs: { + document: { type: 'string', description: 'Document URL' }, + }, + } as never) + const registry = new ResolvedSecretTraceRegistry([ + { + name: 'FILE_SECRET', + plaintext: 'secret-url', + encryptedValue: 'encrypted-file-secret', + }, + ]) + registry.recordResolvedAtInputPath('FILE_SECRET', 'secret-url', ['document']) + registry.recordResolvedInputProjection(['document'], ' secret-url ', ' {{FILE_SECRET}} ') + mockContext.resolvedSecretTraceRegistry = registry + + await handler.execute(mockContext, mockBlock, { document: ' secret-url ' }) + + expect(mockExecuteTool).toHaveBeenCalledWith( + 'some_custom_tool', + expect.objectContaining({ + document: ' secret-url ', + filePath: 'secret-url', + }), + { executionContext: mockContext } + ) + expect(registry.exportCommittedProvenanceForInputPaths([['filePath']])).toMatchObject({ + complete: true, + entries: [{ name: 'FILE_SECRET', encryptedValue: 'encrypted-file-secret' }], + }) + }) + + it('traces each secret path without letting secret-valued controls change another path', async () => { + mockTool.request.modelInput = { + mode: 'private-provenance', + inputPaths: () => [['input']], + } + mockGetBlock.mockReturnValue({ + tools: { + access: ['some_custom_tool'], + config: { + tool: () => 'some_custom_tool', + params: (params: Record) => + params.operation === 'deep_research' ? { input: params.research_input } : {}, + }, + }, + inputs: { + operation: { type: 'string', description: 'Operation' }, + research_input: { type: 'string', description: 'Research input' }, + }, + } as never) + const registry = new ResolvedSecretTraceRegistry([ + { + name: 'OPERATION', + plaintext: 'deep_research', + encryptedValue: 'encrypted-operation', + }, + { name: 'QUERY', plaintext: 'secret query', encryptedValue: 'encrypted-query' }, + ]) + registry.recordResolvedAtInputPath('OPERATION', 'deep_research', ['operation']) + registry.recordResolvedInputProjection(['operation'], 'deep_research', '{{OPERATION}}') + registry.recordResolvedAtInputPath('QUERY', 'secret query', ['research_input']) + registry.recordResolvedInputProjection(['research_input'], 'secret query', '{{QUERY}}') + mockContext.resolvedSecretTraceRegistry = registry + + await handler.execute(mockContext, mockBlock, { + operation: 'deep_research', + research_input: 'secret query', + }) + + expect(mockExecuteTool).toHaveBeenCalledWith( + 'some_custom_tool', + expect.objectContaining({ + operation: 'deep_research', + research_input: 'secret query', + input: 'secret query', + }), + { executionContext: mockContext } + ) + expect(registry.exportCommittedProvenanceForInputPaths([['input']])).toMatchObject({ + complete: true, + entries: [{ name: 'QUERY', encryptedValue: 'encrypted-query' }], + }) + }) + + it('preserves exact table leaf provenance across legacy unquoted JSON placeholders', async () => { + mockTool.request.secretProvenance = { + request: () => [{ key: 'data', inputPaths: [['data']] }], + } + mockGetBlock.mockReturnValue({ + tools: { + access: ['some_custom_tool'], + config: { + tool: () => 'some_custom_tool', + params: (params: Record) => ({ + data: typeof params.data === 'string' ? JSON.parse(params.data) : params.data, + }), + }, + }, + inputs: { + data: { type: 'json', description: 'Row data' }, + }, + } as never) + const registry = new ResolvedSecretTraceRegistry([ + { name: '1BOOLEAN_SECRET', plaintext: 'true', encryptedValue: 'encrypted-boolean' }, + { name: 'UNUSED', plaintext: 'true', encryptedValue: 'encrypted-unused' }, + ]) + registry.recordResolvedAtInputPath('1BOOLEAN_SECRET', 'true', ['data']) + registry.recordResolvedInputProjection( + ['data'], + '{"secret":true,"public":true}', + '{"secret":{{1BOOLEAN_SECRET}},"public":true}' + ) + mockContext.resolvedSecretTraceRegistry = registry + + await handler.execute(mockContext, mockBlock, { + data: '{"secret":true,"public":true}', + }) + + expect(mockExecuteTool).toHaveBeenCalledWith( + 'some_custom_tool', + expect.objectContaining({ data: { secret: true, public: true } }), + { executionContext: mockContext } + ) + expect(registry.exportCommittedProvenanceForInputPaths([['data', 'secret']])).toMatchObject({ + complete: true, + entries: [{ name: '1BOOLEAN_SECRET', encryptedValue: 'encrypted-boolean' }], + }) + expect(registry.exportCommittedProvenanceForInputPaths([['data', 'public']])).toMatchObject({ + complete: true, + entries: [], + }) + }) + + it('normalizes legacy JSON-string knowledge tags without mutating inputs or overbinding tag names', async () => { + mockTool.request.secretProvenance = { + request: selectKnowledgeDocumentWriteSecretProvenance, + } + mockGetBlock.mockReturnValue(KnowledgeBlock) + mockBlock.metadata = { id: 'knowledge', name: 'Knowledge' } + const documentTags = '[{"tagName":"team","value":"support"}]' + const projectedTags = '[{"tagName":"team","value":"{{TAG_VALUE}}"}]' + const inputs = { + operation: 'create_document', + knowledgeBaseId: 'kb-1', + name: 'doc.md', + content: 'content', + documentTags, + } + const registry = new ResolvedSecretTraceRegistry([ + { name: 'TAG_VALUE', plaintext: 'support', encryptedValue: 'encrypted-tag' }, + ]) + registry.recordResolvedAtInputPath('TAG_VALUE', 'support', ['documentTags']) + registry.recordResolvedInputProjection(['documentTags'], documentTags, projectedTags) + mockContext.resolvedSecretTraceRegistry = registry + + await handler.execute(mockContext, mockBlock, inputs) + + expect(inputs.documentTags).toBe(documentTags) + expect(mockExecuteTool).toHaveBeenCalledWith( + 'some_custom_tool', + expect.objectContaining({ + documentTags: [{ tagName: 'team', value: 'support' }], + }), + { executionContext: mockContext } + ) + expect( + registry.exportCommittedProvenanceForInputPaths([['documentTags', '0', 'tagName']]) + ).toMatchObject({ complete: true, entries: [] }) + expect( + registry.exportCommittedProvenanceForInputPaths([['documentTags', '0', 'value']]) + ).toMatchObject({ + complete: true, + entries: [{ name: 'TAG_VALUE', encryptedValue: 'encrypted-tag' }], + }) + }) + + it('preserves a whole structured secret without changing the raw parsed value', async () => { + mockTool.request.secretProvenance = { + request: () => [{ key: 'data', inputPaths: [['data']] }], + } + mockGetBlock.mockReturnValue({ + tools: { + access: ['some_custom_tool'], + config: { + tool: () => 'some_custom_tool', + params: (params: Record) => ({ + data: typeof params.data === 'string' ? JSON.parse(params.data) : params.data, + }), + }, + }, + inputs: { + data: { type: 'json', description: 'Row data' }, + }, + } as never) + const rawStructuredSecret = '{"nested":"value","url":"https://example.com/data","count":1}' + const registry = new ResolvedSecretTraceRegistry([ + { + name: 'JSON_SECRET', + plaintext: rawStructuredSecret, + encryptedValue: 'encrypted-json', + }, + ]) + registry.recordResolvedAtInputPath('JSON_SECRET', rawStructuredSecret, ['data']) + registry.recordResolvedInputProjection(['data'], rawStructuredSecret, '{{JSON_SECRET}}') + mockContext.resolvedSecretTraceRegistry = registry + + await handler.execute(mockContext, mockBlock, { data: rawStructuredSecret }) + + expect(mockExecuteTool).toHaveBeenCalledWith( + 'some_custom_tool', + expect.objectContaining({ + data: { nested: 'value', url: 'https://example.com/data', count: 1 }, + }), + { executionContext: mockContext } + ) + expect(registry.exportCommittedProvenanceForInputPaths([['data', 'nested']])).toMatchObject({ + complete: true, + entries: [{ name: 'JSON_SECRET', encryptedValue: 'encrypted-json' }], + }) + expect(registry.exportCommittedProvenanceForInputPaths([['data', 'count']])).toMatchObject({ + complete: true, + entries: [{ name: 'JSON_SECRET', encryptedValue: 'encrypted-json' }], + }) + expect(registry.exportCommittedProvenanceForInputPaths([['data', 'url']])).toMatchObject({ + complete: true, + entries: [{ name: 'JSON_SECRET', encryptedValue: 'encrypted-json' }], + }) + }) + + it('preserves structured message roles while projecting only model-visible content', async () => { + const parseMessages = (value: unknown) => { + const parsed = typeof value === 'string' ? JSON.parse(value) : value + if (!Array.isArray(parsed)) throw new Error('Messages must be an array') + return parsed.map((message) => { + if ( + !message || + typeof message !== 'object' || + !['user', 'assistant', 'system'].includes(String(message.role)) + ) { + throw new Error('Invalid message role') + } + return { role: String(message.role), content: String(message.content) } + }) + } + mockTool.request.modelInput = { + mode: 'project', + select: (params) => ({ + messages: parseMessages(params.messages).map((message) => message.content), + }), + applyProjected: (selectedParams, projectedSelection) => ({ + messages: parseMessages(selectedParams.messages).map((message, index) => ({ + ...message, + content: (projectedSelection.messages as unknown[])[index], + })), + }), + } + mockGetBlock.mockReturnValue({ + tools: { + access: ['some_custom_tool'], + config: { + tool: () => 'some_custom_tool', + params: (params: Record) => ({ + messages: parseMessages(params.messages), + }), + }, + }, + inputs: { + messages: { type: 'json', description: 'Messages' }, + }, + } as never) + const rawMessages = '[{"role":"user","content":"hello"}]' + const registry = new ResolvedSecretTraceRegistry([ + { name: 'MESSAGES', plaintext: rawMessages, encryptedValue: 'encrypted-messages' }, + ]) + registry.recordResolvedAtInputPath('MESSAGES', rawMessages, ['messages']) + registry.recordResolvedInputProjection(['messages'], rawMessages, '{{MESSAGES}}') + mockContext.resolvedSecretTraceRegistry = registry + + await handler.execute(mockContext, mockBlock, { messages: rawMessages }) + + expect(mockExecuteTool).toHaveBeenCalledWith( + 'some_custom_tool', + expect.objectContaining({ messages: [{ role: 'user', content: 'hello' }] }), + { executionContext: mockContext } + ) + expect( + registry.exportCommittedProvenanceForInputPaths([['messages', '0', 'role']]) + ).toMatchObject({ complete: true, entries: [] }) + expect( + registry.exportCommittedProvenanceForInputPaths([['messages', '0', 'content']]) + ).toMatchObject({ + complete: true, + entries: [{ name: 'MESSAGES', encryptedValue: 'encrypted-messages' }], + }) + }) + + it('preserves raw file execution while binding whole serialized descriptors to the file boundary', async () => { + mockTool.params.audioFile = { type: 'file' } + mockTool.params.audioUrl = { type: 'string' } + mockTool.request.modelInput = { + mode: 'private-provenance', + inputPaths: () => [['audioUrl']], + } + mockGetBlock.mockReturnValue({ + tools: { + access: ['some_custom_tool'], + config: { + tool: () => 'some_custom_tool', + params: (params: Record) => { + const file = + typeof params.audioFile === 'string' ? JSON.parse(params.audioFile) : params.audioFile + if (!file || typeof file !== 'object' || !String(file.url).startsWith('https://')) { + throw new Error('A valid HTTPS audio file is required') + } + return { audioUrl: String(file.url), audioFile: undefined } + }, + }, + }, + inputs: { + audioFile: { type: 'json', description: 'Audio file' }, + }, + } as never) + const rawFile = '{"name":"audio.mp3","size":4,"url":"https://files.example/audio.mp3"}' + const registry = new ResolvedSecretTraceRegistry([ + { name: 'FILE', plaintext: rawFile, encryptedValue: 'encrypted-file' }, + ]) + registry.recordResolvedAtInputPath('FILE', rawFile, ['audioFile']) + registry.recordResolvedInputProjection(['audioFile'], rawFile, '{{FILE}}') + mockContext.resolvedSecretTraceRegistry = registry + + await handler.execute(mockContext, mockBlock, { audioFile: rawFile }) + + expect(mockExecuteTool).toHaveBeenCalledWith( + 'some_custom_tool', + expect.objectContaining({ + audioFile: undefined, + audioUrl: 'https://files.example/audio.mp3', + }), + { executionContext: mockContext } + ) + expect(registry.isComplete()).toBe(true) + expect(registry.exportCommittedProvenanceForInputPaths([['audioUrl']])).toMatchObject({ + complete: true, + entries: [{ name: 'FILE', encryptedValue: 'encrypted-file' }], + }) + }) + + it('does not replay block transforms for configured but unused secrets', async () => { + mockTool.request.modelInput = { + mode: 'project', + select: (params) => ({ param1: params.param1 }), + } + const transform = vi.fn((params: Record) => params) + mockGetBlock.mockReturnValue({ + tools: { + access: ['some_custom_tool'], + config: { tool: () => 'some_custom_tool', params: transform }, + }, + inputs: { + param1: { type: 'string', description: 'Value' }, + }, + } as never) + mockContext.resolvedSecretTraceRegistry = new ResolvedSecretTraceRegistry([ + { name: 'UNUSED', plaintext: 'value1', encryptedValue: 'encrypted-unused' }, + ]) + + await handler.execute(mockContext, mockBlock, { param1: 'value1' }) + + expect(transform).toHaveBeenCalledTimes(1) + }) + it('should throw error if the associated tool is not found', async () => { const inputs = { param1: 'value' } diff --git a/apps/sim/executor/handlers/generic/generic-handler.ts b/apps/sim/executor/handlers/generic/generic-handler.ts index 8336bbb2ed3..e3d029637cc 100644 --- a/apps/sim/executor/handlers/generic/generic-handler.ts +++ b/apps/sim/executor/handlers/generic/generic-handler.ts @@ -1,15 +1,154 @@ +import { isDeepStrictEqual } from 'node:util' import { createLogger } from '@sim/logger' import { toError } from '@sim/utils/errors' +import { isPlainRecord } from '@sim/utils/object' import { getBlock } from '@/blocks/index' import { isMcpTool } from '@/executor/constants' import type { BlockHandler, ExecutionContext } from '@/executor/types' import { readStatusCode } from '@/executor/utils/errors' +import { prepareResolvedSecretProjectedInputs } from '@/executor/utils/resolved-secret-input-projection' +import type { ResolvedSecretInputPath } from '@/executor/utils/resolved-secret-trace-registry' import type { SerializedBlock } from '@/serializer/types' import { executeTool } from '@/tools' +import type { ToolConfig } from '@/tools/types' import { getTool } from '@/tools/utils' const logger = createLogger('GenericBlockHandler') +interface BlockBoundaryPaths { + paths: ResolvedSecretInputPath[] + requiredProjectionRoots: Set +} + +function selectBlockBoundaryPaths( + tool: ToolConfig, + params: Record +): BlockBoundaryPaths | undefined { + try { + const paths: ResolvedSecretInputPath[] = [] + const requiredProjectionRoots = new Set() + const modelInput = tool.request.modelInput + if (modelInput?.mode === 'project') { + const selected = modelInput.select(params) + if (!isPlainRecord(selected)) return undefined + for (const key of Object.keys(selected)) { + requiredProjectionRoots.add(key) + paths.push([key]) + } + const privateInputPaths = modelInput.privateInputPaths?.(params) ?? [] + paths.push(...privateInputPaths) + for (const path of privateInputPaths) { + if (path[0]) requiredProjectionRoots.add(path[0]) + } + } else if (modelInput?.mode === 'private-provenance') { + const privateInputPaths = modelInput.inputPaths(params) + paths.push(...privateInputPaths) + for (const path of privateInputPaths) { + if (path[0]) requiredProjectionRoots.add(path[0]) + } + } + const opaqueInputPaths = tool.request.opaqueModelInput?.inputPaths(params) ?? [] + paths.push(...opaqueInputPaths) + for (const path of opaqueInputPaths) { + if (path[0]) requiredProjectionRoots.add(path[0]) + } + for (const selection of tool.request.secretProvenance?.request?.(params) ?? []) { + paths.push(...selection.inputPaths) + for (const path of selection.inputPaths) { + if (path[0]) requiredProjectionRoots.add(path[0]) + } + } + + const uniquePaths = new Map() + for (const path of paths) { + if (path.length > 0) uniquePaths.set(JSON.stringify(path), path) + } + return { paths: [...uniquePaths.values()], requiredProjectionRoots } + } catch { + return undefined + } +} + +function canonicalPlaceholder(value: unknown): string | undefined { + if (typeof value !== 'string') return undefined + const match = /^\{\{([A-Za-z0-9_]+)\}\}$/.exec(value.trim()) + return match ? value.trim() : undefined +} + +function isFileBoundaryPath(tool: ToolConfig, path: ResolvedSecretInputPath): boolean { + return Boolean(path[0] && tool.params[path[0]]?.type === 'file') +} + +function projectScalarLeaves( + value: unknown, + placeholder: string +): { value: unknown; projectedLeaves: number } | undefined { + if (value === null || typeof value !== 'object') { + return { value: placeholder, projectedLeaves: 1 } + } + if (!Array.isArray(value) && !isPlainRecord(value)) return undefined + + const root: unknown[] | Record = Array.isArray(value) ? [] : {} + const pending: Array<{ + source: unknown[] | Record + target: unknown[] | Record + }> = [{ source: value as unknown[] | Record, target: root }] + const visited = new WeakSet() + let projectedLeaves = 0 + while (pending.length > 0) { + const { source, target } = pending.pop()! + if (visited.has(source)) return undefined + visited.add(source) + for (const [key, child] of Object.entries(source)) { + if (child !== null && typeof child === 'object') { + if (!Array.isArray(child) && !isPlainRecord(child)) return undefined + const projectedChild: unknown[] | Record = Array.isArray(child) ? [] : {} + ;(target as Record)[key] = projectedChild + pending.push({ + source: child as unknown[] | Record, + target: projectedChild, + }) + } else { + ;(target as Record)[key] = placeholder + projectedLeaves += 1 + } + } + } + return { value: root, projectedLeaves } +} + +function createStructuredModelProjection( + tool: ToolConfig, + finalInputs: Record, + sourcePath: ResolvedSecretInputPath, + projectedSourceValue: unknown +): Record | undefined { + const modelInput = tool.request.modelInput + const sourceKey = sourcePath.length === 1 ? sourcePath[0] : undefined + const placeholder = canonicalPlaceholder(projectedSourceValue) + if (modelInput?.mode !== 'project' || !modelInput.applyProjected || !sourceKey || !placeholder) { + return undefined + } + + try { + const selected = modelInput.select(finalInputs) + if (!isPlainRecord(selected) || !Object.hasOwn(selected, sourceKey)) return undefined + const projectedValue = projectScalarLeaves(selected[sourceKey], placeholder) + if (!projectedValue || projectedValue.projectedLeaves === 0) return undefined + const projectedSelection = { ...selected, [sourceKey]: projectedValue.value } + const selectedParams = Object.fromEntries( + Object.keys(selected).map((key) => [key, finalInputs[key]]) + ) + const patch = modelInput.applyProjected(structuredClone(selectedParams), projectedSelection) + if (!isPlainRecord(patch)) return undefined + const projectedInputs = { ...finalInputs, ...patch } + if (!isDeepStrictEqual(modelInput.select(projectedInputs), projectedSelection)) return undefined + return projectedInputs + } catch { + return undefined + } +} + export class GenericBlockHandler implements BlockHandler { canHandle(block: SerializedBlock): boolean { return true @@ -35,6 +174,8 @@ export class GenericBlockHandler implements BlockHandler { const blockType = block.metadata?.id if (blockType) { const blockConfig = getBlock(blockType) + const registry = ctx.resolvedSecretTraceRegistry + if (blockConfig?.tools?.config?.params) { const transformedParams = blockConfig.tools.config.params(inputs) finalInputs = { ...inputs, ...transformedParams } @@ -57,6 +198,74 @@ export class GenericBlockHandler implements BlockHandler { } } } + + const boundary = tool ? selectBlockBoundaryPaths(tool, finalInputs) : undefined + const projectedInputs = + boundary && boundary.paths.length > 0 && registry?.hasResolvedInputProjections() + ? registry.projectResolvedInputSelections(inputs) + : undefined + if (projectedInputs?.complete === false) registry?.markIncomplete() + + if (projectedInputs?.complete && boundary && tool && registry) { + for (const projection of projectedInputs.values) { + const preserveFileDescriptorGrammar = + isFileBoundaryPath(tool, projection.path) || + boundary.paths.some((path) => isFileBoundaryPath(tool, path)) + let projectedFinalInputs = prepareResolvedSecretProjectedInputs( + projection.value, + blockConfig?.inputs, + inputs, + { preserveFileDescriptorGrammar } + ) + try { + if (blockConfig?.tools?.config?.params) { + projectedFinalInputs = { + ...projectedFinalInputs, + ...blockConfig.tools.config.params(projectedFinalInputs), + } + } + } catch { + const structuredProjection = createStructuredModelProjection( + tool, + finalInputs, + projection.path, + projection.projectedValue + ) + if (structuredProjection) { + registry.recordTransformedInputProjection(finalInputs, structuredProjection, { + targetPaths: boundary.paths, + }) + continue + } + if (boundary.requiredProjectionRoots.has(projection.path[0])) { + registry.markIncomplete() + } + continue + } + + if (blockConfig?.inputs) { + projectedFinalInputs = prepareResolvedSecretProjectedInputs( + projectedFinalInputs, + blockConfig.inputs, + finalInputs, + { preserveFileDescriptorGrammar } + ) + for (const [key, inputSchema] of Object.entries(blockConfig.inputs)) { + const value = projectedFinalInputs[key] + if (typeof value !== 'string' || value.trim().length === 0) continue + const inputType = typeof inputSchema === 'object' ? inputSchema.type : inputSchema + if (inputType !== 'json' && inputType !== 'array') continue + try { + projectedFinalInputs[key] = JSON.parse(value.trim()) + } catch {} + } + } + + registry.recordTransformedInputProjection(finalInputs, projectedFinalInputs, { + targetPaths: boundary.paths, + }) + } + } } try { diff --git a/apps/sim/executor/handlers/mothership/mothership-handler.test.ts b/apps/sim/executor/handlers/mothership/mothership-handler.test.ts index 611b907b20d..500a4308235 100644 --- a/apps/sim/executor/handlers/mothership/mothership-handler.test.ts +++ b/apps/sim/executor/handlers/mothership/mothership-handler.test.ts @@ -5,8 +5,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { BlockType } from '@/executor/constants' import { MothershipBlockHandler } from '@/executor/handlers/mothership/mothership-handler' import type { ExecutionContext, StreamingExecution } from '@/executor/types' -import { createResolvedSecretMatcher } from '@/executor/utils/resolved-secret-content-projection' -import type { ResolvedSecretTraceRegistry } from '@/executor/utils/resolved-secret-trace-registry' +import { ResolvedSecretTraceRegistry } from '@/executor/utils/resolved-secret-trace-registry' import type { SerializedBlock } from '@/serializer/types' const BILLING_ATTRIBUTION = { @@ -121,21 +120,16 @@ async function readStreamText(stream: ReadableStream): Promise { } function createTraceRegistryMock(): ResolvedSecretTraceRegistry & { - getModelEgressRevision: ReturnType - getModelEgressSnapshot: ReturnType importProvenanceForValue: ReturnType markIncomplete: ReturnType } { - return { - getModelEgressRevision: vi.fn().mockReturnValue(0), - getModelEgressSnapshot: vi - .fn() - .mockReturnValue({ complete: true, matches: [], matcher: undefined }), - importProvenanceForValue: vi.fn().mockResolvedValue(true), - markIncomplete: vi.fn(), - } as unknown as ResolvedSecretTraceRegistry & { - getModelEgressRevision: ReturnType - getModelEgressSnapshot: ReturnType + const registry = new ResolvedSecretTraceRegistry() + return Object.assign(registry, { + importProvenanceForValue: vi + .spyOn(registry, 'importProvenanceForValue') + .mockResolvedValue(true), + markIncomplete: vi.spyOn(registry, 'markIncomplete'), + }) as ResolvedSecretTraceRegistry & { importProvenanceForValue: ReturnType markIncomplete: ReturnType } @@ -299,19 +293,19 @@ describe('MothershipBlockHandler', () => { expect(JSON.stringify(result)).not.toContain('encrypted-secret') }) - it('projects parent execution secrets before sending a Mothership prompt', async () => { - const registry = createTraceRegistryMock() - const matches = [ - { - plaintext: 'cross-workspace-secret', - replacement: '[REDACTED_SECRET]', - }, - ] - registry.getModelEgressSnapshot.mockReturnValue({ - complete: true, - matches, - matcher: createResolvedSecretMatcher(matches), - }) + it('projects only secrets resolved at the Mothership prompt input path', async () => { + const registry = new ResolvedSecretTraceRegistry([ + { name: 'PROMPT_SECRET', plaintext: 'prompt-secret', encryptedValue: 'prompt-ciphertext' }, + { name: 'UNUSED', plaintext: 'x', encryptedValue: 'unused-ciphertext' }, + ]) + registry.recordResolvedAtInputPath('PROMPT_SECRET', 'prompt-secret', ['prompt']) + registry.recordResolvedInputProjection( + ['prompt'], + 'Use prompt-secret while Box stays unchanged', + 'Use {{PROMPT_SECRET}} while Box stays unchanged' + ) + registry.recordResolved('UNUSED', 'x') + vi.spyOn(registry, 'importProvenanceForValue').mockResolvedValue(true) context.resolvedSecretTraceRegistry = registry mockGenerateId .mockReturnValueOnce('chat-uuid') @@ -320,25 +314,45 @@ describe('MothershipBlockHandler', () => { fetchMock.mockResolvedValue(createJsonResponse({ content: 'done', toolCalls: [] })) await handler.execute(context, block, { - prompt: 'Use cross-workspace-secret and __var_FOREIGN', + prompt: 'Use prompt-secret while Box stays unchanged', }) const [, options] = fetchMock.mock.calls[0] as [string, RequestInit] const body = String(options.body) - expect(body).toContain('[REDACTED_SECRET]') - expect(body).not.toContain('cross-workspace-secret') + expect(body).not.toContain('prompt-secret') expect(JSON.parse(body)).toMatchObject({ - messages: [{ content: 'Use [REDACTED_SECRET] and __var_FOREIGN' }], + messages: [{ content: 'Use {{PROMPT_SECRET}} while Box stays unchanged' }], }) }) - it('drops a legacy response without poisoning later provenance', async () => { + it('preserves a headerless legacy JSON response without poisoning later calls', async () => { const registry = createTraceRegistryMock() context.resolvedSecretTraceRegistry = registry mockGenerateId .mockReturnValueOnce('chat-uuid') .mockReturnValueOnce('message-uuid') .mockReturnValueOnce('request-uuid') + fetchMock.mockResolvedValue( + new Response( + JSON.stringify({ + content: 'unchanged output', + toolCalls: [], + }), + { status: 200, headers: { 'Content-Type': 'application/json' } } + ) + ) + + await expect(handler.execute(context, block, { prompt: 'Hello' })).resolves.toMatchObject({ + content: 'unchanged output', + }) + + expect(registry.importProvenanceForValue).not.toHaveBeenCalled() + expect(registry.markIncomplete).not.toHaveBeenCalled() + }) + + it('rejects a headerless response that contains a partial private envelope', async () => { + const registry = createTraceRegistryMock() + context.resolvedSecretTraceRegistry = registry fetchMock.mockResolvedValue( new Response( JSON.stringify({ @@ -351,11 +365,11 @@ describe('MothershipBlockHandler', () => { ) await expect(handler.execute(context, block, { prompt: 'Hello' })).rejects.toThrow( - 'does not support private provenance metadata' + 'provenance metadata is invalid' ) expect(registry.importProvenanceForValue).not.toHaveBeenCalled() - expect(registry.markIncomplete).not.toHaveBeenCalled() + expect(registry.markIncomplete).toHaveBeenCalled() }) it('poisons provenance when a declared response omits its private field', async () => { @@ -379,13 +393,55 @@ describe('MothershipBlockHandler', () => { expect(registry.markIncomplete).toHaveBeenCalledOnce() }) - it('fails closed before the request when the model-egress registry is unavailable', async () => { + it('preserves legacy request and response behavior when no registry is available', async () => { context.resolvedSecretTraceRegistry = undefined + fetchMock.mockResolvedValue( + new Response(JSON.stringify({ content: 'legacy output', toolCalls: [] }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }) + ) + + await expect(handler.execute(context, block, { prompt: 'Hello' })).resolves.toMatchObject({ + content: 'legacy output', + }) + expect(context.resolvedSecretTraceRegistry).toBeUndefined() + }) + + it('rejects an explicitly mismatched response metadata version', async () => { + const registry = createTraceRegistryMock() + context.resolvedSecretTraceRegistry = registry + fetchMock.mockResolvedValue( + new Response(JSON.stringify({ content: 'unsafe output', toolCalls: [] }), { + status: 200, + headers: { + 'Content-Type': 'application/json', + 'x-sim-private-tool-metadata': 'resolved-secret-provenance-v2', + }, + }) + ) await expect(handler.execute(context, block, { prompt: 'Hello' })).rejects.toThrow( - 'Mothership input could not be safely projected' + 'provenance metadata is invalid' ) - expect(fetchMock).not.toHaveBeenCalled() + expect(registry.markIncomplete).toHaveBeenCalled() + }) + + it('preserves a headerless legacy upstream error without poisoning later calls', async () => { + const registry = createTraceRegistryMock() + context.resolvedSecretTraceRegistry = registry + fetchMock.mockResolvedValue( + new Response(JSON.stringify({ error: 'legacy upstream error' }), { + status: 502, + headers: { 'Content-Type': 'application/json' }, + }) + ) + + await expect(handler.execute(context, block, { prompt: 'Hello' })).rejects.toThrow( + 'Sim execution failed: legacy upstream error' + ) + expect(registry.importProvenanceForValue).not.toHaveBeenCalled() + expect(registry.markIncomplete).not.toHaveBeenCalled() }) it('imports provenance from a terminal NDJSON error without forcing structural fallback', async () => { @@ -468,6 +524,100 @@ describe('MothershipBlockHandler', () => { expect(JSON.stringify(result.execution.output)).not.toContain('encrypted-secret') }) + it('preserves a headerless legacy NDJSON final result without poisoning later calls', async () => { + const registry = createTraceRegistryMock() + context.resolvedSecretTraceRegistry = registry + const encoder = new TextEncoder() + fetchMock.mockResolvedValue( + new Response( + new ReadableStream({ + start(controller) { + controller.enqueue( + encoder.encode( + `${JSON.stringify({ + type: 'final', + data: { content: 'legacy final', toolCalls: [] }, + })}\n` + ) + ) + controller.close() + }, + }), + { headers: { 'Content-Type': 'application/x-ndjson; charset=utf-8' } } + ) + ) + + await expect(handler.execute(context, block, { prompt: 'Hello' })).resolves.toMatchObject({ + content: 'legacy final', + }) + expect(registry.importProvenanceForValue).not.toHaveBeenCalled() + expect(registry.markIncomplete).not.toHaveBeenCalled() + }) + + it('preserves headerless legacy selected-output streaming without poisoning later calls', async () => { + const registry = createTraceRegistryMock() + context.resolvedSecretTraceRegistry = registry + context.stream = true + context.selectedOutputs = [`${block.id}_content`] + const encoder = new TextEncoder() + fetchMock.mockResolvedValue( + new Response( + new ReadableStream({ + start(controller) { + controller.enqueue( + encoder.encode(`${JSON.stringify({ type: 'chunk', content: 'legacy chunk' })}\n`) + ) + controller.enqueue( + encoder.encode( + `${JSON.stringify({ + type: 'final', + data: { content: 'legacy final', toolCalls: [] }, + })}\n` + ) + ) + controller.close() + }, + }), + { headers: { 'Content-Type': 'application/x-ndjson; charset=utf-8' } } + ) + ) + + const result = (await handler.execute(context, block, { + prompt: 'Hello', + })) as StreamingExecution + await expect(readStreamText(result.stream)).resolves.toBe('legacy chunk') + expect(result.execution.output).toMatchObject({ content: 'legacy final' }) + expect(registry.importProvenanceForValue).not.toHaveBeenCalled() + expect(registry.markIncomplete).not.toHaveBeenCalled() + }) + + it('surfaces a headerless legacy NDJSON terminal error without poisoning later calls', async () => { + const registry = createTraceRegistryMock() + context.resolvedSecretTraceRegistry = registry + const encoder = new TextEncoder() + fetchMock.mockResolvedValue( + new Response( + new ReadableStream({ + start(controller) { + controller.enqueue( + encoder.encode( + `${JSON.stringify({ type: 'error', error: 'legacy terminal error' })}\n` + ) + ) + controller.close() + }, + }), + { headers: { 'Content-Type': 'application/x-ndjson; charset=utf-8' } } + ) + ) + + await expect(handler.execute(context, block, { prompt: 'Hello' })).rejects.toThrow( + 'Sim execution failed: legacy terminal error' + ) + expect(registry.importProvenanceForValue).not.toHaveBeenCalled() + expect(registry.markIncomplete).not.toHaveBeenCalled() + }) + it('forwards workflow and execution metadata with generated UUID ids', async () => { mockGenerateId.mockReturnValueOnce('chat-uuid') mockGenerateId.mockReturnValueOnce('message-uuid') @@ -661,16 +811,12 @@ describe('MothershipBlockHandler', () => { expect(body.contexts).toEqual([{ kind: 'skill', skillId: 'skill-1', label: 'sales-playbook' }]) }) - it('projects proven model metadata without rewriting arbitrary attachment names or payloads', async () => { + it('does not scan arbitrary Mothership metadata, attachment names, or payloads', async () => { const secret = 'boundary-secret' - const replacement = '{{API_KEY}}' - const registry = createTraceRegistryMock() - const matches = [{ plaintext: secret, replacement }] - registry.getModelEgressSnapshot.mockReturnValue({ - complete: true, - matches, - matcher: createResolvedSecretMatcher(matches), - }) + const registry = new ResolvedSecretTraceRegistry([ + { name: 'UNUSED_SECRET', plaintext: secret, encryptedValue: 'encrypted-unused-secret' }, + ]) + vi.spyOn(registry, 'importProvenanceForValue').mockResolvedValue(true) context.resolvedSecretTraceRegistry = registry mockGenerateId .mockReturnValueOnce('chat-uuid') @@ -734,33 +880,366 @@ describe('MothershipBlockHandler', () => { usageControl: 'force', schema: { type: 'object', - title: `Query ${replacement}`, - description: `Search using ${replacement}`, + title: `Query ${secret}`, + description: `Search using ${secret}`, properties: { - query: { type: 'string', description: `Find ${replacement}` }, + query: { type: 'string', description: `Find ${secret}` }, }, }, params: { serverId: 'mcp-server-1', toolName: 'search', - serverName: `Docs ${replacement}`, + serverName: `Docs ${secret}`, }, }, ]) expect(body.contexts).toEqual([ - { kind: 'skill', skillId: 'skill-1', label: `Playbook ${replacement}` }, + { kind: 'skill', skillId: 'skill-1', label: `Playbook ${secret}` }, ]) - const attachmentMetadata = { - type: body.fileAttachments[0].type, + }) + + it('projects only resolver-recorded model-visible MCP and skill metadata', async () => { + const secrets = [ + { + name: 'MCP_SERVER_LABEL', + plaintext: 'private server label', + encryptedValue: 'encrypted-server-label', + path: ['tools', '0', 'params', 'serverName'], + raw: 'Docs private server label', + projected: 'Docs {{MCP_SERVER_LABEL}}', + }, + { + name: 'MCP_SCHEMA_DESCRIPTION', + plaintext: 'private schema text', + encryptedValue: 'encrypted-schema-description', + path: ['tools', '0', 'schema', 'description'], + raw: 'Search private schema text for Box', + projected: 'Search {{MCP_SCHEMA_DESCRIPTION}} for Box', + }, + { + name: 'SKILL_LABEL', + plaintext: 'private skill label', + encryptedValue: 'encrypted-skill-label', + path: ['skills', '0', 'name'], + raw: 'Playbook private skill label', + projected: 'Playbook {{SKILL_LABEL}}', + }, + { + name: 'DISABLED_SERVER_ID', + plaintext: 'disabled-server-secret', + encryptedValue: 'encrypted-disabled-server-id', + path: ['tools', '1', 'params', 'serverId'], + raw: 'disabled-server-secret', + projected: '{{DISABLED_SERVER_ID}}', + }, + ] as const + const registry = new ResolvedSecretTraceRegistry([ + ...secrets.map(({ name, plaintext, encryptedValue }) => ({ + name, + plaintext, + encryptedValue, + })), + { name: 'UNUSED_SECRET', plaintext: 'x', encryptedValue: 'encrypted-unused' }, + ]) + for (const secret of secrets) { + registry.recordResolvedAtInputPath(secret.name, secret.plaintext, secret.path) + registry.recordResolvedInputProjection(secret.path, secret.raw, secret.projected) } - expect( - JSON.stringify({ - messages: body.messages, - attachmentMetadata, - mcpTools: body.mcpTools, - contexts: body.contexts, + vi.spyOn(registry, 'importProvenanceForValue').mockResolvedValue(true) + context.resolvedSecretTraceRegistry = registry + mockGenerateId + .mockReturnValueOnce('chat-uuid') + .mockReturnValueOnce('message-uuid') + .mockReturnValueOnce('request-uuid') + fetchMock.mockResolvedValue(createJsonResponse({ content: 'done', toolCalls: [] })) + const tools = [ + { + type: 'mcp', + params: { + serverId: 'mcp-server-1', + toolName: 'search', + serverName: 'Docs private server label', + }, + schema: { + type: 'object', + description: 'Search private schema text for Box', + properties: { query: { type: 'string' } }, + }, + }, + { + type: 'mcp', + usageControl: 'none', + params: { serverId: 'disabled-server-secret', toolName: 'disabled' }, + }, + ] + const skills = [{ skillId: 'skill-1', name: 'Playbook private skill label' }] + + await handler.execute(context, block, { + prompt: 'Use Box without changing it', + tools, + skills, + }) + + const [, options] = fetchMock.mock.calls[0] as [string, RequestInit] + const body = JSON.parse(String(options.body)) + expect(body.mcpTools).toEqual([ + { + type: 'mcp', + schema: { + type: 'object', + description: 'Search {{MCP_SCHEMA_DESCRIPTION}} for Box', + properties: { query: { type: 'string' } }, + }, + params: { + serverId: 'mcp-server-1', + toolName: 'search', + serverName: 'Docs {{MCP_SERVER_LABEL}}', + }, + }, + ]) + expect(body.contexts).toEqual([ + { kind: 'skill', skillId: 'skill-1', label: 'Playbook {{SKILL_LABEL}}' }, + ]) + expect(JSON.stringify(body)).not.toContain('private server label') + expect(JSON.stringify(body)).not.toContain('private schema text') + expect(JSON.stringify(body)).not.toContain('private skill label') + expect(tools[0].params.serverName).toBe('Docs private server label') + expect(tools[0].schema.description).toBe('Search private schema text for Box') + expect(skills[0].name).toBe('Playbook private skill label') + }) + + it('rejects only an enabled structural identifier with exact resolver provenance', async () => { + const registry = new ResolvedSecretTraceRegistry([ + { + name: 'MCP_SERVER_ID', + plaintext: 'resolved-server-id', + encryptedValue: 'encrypted-server-id', + }, + ]) + registry.recordResolvedAtInputPath('MCP_SERVER_ID', 'resolved-server-id', [ + 'tools', + '0', + 'params', + 'serverId', + ]) + registry.recordResolvedInputProjection( + ['tools', '0', 'params', 'serverId'], + 'resolved-server-id', + '{{MCP_SERVER_ID}}' + ) + context.resolvedSecretTraceRegistry = registry + + await expect( + handler.execute(context, block, { + prompt: 'Use the selected tool', + tools: [ + { + type: 'mcp', + params: { serverId: 'resolved-server-id', toolName: 'search' }, + }, + ], + }) + ).rejects.toThrow('Mothership structural model inputs cannot contain secret references') + expect(fetchMock).not.toHaveBeenCalled() + }) + + it('rejects a resolver-derived MCP enum under a property named description', async () => { + const registry = new ResolvedSecretTraceRegistry([ + { + name: 'ENUM_VALUE', + plaintext: 'private-option', + encryptedValue: 'encrypted-option', + }, + ]) + const inputPath = ['tools', '0', 'schema', 'properties', 'description', 'enum', '0'] as const + registry.recordResolvedAtInputPath('ENUM_VALUE', 'private-option', inputPath) + registry.recordResolvedInputProjection(inputPath, 'private-option', '{{ENUM_VALUE}}') + context.resolvedSecretTraceRegistry = registry + + await expect( + handler.execute(context, block, { + prompt: 'Use the selected tool', + tools: [ + { + type: 'mcp', + params: { serverId: 'server-1', toolName: 'search' }, + schema: { + type: 'object', + properties: { + description: { type: 'string', enum: ['private-option'] }, + }, + }, + }, + ], + }) + ).rejects.toThrow('Mothership structural model inputs cannot contain secret references') + expect(fetchMock).not.toHaveBeenCalled() + }) + + it('projects a resolver-recorded attachment name without changing file materialization', async () => { + const registry = new ResolvedSecretTraceRegistry([ + { name: 'FILE_TOKEN', plaintext: 'x', encryptedValue: 'encrypted-file-token' }, + ]) + registry.recordResolvedAtInputPath('FILE_TOKEN', 'x', ['files', '0', 'name']) + registry.recordResolvedInputProjection( + ['files', '0', 'name'], + 'report-x.txt', + 'report-{{FILE_TOKEN}}.txt' + ) + vi.spyOn(registry, 'importProvenanceForValue').mockResolvedValue(true) + context.resolvedSecretTraceRegistry = registry + mockGenerateId + .mockReturnValueOnce('chat-uuid') + .mockReturnValueOnce('message-uuid') + .mockReturnValueOnce('request-uuid') + const attachmentData = Buffer.from('ordinary bytes', 'utf8').toString('base64') + mockReadUserFileContent.mockResolvedValueOnce(attachmentData) + fetchMock.mockResolvedValue(createJsonResponse({ content: 'done', toolCalls: [] })) + + await handler.execute(context, block, { + prompt: 'Read the attachment', + files: [ + { + name: 'report-x.txt', + key: 'workspace/workspace-1/report.txt', + size: 32, + type: 'text/plain', + }, + ], + }) + + expect(mockReadUserFileContent).toHaveBeenCalledWith( + expect.objectContaining({ + name: 'report-x.txt', + key: 'workspace/workspace-1/report.txt', + }), + expect.any(Object) + ) + const [, options] = fetchMock.mock.calls[0] as [string, RequestInit] + const body = JSON.parse(String(options.body)) + expect(body.fileAttachments).toEqual([ + { + type: 'document', + source: { + type: 'base64', + media_type: 'text/plain', + data: attachmentData, + }, + filename: 'report-{{FILE_TOKEN}}.txt', + }, + ]) + }) + + it('rejects resolver-derived inline attachment bytes before materialization', async () => { + const encodedSecret = 'aW1hZ2U=' + const registry = new ResolvedSecretTraceRegistry([ + { + name: 'FILE_BYTES', + plaintext: encodedSecret, + encryptedValue: 'encrypted-file-bytes', + }, + ]) + const inputPath = ['files', '0', 'base64'] as const + registry.recordResolvedAtInputPath('FILE_BYTES', encodedSecret, inputPath) + registry.recordResolvedInputProjection(inputPath, encodedSecret, '{{FILE_BYTES}}') + context.resolvedSecretTraceRegistry = registry + + await expect( + handler.execute(context, block, { + prompt: 'Read the attachment', + files: [ + { + name: 'example.png', + key: 'workspace/workspace-1/example.png', + size: 5, + type: 'image/png', + base64: encodedSecret, + }, + ], + }) + ).rejects.toThrow('Mothership inline file content cannot contain secret references') + expect(mockReadUserFileContent).not.toHaveBeenCalled() + expect(fetchMock).not.toHaveBeenCalled() + }) + + it('rejects resolver-derived inline bytes inside a serialized file input', async () => { + const rawFiles = JSON.stringify([ + { + name: 'example.png', + key: 'workspace/workspace-1/example.png', + size: 5, + type: 'image/png', + base64: 'aW1hZ2U=', + }, + ]) + const projectedFiles = JSON.stringify([ + { + name: 'example.png', + key: 'workspace/workspace-1/example.png', + size: 5, + type: 'image/png', + base64: '{{FILE_BYTES}}', + }, + ]) + const registry = new ResolvedSecretTraceRegistry([ + { + name: 'FILE_BYTES', + plaintext: 'aW1hZ2U=', + encryptedValue: 'encrypted-file-bytes', + }, + ]) + registry.recordResolvedAtInputPath('FILE_BYTES', 'aW1hZ2U=', ['files']) + registry.recordResolvedInputProjection(['files'], rawFiles, projectedFiles) + context.resolvedSecretTraceRegistry = registry + + await expect( + handler.execute(context, block, { + prompt: 'Read the attachment', + files: rawFiles, }) - ).not.toContain(secret) + ).rejects.toThrow('Mothership inline file content cannot contain secret references') + expect(mockReadUserFileContent).not.toHaveBeenCalled() + expect(fetchMock).not.toHaveBeenCalled() + }) + + it('keeps dormant inline attachment bytes unchanged', async () => { + const encodedBytes = 'aW1hZ2U=' + context.resolvedSecretTraceRegistry = new ResolvedSecretTraceRegistry([ + { + name: 'UNUSED_FILE_BYTES', + plaintext: encodedBytes, + encryptedValue: 'encrypted-unused-file-bytes', + }, + ]) + mockGenerateId + .mockReturnValueOnce('chat-uuid') + .mockReturnValueOnce('message-uuid') + .mockReturnValueOnce('request-uuid') + mockReadUserFileContent.mockResolvedValueOnce(encodedBytes) + fetchMock.mockResolvedValue( + new Response(JSON.stringify({ content: 'done', toolCalls: [] }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }) + ) + + await handler.execute(context, block, { + prompt: 'Read the attachment', + files: [ + { + name: 'example.png', + key: 'workspace/workspace-1/example.png', + size: 5, + type: 'image/png', + base64: encodedBytes, + }, + ], + }) + + expect(mockReadUserFileContent).toHaveBeenCalledTimes(1) + const [, options] = fetchMock.mock.calls[0] as [string, RequestInit] + const body = JSON.parse(String(options.body)) + expect(body.fileAttachments[0].source.data).toBe(encodedBytes) }) it('rejects a canonical tracked file whose exact byte provenance is not model-safe', async () => { @@ -787,15 +1266,9 @@ describe('MothershipBlockHandler', () => { expect(fetchMock).not.toHaveBeenCalled() }) - it('drops Mothership selections whose protocol identifiers or schema semantics contain secrets', async () => { + it('does not infer secret provenance from matching protocol or schema literals', async () => { const secret = 'boundary-secret' const registry = createTraceRegistryMock() - const matches = [{ plaintext: secret, replacement: '{{API_KEY}}' }] - registry.getModelEgressSnapshot.mockReturnValue({ - complete: true, - matches, - matcher: createResolvedSecretMatcher(matches), - }) context.resolvedSecretTraceRegistry = registry mockGenerateId .mockReturnValueOnce('chat-uuid') @@ -838,14 +1311,16 @@ describe('MothershipBlockHandler', () => { const [, options] = fetchMock.mock.calls[0] as [string, RequestInit] const body = JSON.parse(String(options.body)) - expect(body.mcpTools).toEqual([ - { - type: 'mcp', - params: { serverId: 'mcp-server-1', toolName: 'search' }, - }, + expect(body.mcpTools).toHaveLength(5) + expect(body.mcpTools[0]).toEqual({ + type: 'mcp', + params: { serverId: secret, toolName: 'search' }, + }) + expect(body.mcpTools[2].schema).toEqual({ type: 'string', enum: [secret] }) + expect(body.contexts).toEqual([ + { kind: 'skill', skillId: secret, label: 'Unsafe' }, + { kind: 'skill', skillId: 'skill-1', label: 'Safe skill' }, ]) - expect(body.contexts).toEqual([{ kind: 'skill', skillId: 'skill-1', label: 'Safe skill' }]) - expect(JSON.stringify(body)).not.toContain(secret) }) it('consumes mothership execute heartbeat streams until the final result', async () => { diff --git a/apps/sim/executor/handlers/mothership/mothership-handler.ts b/apps/sim/executor/handlers/mothership/mothership-handler.ts index a81dc773018..b9c52be8891 100644 --- a/apps/sim/executor/handlers/mothership/mothership-handler.ts +++ b/apps/sim/executor/handlers/mothership/mothership-handler.ts @@ -6,13 +6,14 @@ import { BILLING_ATTRIBUTION_HEADER, serializeBillingAttributionHeader, } from '@/lib/billing/core/billing-attribution' -import { - collectModelVisibleSchemaContent, - restoreModelVisibleSchemaValues, -} from '@/lib/copilot/model-visible-schema' import { normalizeSecretMountPolicy } from '@/lib/copilot/secret-mount-policy' import { env } from '@/lib/core/config/env' import { isExecutionCancelled, isRedisCancellationEnabled } from '@/lib/execution/cancellation' +import { + projectModelSchemaAnnotations, + projectResolvedModelInput, + selectModelSchemaInputPaths, +} from '@/lib/execution/model-input-provenance' import { readUserFileContent } from '@/lib/execution/payloads/materialization.server' import { inspectPrivateToolMetadataEnvelope, @@ -31,6 +32,7 @@ import { processSingleFileToUserFile, type RawFileInput, } from '@/lib/uploads/utils/file-utils' +import { selectModelBoundFileInputPaths } from '@/lib/uploads/utils/model-input' import type { BlockOutput } from '@/blocks/types' import { normalizeFileInput } from '@/blocks/utils' import { BlockType } from '@/executor/constants' @@ -41,11 +43,10 @@ import type { StreamingExecution, } from '@/executor/types' import { buildAPIUrl, buildAuthHeaders, extractAPIErrorMessage } from '@/executor/utils/http' -import { - isResolvedSecretModelContentUnchanged, - projectResolvedSecretModelContent, -} from '@/executor/utils/resolved-secret-content-projection' -import type { ResolvedSecretTraceRegistry } from '@/executor/utils/resolved-secret-trace-registry' +import type { + ResolvedSecretInputPath, + ResolvedSecretTraceRegistry, +} from '@/executor/utils/resolved-secret-trace-registry' import type { SerializedBlock } from '@/serializer/types' const logger = createLogger('MothershipBlockHandler') @@ -75,6 +76,17 @@ interface MothershipSkillContext { label: string } +interface IndexedMothershipMcpToolSelection { + inputIndex: number + selection: MothershipMcpToolSelection +} + +interface IndexedMothershipSkillContext { + inputIndex: number + context: MothershipSkillContext + hasExplicitLabel: boolean +} + type MothershipExecuteResult = { content?: string model?: string @@ -92,67 +104,21 @@ type MothershipExecuteStreamEvent = Record >) -function projectMothershipPrompt( - prompt: string, - registry: ResolvedSecretTraceRegistry | undefined -): string { - const projection = projectResolvedSecretModelContent(prompt, registry) - if (!projection.safe || typeof projection.value !== 'string') { - throw new Error('Mothership input could not be safely projected') - } - return projection.value -} - -function projectMothershipMcpTools( - tools: unknown, - registry: ResolvedSecretTraceRegistry | undefined -): MothershipMcpToolSelection[] { +function selectIndexedMothershipMcpTools(tools: unknown): IndexedMothershipMcpToolSelection[] { if (!Array.isArray(tools)) return [] - return tools.flatMap((candidate) => { + return tools.flatMap((candidate, inputIndex) => { if (!isPlainRecord(candidate) || candidate.type !== 'mcp') return [] if (candidate.usageControl === 'none' || !isPlainRecord(candidate.params)) return [] const { serverId, toolName } = candidate.params - if ( - typeof serverId !== 'string' || - !serverId || - typeof toolName !== 'string' || - !toolName || - !isResolvedSecretModelContentUnchanged([serverId, toolName], registry) - ) { + if (typeof serverId !== 'string' || !serverId || typeof toolName !== 'string' || !toolName) { return [] } const serverName = typeof candidate.params.serverName === 'string' ? candidate.params.serverName : undefined const schema = isPlainRecord(candidate.schema) ? candidate.schema : undefined - const schemaContent = schema - ? collectModelVisibleSchemaContent(schema) - : { projectedValues: [], guardedValues: [] } - if (!isResolvedSecretModelContentUnchanged(schemaContent.guardedValues, registry)) return [] - - const projection = projectResolvedSecretModelContent( - [serverName, schemaContent.projectedValues], - registry - ) - if (!projection.safe || !Array.isArray(projection.value) || projection.value.length !== 2) { - throw new Error('Mothership MCP tool metadata could not be safely projected') - } - const [projectedServerName, projectedSchemaValues] = projection.value - if (serverName === undefined && projectedServerName !== undefined) { - throw new Error('Mothership MCP tool metadata could not be safely projected') - } - if (serverName !== undefined && typeof projectedServerName !== 'string') { - throw new Error('Mothership MCP tool metadata could not be safely projected') - } - - const projectedSchema = schema - ? restoreModelVisibleSchemaValues(schema, projectedSchemaValues) - : undefined - if (projectedSchema !== undefined && !isPlainRecord(projectedSchema)) { - throw new Error('Mothership MCP tool metadata could not be safely projected') - } const usageControl = candidate.usageControl === 'auto' || candidate.usageControl === 'force' @@ -161,54 +127,116 @@ function projectMothershipMcpTools( const selection: MothershipMcpToolSelection = { type: 'mcp', ...(usageControl ? { usageControl } : {}), - ...(projectedSchema ? { schema: projectedSchema } : {}), + ...(schema ? { schema } : {}), params: { serverId, toolName, - ...(projectedServerName !== undefined ? { serverName: projectedServerName } : {}), + ...(serverName !== undefined ? { serverName } : {}), }, } - return [selection] + return [{ inputIndex, selection }] }) } -function projectMothershipSkillContexts( - skills: unknown, - registry: ResolvedSecretTraceRegistry | undefined -): MothershipSkillContext[] { +function selectMothershipMcpTools(tools: unknown): MothershipMcpToolSelection[] { + return selectIndexedMothershipMcpTools(tools).map(({ selection }) => selection) +} + +function selectIndexedMothershipSkillContexts(skills: unknown): IndexedMothershipSkillContext[] { if (!Array.isArray(skills)) return [] - const selected = skills.flatMap((candidate) => { + return skills.flatMap((candidate, inputIndex) => { if (!isPlainRecord(candidate) || typeof candidate.skillId !== 'string' || !candidate.skillId) { return [] } - if (!isResolvedSecretModelContentUnchanged(candidate.skillId, registry)) return [] + const explicitLabel = typeof candidate.name === 'string' ? candidate.name : undefined + const hasExplicitLabel = explicitLabel !== undefined + const label = explicitLabel ?? candidate.skillId return [ { - skillId: candidate.skillId, - label: typeof candidate.name === 'string' ? candidate.name : candidate.skillId, + inputIndex, + hasExplicitLabel, + context: { + kind: 'skill' as const, + skillId: candidate.skillId, + label, + }, }, ] }) - const projection = projectResolvedSecretModelContent( - selected.map((skill) => skill.label), - registry - ) - if (!projection.safe) { - throw new Error('Mothership skill metadata could not be safely projected') +} + +function selectMothershipSkillContexts(skills: unknown): MothershipSkillContext[] { + return selectIndexedMothershipSkillContexts(skills).map(({ context }) => context) +} + +function selectMothershipMetadataModelInputPaths( + tools: unknown, + skills: unknown +): { + modelInputPaths: ResolvedSecretInputPath[] + structuralInputPaths: ResolvedSecretInputPath[] +} { + const modelInputPaths: ResolvedSecretInputPath[] = [] + const structuralInputPaths: ResolvedSecretInputPath[] = [] + + for (const { inputIndex, selection } of selectIndexedMothershipMcpTools(tools)) { + const root = ['tools', String(inputIndex)] as const + structuralInputPaths.push([...root, 'params', 'serverId'], [...root, 'params', 'toolName']) + if (selection.schema) { + const schemaPaths = selectModelSchemaInputPaths(selection.schema, [...root, 'schema']) + modelInputPaths.push(...schemaPaths.annotationInputPaths) + structuralInputPaths.push(...schemaPaths.semanticInputPaths) + } + if (selection.params.serverName !== undefined) { + modelInputPaths.push([...root, 'params', 'serverName']) + } } - const projectedLabels = projection.value - if (!Array.isArray(projectedLabels) || projectedLabels.length !== selected.length) { - throw new Error('Mothership skill metadata could not be safely projected') + + for (const { inputIndex, hasExplicitLabel } of selectIndexedMothershipSkillContexts(skills)) { + const root = ['skills', String(inputIndex)] as const + structuralInputPaths.push([...root, 'skillId']) + if (hasExplicitLabel) modelInputPaths.push([...root, 'name']) } - return selected.map((skill, index) => { - const label = projectedLabels[index] - if (typeof label !== 'string') { - throw new Error('Mothership skill metadata could not be safely projected') + return { modelInputPaths, structuralInputPaths } +} + +function assertMothershipToolSchemaProjectionsAreSafe( + registry: ResolvedSecretTraceRegistry, + tools: unknown +): void { + 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') + } + + 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') } - return { kind: 'skill', skillId: skill.skillId, label } - }) + 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') + } + } +} + +function assertMothershipStructuralInputsDoNotResolveSecrets( + registry: ResolvedSecretTraceRegistry, + inputPaths: readonly ResolvedSecretInputPath[] +): void { + const provenance = registry.exportCommittedProvenanceForInputPaths(inputPaths) + if (!provenance.complete) { + throw new Error('Mothership input could not be safely projected') + } + if (provenance.entries.length > 0) { + throw new Error('Mothership structural model inputs cannot contain secret references') + } } async function consumeMothershipProvenance( @@ -216,8 +244,6 @@ async function consumeMothershipProvenance( response: Response, registry?: ResolvedSecretTraceRegistry ): Promise { - if (!registry) throw new Error('Mothership model-egress provenance registry is unavailable') - const inspection = inspectPrivateToolMetadataEnvelope( response.headers, payload, @@ -226,31 +252,35 @@ async function consumeMothershipProvenance( const provenance = payload[RESOLVED_SECRET_PROVENANCE_FIELD] payload[RESOLVED_SECRET_PROVENANCE_FIELD] = undefined if (inspection.status === 'unsupported') { - throw new Error('Mothership response does not support private provenance metadata') + return false } if (inspection.status === 'invalid') { - registry.markIncomplete() + registry?.markIncomplete() throw new Error('Mothership response provenance metadata is invalid') } + if (!registry) return false + const imported = await registry.importProvenanceForValue(provenance, payload, { trusted: true }) if (!imported) throw new Error('Mothership response provenance metadata is invalid') return true } -function assertMothershipResponseCapability( +function inspectMothershipResponseCapability( response: Response, registry: ResolvedSecretTraceRegistry | undefined -): void { - if (!registry) throw new Error('Mothership model-egress provenance registry is unavailable') - +): boolean { const capability = inspectPrivateToolMetadataResponseCapability( response.headers, RESOLVED_SECRET_PROVENANCE_METADATA_V1 ) - if (capability.status === 'supported') return - if (capability.status === 'mismatched') registry.markIncomplete() - throw new Error('Mothership response does not support private provenance metadata') + if (capability.status === 'supported') return true + if (capability.status === 'unsupported') { + return false + } + + registry?.markIncomplete() + throw new Error('Mothership response provenance metadata is invalid') } function parseMothershipExecuteStreamLine(line: string): MothershipExecuteStreamEvent | undefined { @@ -306,15 +336,18 @@ async function readMothershipExecuteResponse( response: Response, registry?: ResolvedSecretTraceRegistry ): Promise { - assertMothershipResponseCapability(response, registry) + const expectsProvenance = inspectMothershipResponseCapability(response, registry) const contentType = response.headers.get('content-type') || '' if (!contentType.includes('application/x-ndjson')) { let result: MothershipExecuteResult try { result = (await response.json()) as MothershipExecuteResult - } catch { - registry?.markIncomplete() - throw new Error('Mothership response provenance metadata is invalid') + } catch (error) { + if (expectsProvenance) { + registry?.markIncomplete() + throw new Error('Mothership response provenance metadata is invalid') + } + throw error } await consumeMothershipProvenance(result, response, registry) return result @@ -374,7 +407,9 @@ async function readMothershipExecuteResponse( return finalResult } finally { - if (!finalResult && !receivedTerminalProvenance) registry?.markIncomplete() + if (expectsProvenance && !finalResult && !receivedTerminalProvenance) { + registry?.markIncomplete() + } reader.releaseLock() } } @@ -389,7 +424,7 @@ function createMothershipStreamingExecution( registry?: ResolvedSecretTraceRegistry } = {} ): StreamingExecution { - assertMothershipResponseCapability(response, options.registry) + const expectsProvenance = inspectMothershipResponseCapability(response, options.registry) if (!response.body) { throw new Error('Sim execution stream ended without a response body') } @@ -476,7 +511,9 @@ function createMothershipStreamingExecution( controller.error(error) } } finally { - if (!sawFinal && !receivedTerminalProvenance) options.registry?.markIncomplete() + if (expectsProvenance && !sawFinal && !receivedTerminalProvenance) { + options.registry?.markIncomplete() + } cleanup() reader?.releaseLock() } @@ -507,6 +544,7 @@ function createMothershipStreamingExecution( async function buildMothershipFileAttachments( filesInput: unknown, + projectedFilesInput: unknown, ctx: ExecutionContext, requestId: string ): Promise { @@ -518,6 +556,10 @@ async function buildMothershipFileAttachments( if (!ctx.userId) { throw new Error('Mothership file attachments require an authenticated user.') } + const projectedFiles = normalizeFileInput(projectedFilesInput) + if (!projectedFiles || projectedFiles.length !== files.length) { + throw new Error('Mothership input could not be safely projected') + } const userFiles = files.map((file) => processSingleFileToUserFile(file as RawFileInput, requestId, logger) @@ -529,7 +571,17 @@ async function buildMothershipFileAttachments( if (!modelSafe) throw new Error(MODEL_UNSAFE_WORKSPACE_FILE_ERROR_MESSAGE) const attachments: MothershipFileAttachment[] = [] - for (const userFile of userFiles) { + for (let fileIndex = 0; fileIndex < userFiles.length; fileIndex++) { + const userFile = userFiles[fileIndex] + const rawFile = files[fileIndex] + const projectedFile = projectedFiles[fileIndex] + if ( + isPlainRecord(rawFile) && + isPlainRecord(projectedFile) && + !Object.is(rawFile.base64, projectedFile.base64) + ) { + throw new Error('Mothership inline file content cannot contain secret references') + } const base64 = await readUserFileContent(userFile, { encoding: 'base64', userId: ctx.userId, @@ -551,7 +603,11 @@ async function buildMothershipFileAttachments( throw new Error(`File type is not supported for Mothership attachments: ${userFile.name}`) } - attachments.push({ ...content, filename: userFile.name }) + const projectedName = isPlainRecord(projectedFile) ? projectedFile.name : undefined + attachments.push({ + ...content, + filename: typeof projectedName === 'string' ? projectedName : userFile.name, + }) } return attachments @@ -584,10 +640,34 @@ export class MothershipBlockHandler implements BlockHandler { if (!prompt || typeof prompt !== 'string') { throw new Error('Prompt input is required') } + const metadataInputPaths = selectMothershipMetadataModelInputPaths(inputs.tools, inputs.skills) + if (ctx.resolvedSecretTraceRegistry) { + assertMothershipStructuralInputsDoNotResolveSecrets( + ctx.resolvedSecretTraceRegistry, + metadataInputPaths.structuralInputPaths + ) + assertMothershipToolSchemaProjectionsAreSafe(ctx.resolvedSecretTraceRegistry, inputs.tools) + } + const modelInputPaths: ResolvedSecretInputPath[] = [ + ['prompt'], + ...selectModelBoundFileInputPaths(inputs.files, ['files'], { + includeInlineBase64: true, + includeName: true, + }), + ...metadataInputPaths.modelInputPaths, + ] + const modelInputProjection = projectResolvedModelInput( + ctx.resolvedSecretTraceRegistry, + { prompt, files: inputs.files, tools: inputs.tools, skills: inputs.skills }, + modelInputPaths + ) + if (!modelInputProjection.complete || typeof modelInputProjection.value.prompt !== 'string') { + throw new Error('Mothership input could not be safely projected') + } const messages = [ { role: 'user' as const, - content: projectMothershipPrompt(prompt, ctx.resolvedSecretTraceRegistry), + content: modelInputProjection.value.prompt, }, ] const providedConversationId = @@ -599,12 +679,14 @@ export class MothershipBlockHandler implements BlockHandler { secretScope: inputs.secretScope, mountedSecrets: inputs.mountedSecrets, }) - const fileAttachments = await buildMothershipFileAttachments(inputs.files, ctx, requestId) - const mcpTools = projectMothershipMcpTools(inputs.tools, ctx.resolvedSecretTraceRegistry) - const skillContexts = projectMothershipSkillContexts( - inputs.skills, - ctx.resolvedSecretTraceRegistry + const fileAttachments = await buildMothershipFileAttachments( + inputs.files, + modelInputProjection.value.files, + ctx, + requestId ) + const mcpTools = selectMothershipMcpTools(modelInputProjection.value.tools) + const skillContexts = selectMothershipSkillContexts(modelInputProjection.value.skills) const url = buildAPIUrl('/api/mothership/execute') const headers = await buildAuthHeaders(ctx.userId) @@ -706,15 +788,20 @@ export class MothershipBlockHandler implements BlockHandler { }) if (!response.ok) { - assertMothershipResponseCapability(response, ctx.resolvedSecretTraceRegistry) - let payload: MothershipExecuteResult - try { - payload = (await response.clone().json()) as MothershipExecuteResult - } catch { - ctx.resolvedSecretTraceRegistry?.markIncomplete() - throw new Error('Mothership response provenance metadata is invalid') + const expectsProvenance = inspectMothershipResponseCapability( + response, + ctx.resolvedSecretTraceRegistry + ) + if (expectsProvenance) { + let payload: MothershipExecuteResult + try { + payload = (await response.clone().json()) as MothershipExecuteResult + } catch { + ctx.resolvedSecretTraceRegistry?.markIncomplete() + throw new Error('Mothership response provenance metadata is invalid') + } + await consumeMothershipProvenance(payload, response, ctx.resolvedSecretTraceRegistry) } - await consumeMothershipProvenance(payload, response, ctx.resolvedSecretTraceRegistry) const errorMsg = await extractAPIErrorMessage(response) throw new Error(`Sim execution failed: ${errorMsg}`) } diff --git a/apps/sim/executor/handlers/pi/pi-handler.test.ts b/apps/sim/executor/handlers/pi/pi-handler.test.ts index a236123c412..f79a76a8efc 100644 --- a/apps/sim/executor/handlers/pi/pi-handler.test.ts +++ b/apps/sim/executor/handlers/pi/pi-handler.test.ts @@ -190,29 +190,39 @@ describe('PiBlockHandler', () => { it('projects activated task secrets at the final Pi input boundary', async () => { const registry = new ResolvedSecretTraceRegistry([ { name: 'API_KEY', plaintext: 'secret-value', encryptedValue: 'ciphertext' }, + { name: 'UNUSED', plaintext: 'x', encryptedValue: 'unused-ciphertext' }, ]) - registry.recordResolved('API_KEY', 'secret-value') + registry.recordResolvedAtInputPath('API_KEY', 'secret-value', ['task']) + registry.recordResolvedInputProjection( + ['task'], + 'Use secret-value without changing Box.', + 'Use {{API_KEY}} without changing Box.' + ) + registry.recordResolved('UNUSED', 'x') await handler.execute( ctx({ resolvedSecretTraceRegistry: registry }), block, - localInputs({ task: 'Use secret-value without changing the rest.' }) + localInputs({ task: 'Use secret-value without changing Box.' }) + ) + + expect(mockRunLocal.mock.calls[0][0].task).toBe('Use {{API_KEY}} without changing Box.') + }) + + it('preserves legacy task behavior when no provenance registry exists', async () => { + await handler.execute( + ctx({ resolvedSecretTraceRegistry: undefined }), + block, + localInputs({ task: 'ordinary task' }) ) - expect(mockRunLocal.mock.calls[0][0].task).toBe('Use {{API_KEY}} without changing the rest.') + expect(mockRunLocal.mock.calls[0][0].task).toBe('ordinary task') }) - it.each([ - ['missing', undefined], - [ - 'incomplete', - (() => { - const registry = new ResolvedSecretTraceRegistry() - registry.markIncomplete() - return registry - })(), - ], - ])('fails closed when task provenance is %s', async (_label, registry) => { + it('fails closed when task provenance is incomplete', async () => { + const registry = new ResolvedSecretTraceRegistry() + registry.markIncomplete() + await expect( handler.execute( ctx({ resolvedSecretTraceRegistry: registry }), @@ -682,7 +692,8 @@ describe('PiBlockHandler', () => { expect(mockBuildSearchTool).toHaveBeenCalledWith( expect.anything(), { provider: 'exa', apiKey: 'search-key' }, - 'local' + 'local', + 'search-key' ) expect(mockRunLocal.mock.calls[0][0].search).toEqual({ provider: 'exa', @@ -691,6 +702,29 @@ describe('PiBlockHandler', () => { }) }) + it('replays search-key normalization on the resolver-recorded projection', async () => { + mockParseSearchProvider.mockReturnValue('exa') + mockResolveSearchKey.mockImplementation(({ apiKey }: { apiKey?: string }) => apiKey?.trim()) + const registry = new ResolvedSecretTraceRegistry([ + { name: 'SEARCH_KEY', plaintext: ' key\n', encryptedValue: 'ciphertext' }, + ]) + registry.recordResolvedAtInputPath('SEARCH_KEY', ' key\n', ['searchApiKey']) + registry.recordResolvedInputProjection(['searchApiKey'], ' key\n', '{{SEARCH_KEY}}') + + await handler.execute( + ctx({ resolvedSecretTraceRegistry: registry }), + block, + localInputs({ searchProvider: 'exa', searchApiKey: ' key\n' }) + ) + + expect(mockBuildSearchTool).toHaveBeenCalledWith( + expect.anything(), + { provider: 'exa', apiKey: 'key' }, + 'local', + '{{SEARCH_KEY}}' + ) + }) + it('builds the host tool for Review Code too', async () => { mockParseSearchProvider.mockReturnValue('serper') @@ -708,7 +742,8 @@ describe('PiBlockHandler', () => { expect(mockBuildSearchTool).toHaveBeenCalledWith( expect.anything(), expect.objectContaining({ provider: 'serper' }), - 'cloud_review' + 'cloud_review', + 'search-key' ) expect(mockRunCloudReview.mock.calls[0][0].search.tool).toEqual({ name: 'web_search' }) }) diff --git a/apps/sim/executor/handlers/pi/pi-handler.ts b/apps/sim/executor/handlers/pi/pi-handler.ts index 7a847270ee9..5e48b9a47b4 100644 --- a/apps/sim/executor/handlers/pi/pi-handler.ts +++ b/apps/sim/executor/handlers/pi/pi-handler.ts @@ -7,6 +7,7 @@ */ import { createLogger } from '@sim/logger' +import { projectResolvedModelInput } from '@/lib/execution/model-input-provenance' import type { BlockOutput } from '@/blocks/types' import { parseOptionalNumberInput } from '@/blocks/utils' import { @@ -50,7 +51,6 @@ import type { NormalizedBlockOutput, StreamingExecution, } from '@/executor/types' -import { projectResolvedSecretModelContent } from '@/executor/utils/resolved-secret-content-projection' import { isPiSupportedProvider, resolvePiModelId } from '@/providers/pi-providers' import { getProviderFromModel } from '@/providers/utils' import type { SerializedBlock } from '@/serializer/types' @@ -163,14 +163,15 @@ export class PiBlockHandler implements BlockHandler { const mode = parsePiMode(inputs.mode) const resolvedTask = asOptString(inputs.task) if (!resolvedTask) throw new Error('Task is required') - const taskProjection = projectResolvedSecretModelContent( - resolvedTask, - ctx.resolvedSecretTraceRegistry + const taskProjection = projectResolvedModelInput( + ctx.resolvedSecretTraceRegistry, + { task: resolvedTask }, + [['task']] ) - if (!taskProjection.safe || typeof taskProjection.value !== 'string') { + if (!taskProjection.complete || typeof taskProjection.value.task !== 'string') { throw new Error('Pi input could not be safely projected') } - const task = taskProjection.value + const task = taskProjection.value.task const model = asOptString(inputs.model) ?? DEFAULT_MODEL const providerId = getProviderFromModel(model) @@ -400,15 +401,33 @@ export class PiBlockHandler implements BlockHandler { throw error } + const rawSearchApiKey = inputs.searchApiKey const apiKey = resolvePiSearchKey({ provider, - apiKey: asOptString(inputs.searchApiKey), + apiKey: asOptString(rawSearchApiKey), }) - const credentials = { provider, apiKey } - return mode === 'cloud' || mode === 'cloud_branch' - ? credentials - : { ...credentials, tool: buildPiSearchToolSpec(ctx, credentials, mode) } + if (mode === 'cloud' || mode === 'cloud_branch') return credentials + + const searchInputProjection = projectResolvedModelInput( + ctx.resolvedSecretTraceRegistry, + { searchApiKey: rawSearchApiKey }, + [['searchApiKey']] + ) + if (!searchInputProjection.complete) { + throw new Error('Pi search input could not be safely projected') + } + const projectedApiKey = Object.is(searchInputProjection.value.searchApiKey, rawSearchApiKey) + ? apiKey + : resolvePiSearchKey({ + provider, + apiKey: asOptString(searchInputProjection.value.searchApiKey), + }) + + return { + ...credentials, + tool: buildPiSearchToolSpec(ctx, credentials, mode, projectedApiKey), + } } private isContentSelectedForStreaming(ctx: ExecutionContext, block: SerializedBlock): boolean { diff --git a/apps/sim/executor/handlers/pi/search/tool.test.ts b/apps/sim/executor/handlers/pi/search/tool.test.ts index 30ec5f97b83..d73175dcf74 100644 --- a/apps/sim/executor/handlers/pi/search/tool.test.ts +++ b/apps/sim/executor/handlers/pi/search/tool.test.ts @@ -1,11 +1,15 @@ /** * @vitest-environment node */ +import { encryptionMockFns } from '@sim/testing' import { beforeEach, describe, expect, it, vi } from 'vitest' const { mockExecuteTool } = vi.hoisted(() => ({ mockExecuteTool: vi.fn() })) vi.mock('@/tools', () => ({ executeTool: mockExecuteTool })) +vi.mock('@/lib/core/security/encryption', () => ({ + decryptSecret: encryptionMockFns.mockDecryptSecret, +})) import { PI_SEARCH_BUDGET_MESSAGE, @@ -44,6 +48,7 @@ async function run( beforeEach(() => { vi.clearAllMocks() + encryptionMockFns.mockDecryptSecret.mockReset() }) describe('buildPiSearchToolSpec', () => { @@ -117,20 +122,30 @@ describe('buildPiSearchToolSpec', () => { const registry = new ResolvedSecretTraceRegistry([ { name: 'SEARCH_QUERY', plaintext: secret, encryptedValue: 'ciphertext' }, ]) - registry.recordResolved('SEARCH_QUERY', secret) const mergeSpy = vi.spyOn(registry, 'mergeToolCallRegistry') const context = executionContext(registry) - mockExecuteTool.mockResolvedValue({ - success: true, - output: { - results: [ - { - title: secret, - url: 'https://example.com/docs', - text: `Bearer ${secret}`, - }, - ], - }, + encryptionMockFns.mockDecryptSecret.mockResolvedValue({ decrypted: secret }) + mockExecuteTool.mockImplementation(async (_toolId, _params, options) => { + await options.resolvedSecretTraceRegistry.importProvenance( + { + version: 1, + complete: true, + entries: [{ name: 'SEARCH_QUERY', encryptedValue: 'ciphertext' }], + }, + { trusted: true } + ) + return { + success: true, + output: { + results: [ + { + title: secret, + url: 'https://example.com/docs', + text: `Bearer ${secret}`, + }, + ], + }, + } }) const result = await buildTool('exa', context).execute({ query: secret }) @@ -176,13 +191,67 @@ describe('buildPiSearchToolSpec', () => { }) }) + it('projects only the exact resolver-recorded search key and leaves the raw result unchanged', async () => { + const registry = new ResolvedSecretTraceRegistry([ + { name: 'SEARCH_KEY', plaintext: 'key-123', encryptedValue: 'search-ciphertext' }, + { name: 'UNRELATED', plaintext: 'Test', encryptedValue: 'unrelated-ciphertext' }, + ]) + registry.recordResolvedAtInputPath('SEARCH_KEY', 'key-123', ['searchApiKey']) + registry.recordResolvedInputProjection(['searchApiKey'], 'key-123', '{{SEARCH_KEY}}') + registry.recordResolvedAtInputPath('UNRELATED', 'Test', ['task']) + registry.recordResolvedInputProjection(['task'], 'Test', '{{UNRELATED}}') + const output = { + results: [ + { + title: 'key-123', + url: 'https://example.com/docs', + text: 'Test', + }, + ], + } + mockExecuteTool.mockResolvedValue({ success: true, output }) + + const result = await buildPiSearchToolSpec( + executionContext(registry), + { provider: 'exa', apiKey: 'key-123' }, + 'local', + '{{SEARCH_KEY}}' + ).execute({ query: 'pi' }) + + expect(JSON.parse(result.text).results[0]).toEqual({ + title: '{{SEARCH_KEY}}', + url: 'https://example.com/docs', + snippet: 'Test', + }) + expect( + mockExecuteTool.mock.calls[0][2].resolvedSecretTraceRegistry + .exportCommittedProvenanceForInputPaths([['apiKey']]) + .entries.map((entry: { name?: string }) => entry.name) + ).toEqual(['SEARCH_KEY']) + expect(output).toEqual({ + results: [ + { + title: 'key-123', + url: 'https://example.com/docs', + text: 'Test', + }, + ], + }) + }) + it('projects anonymous provenance learned by the isolated search call', async () => { const registry = new ResolvedSecretTraceRegistry() + encryptionMockFns.mockDecryptSecret.mockResolvedValue({ decrypted: 'foreign-secret' }) mockExecuteTool.mockImplementation(async (_toolId, _params, options) => { - vi.spyOn(options.resolvedSecretTraceRegistry, 'getModelEgressSnapshot').mockReturnValue({ - complete: true, - matches: [{ plaintext: 'foreign-secret', replacement: '[REDACTED_SECRET]' }], - }) + await options.resolvedSecretTraceRegistry.importProvenance( + { + version: 1, + complete: true, + entries: [{ name: 'FOREIGN', encryptedValue: 'foreign-ciphertext' }], + scope: { userId: 'foreign-user', workspaceId: 'foreign-workspace' }, + }, + { trusted: true } + ) return { success: true, output: { @@ -202,24 +271,31 @@ describe('buildPiSearchToolSpec', () => { expect(JSON.parse(result.text).results[0].snippet).toBe('[REDACTED_SECRET]') }) - it.each([ - ['missing', undefined], - [ - 'incomplete', - (() => { - const registry = new ResolvedSecretTraceRegistry() - registry.markIncomplete() - return registry - })(), - ], - ])('fails closed before search when provenance is %s', async (_label, registry) => { - const context = registry - ? executionContext(registry) - : ({ executionId: 'exec-1', workspaceId: 'ws-1' } as ExecutionContext) + it('preserves legacy search behavior when no provenance registry exists', async () => { + const output = { + results: [{ title: 'Docs', url: 'https://example.com/docs', text: 'Page text' }], + } + mockExecuteTool.mockResolvedValue({ success: true, output }) + const context = { executionId: 'exec-1', workspaceId: 'ws-1' } as ExecutionContext + const result = await buildTool('exa', context).execute({ query: 'pi' }) + expect(result.isError).toBe(false) + expect(JSON.parse(result.text).results[0].snippet).toBe('Page text') + expect(mockExecuteTool.mock.calls[0][2].resolvedSecretTraceRegistry).toBeUndefined() + expect(output.results[0].text).toBe('Page text') + }) + + it('fails closed before search when provenance is incomplete', async () => { + const registry = new ResolvedSecretTraceRegistry() + registry.markIncomplete() + + const result = await buildTool('exa', executionContext(registry)).execute({ query: 'pi' }) + expect(result.isError).toBe(true) - expect(result.text).toContain('could not be returned safely') + expect(result.text).toBe( + 'Web search settled, but its result could not be returned safely. Do not retry automatically.' + ) expect(mockExecuteTool).not.toHaveBeenCalled() }) @@ -243,6 +319,33 @@ describe('buildPiSearchToolSpec', () => { expect(mergeSpy).not.toHaveBeenCalled() }) + it('keeps the fixed unavailable message unchanged when active provenance contains one character', async () => { + encryptionMockFns.mockDecryptSecret.mockResolvedValue({ decrypted: 'W' }) + const registry = new ResolvedSecretTraceRegistry([ + { name: 'LETTER', plaintext: 'W', encryptedValue: 'encrypted-letter' }, + ]) + const cyclic: Record = {} + cyclic.self = cyclic + mockExecuteTool.mockImplementation(async (_toolId, _params, options) => { + await options.resolvedSecretTraceRegistry.importProvenance( + { + version: 1, + complete: true, + entries: [{ name: 'LETTER', encryptedValue: 'encrypted-letter' }], + }, + { trusted: true } + ) + return { success: true, output: cyclic } + }) + + const result = await buildTool('exa', executionContext(registry)).execute({ query: 'pi' }) + + expect(result).toEqual({ + text: 'Web search settled, but its result could not be returned safely. Do not retry automatically.', + isError: true, + }) + }) + it('reports an empty search as a successful no-results envelope', async () => { mockExecuteTool.mockResolvedValue({ success: true, output: { results: [] } }) diff --git a/apps/sim/executor/handlers/pi/search/tool.ts b/apps/sim/executor/handlers/pi/search/tool.ts index d86ff6d546d..19d34b1ea1e 100644 --- a/apps/sim/executor/handlers/pi/search/tool.ts +++ b/apps/sim/executor/handlers/pi/search/tool.ts @@ -25,25 +25,19 @@ import { serializePiSearchEnvelope, } from '@/executor/handlers/pi/search/normalize' import type { ExecutionContext } from '@/executor/types' -import { - projectResolvedSecretModelContent, - projectResolvedSecretModelControlMessage, -} from '@/executor/utils/resolved-secret-content-projection' -import type { ResolvedSecretTraceRegistry } from '@/executor/utils/resolved-secret-trace-registry' +import { projectResolvedSecretModelContent } from '@/executor/utils/resolved-secret-content-projection' import { executeTool } from '@/tools' const logger = createLogger('PiSearchTool') const SEARCH_RESULT_UNAVAILABLE_MESSAGE = 'Web search settled, but its result could not be returned safely. Do not retry automatically.' -function unavailableSearchResult(registry: ResolvedSecretTraceRegistry | undefined): { +function unavailableSearchResult(): { text: string isError: true } { return { - text: - projectResolvedSecretModelControlMessage(SEARCH_RESULT_UNAVAILABLE_MESSAGE, registry) ?? - SEARCH_RESULT_UNAVAILABLE_MESSAGE, + text: SEARCH_RESULT_UNAVAILABLE_MESSAGE, isError: true, } } @@ -87,7 +81,8 @@ function describePiSearchFailure(label: string, status: unknown, error: unknown) export function buildPiSearchToolSpec( ctx: ExecutionContext, search: Pick, - mode: 'local' | 'cloud_review' + mode: 'local' | 'cloud_review', + projectedApiKey = search.apiKey ): PiToolSpec { const { label, toolId } = PI_SEARCH_PROVIDERS[search.provider] const logContext = { @@ -122,9 +117,18 @@ export function buildPiSearchToolSpec( timeout: PI_SEARCH_TIMEOUT_MS, } const registry = ctx.resolvedSecretTraceRegistry - const toolCallRegistry = registry?.forkForToolInputValues(Object.values(providerParams)) - if (!registry || !toolCallRegistry?.isComplete()) { - return unavailableSearchResult(toolCallRegistry) + const toolCallRegistry = registry?.forkForInputPaths([['searchApiKey']], { + propagated: true, + }) + if (toolCallRegistry && !toolCallRegistry.isComplete()) { + return unavailableSearchResult() + } + if (toolCallRegistry) { + toolCallRegistry.recordTransformedInputProjection(providerParams, { + ...providerParams, + apiKey: projectedApiKey, + }) + if (!toolCallRegistry.isComplete()) return unavailableSearchResult() } const result = await executeTool(toolId, providerParams, { @@ -133,18 +137,18 @@ export function buildPiSearchToolSpec( }) if (!result.success) { - if (!toolCallRegistry.isComplete()) { - return unavailableSearchResult(toolCallRegistry) + if (toolCallRegistry && !toolCallRegistry.isComplete()) { + return unavailableSearchResult() } if (result.error === PARALLEL_EMPTY_RESULTS_ERROR) { logger.info('Pi search returned no results', { ...logContext, resultCount: 0 }) - registry.mergeToolCallRegistry(toolCallRegistry) + if (registry && toolCallRegistry) registry.mergeToolCallRegistry(toolCallRegistry) return { text: serializePiSearchEnvelope([]), isError: false } } const status = (result.output as { status?: unknown } | undefined)?.status logger.warn('Pi search failed', { ...logContext, status }) - registry.mergeToolCallRegistry(toolCallRegistry) + if (registry && toolCallRegistry) registry.mergeToolCallRegistry(toolCallRegistry) return { // Classified rather than quoted: `result.error` can carry provider-response-derived text // for all four providers, which the untrusted-results guideline does not cover. Only the @@ -155,9 +159,14 @@ export function buildPiSearchToolSpec( } } - const outputProjection = projectResolvedSecretModelContent(result.output, toolCallRegistry) - if (!outputProjection.safe || !toolCallRegistry.isComplete()) { - return unavailableSearchResult(toolCallRegistry) + const outputProjection = toolCallRegistry + ? projectResolvedSecretModelContent( + result.output, + toolCallRegistry.forkForPropagatedEntries() + ) + : ({ safe: true, value: result.output } as const) + if (!outputProjection.safe || (toolCallRegistry && !toolCallRegistry.isComplete())) { + return unavailableSearchResult() } const results = normalizePiSearchRecords( search.provider, @@ -165,7 +174,7 @@ export function buildPiSearchToolSpec( numResults ) logger.info('Pi search completed', { ...logContext, resultCount: results.length }) - registry.mergeToolCallRegistry(toolCallRegistry) + if (registry && toolCallRegistry) registry.mergeToolCallRegistry(toolCallRegistry) return { text: serializePiSearchEnvelope(results), isError: false } }, } diff --git a/apps/sim/executor/handlers/pi/sim-tools.test.ts b/apps/sim/executor/handlers/pi/sim-tools.test.ts index 2e1760cfbcc..a84deea656c 100644 --- a/apps/sim/executor/handlers/pi/sim-tools.test.ts +++ b/apps/sim/executor/handlers/pi/sim-tools.test.ts @@ -1,6 +1,7 @@ /** * @vitest-environment node */ +import { encryptionMockFns } from '@sim/testing' import { beforeEach, describe, expect, it, vi } from 'vitest' const { mockTransformBlockTool, mockExecuteTool } = vi.hoisted(() => ({ @@ -12,6 +13,9 @@ vi.mock('@/providers/utils', () => ({ transformBlockTool: mockTransformBlockTool vi.mock('@/tools', () => ({ executeTool: mockExecuteTool })) vi.mock('@/tools/utils', () => ({ getTool: vi.fn() })) vi.mock('@/tools/utils.server', () => ({ getToolAsync: vi.fn() })) +vi.mock('@/lib/core/security/encryption', () => ({ + decryptSecret: encryptionMockFns.mockDecryptSecret, +})) import { buildSimToolSpecs } from '@/executor/handlers/pi/sim-tools' import type { ExecutionContext } from '@/executor/types' @@ -44,6 +48,7 @@ function mockToolAdapter(params: Record = {}): void { describe('buildSimToolSpecs', () => { beforeEach(() => { vi.clearAllMocks() + encryptionMockFns.mockDecryptSecret.mockReset() }) it('names the Pi tool with the snake_case tool id, not the human label', async () => { @@ -122,9 +127,20 @@ describe('buildSimToolSpecs', () => { it('projects named provenance in successful Sim tool output', async () => { mockToolAdapter({ apiKey: 'secret-value' }) - mockExecuteTool.mockResolvedValue({ - success: true, - output: { authorization: 'Bearer secret-value' }, + encryptionMockFns.mockDecryptSecret.mockResolvedValue({ decrypted: 'secret-value' }) + mockExecuteTool.mockImplementation(async (_toolId, _params, options) => { + await options.resolvedSecretTraceRegistry.importProvenance( + { + version: 1, + complete: true, + entries: [{ name: 'API_KEY', encryptedValue: 'ciphertext' }], + }, + { trusted: true } + ) + return { + success: true, + output: { authorization: 'Bearer secret-value' }, + } }) const registry = new ResolvedSecretTraceRegistry([ { name: 'API_KEY', plaintext: 'secret-value', encryptedValue: 'ciphertext' }, @@ -141,11 +157,17 @@ describe('buildSimToolSpecs', () => { it('uses the anonymous fallback for cross-scope provenance', async () => { mockToolAdapter() + encryptionMockFns.mockDecryptSecret.mockResolvedValue({ decrypted: 'foreign-secret' }) mockExecuteTool.mockImplementation(async (_toolId, _params, options) => { - vi.spyOn(options.resolvedSecretTraceRegistry, 'getModelEgressSnapshot').mockReturnValue({ - complete: true, - matches: [{ plaintext: 'foreign-secret', replacement: '[REDACTED_SECRET]' }], - }) + await options.resolvedSecretTraceRegistry.importProvenance( + { + version: 1, + complete: true, + entries: [{ name: 'FOREIGN', encryptedValue: 'foreign-ciphertext' }], + scope: { userId: 'foreign-user', workspaceId: 'foreign-workspace' }, + }, + { trusted: true } + ) return { success: true, output: { token: 'foreign-secret' }, @@ -189,54 +211,152 @@ describe('buildSimToolSpecs', () => { await expect(spec.execute({})).resolves.toEqual({ text: 'Test', isError: false }) }) + it('projects only the selected tool params by original array index and leaves raw output unchanged', async () => { + const selectedTool = { + type: 'exa', + operation: 'exa_search', + usageControl: 'auto', + params: { apiKey: 'secret-value' }, + } + const tools = [{ type: 'exa', operation: 'exa_search', usageControl: 'none' }, selectedTool] + mockToolAdapter(selectedTool.params) + const output = { selected: 'secret-value', unrelated: 'Test' } + mockExecuteTool.mockResolvedValue({ success: true, output }) + const registry = new ResolvedSecretTraceRegistry([ + { name: 'API_KEY', plaintext: 'secret-value', encryptedValue: 'ciphertext' }, + { name: 'UNRELATED', plaintext: 'Test', encryptedValue: 'unrelated-ciphertext' }, + ]) + registry.recordResolvedAtInputPath('API_KEY', 'secret-value', [ + 'tools', + '1', + 'params', + 'apiKey', + ]) + registry.recordResolvedInputProjection( + ['tools', '1', 'params', 'apiKey'], + 'secret-value', + '{{API_KEY}}' + ) + registry.recordResolvedAtInputPath('UNRELATED', 'Test', ['task']) + registry.recordResolvedInputProjection(['task'], 'Test', '{{UNRELATED}}') + + const [spec] = await buildSimToolSpecs(executionContext(registry), tools) + + await expect(spec.execute({ query: 'pi' })).resolves.toEqual({ + text: JSON.stringify({ selected: '{{API_KEY}}', unrelated: 'Test' }), + isError: false, + }) + expect( + mockExecuteTool.mock.calls[0][2].resolvedSecretTraceRegistry + .exportCommittedProvenanceForInputPaths([['apiKey']]) + .entries.map((entry: { name?: string }) => entry.name) + ).toEqual(['API_KEY']) + expect(output).toEqual({ selected: 'secret-value', unrelated: 'Test' }) + }) + it('projects error text returned or thrown by a Sim tool', async () => { mockToolAdapter({ apiKey: 'secret-value' }) + encryptionMockFns.mockDecryptSecret.mockResolvedValue({ decrypted: 'secret-value' }) const registry = new ResolvedSecretTraceRegistry([ { name: 'API_KEY', plaintext: 'secret-value', encryptedValue: 'ciphertext' }, ]) - registry.recordResolved('API_KEY', 'secret-value') const [spec] = await buildSimToolSpecs(executionContext(registry), toolInput) - mockExecuteTool.mockResolvedValueOnce({ - success: false, - output: {}, - error: 'provider rejected secret-value', + mockExecuteTool.mockImplementationOnce(async (_toolId, _params, options) => { + await options.resolvedSecretTraceRegistry.importProvenance( + { + version: 1, + complete: true, + entries: [{ name: 'API_KEY', encryptedValue: 'ciphertext' }], + }, + { trusted: true } + ) + return { + success: false, + output: {}, + error: 'provider rejected secret-value', + } }) await expect(spec.execute({})).resolves.toEqual({ text: 'provider rejected {{API_KEY}}', isError: true, }) - mockExecuteTool.mockRejectedValueOnce(new Error('transport exposed secret-value')) + mockExecuteTool.mockImplementationOnce(async (_toolId, _params, options) => { + await options.resolvedSecretTraceRegistry.importProvenance( + { + version: 1, + complete: true, + entries: [{ name: 'API_KEY', encryptedValue: 'ciphertext' }], + }, + { trusted: true } + ) + throw new Error('transport exposed secret-value') + }) await expect(spec.execute({})).resolves.toEqual({ text: 'transport exposed {{API_KEY}}', isError: true, }) }) - it.each([ - ['missing', undefined], - [ - 'incomplete', - (() => { - const registry = new ResolvedSecretTraceRegistry() - registry.markIncomplete() - return registry - })(), - ], - ])('fails closed when Sim tool result provenance is %s', async (_label, registry) => { + it('preserves legacy Sim tool behavior when no provenance registry exists', async () => { + mockToolAdapter() + const output = { result: 'ordinary output' } + mockExecuteTool.mockResolvedValue({ success: true, output }) + const [spec] = await buildSimToolSpecs(executionContext(undefined), toolInput) + + await expect(spec.execute({})).resolves.toEqual({ + text: JSON.stringify(output), + isError: false, + }) + expect(mockExecuteTool.mock.calls[0][2].resolvedSecretTraceRegistry).toBeUndefined() + expect(output).toEqual({ result: 'ordinary output' }) + }) + + it('fails closed when Sim tool result provenance is incomplete', async () => { mockToolAdapter() mockExecuteTool.mockResolvedValue({ success: true, output: { result: 'untrusted output' }, }) + const registry = new ResolvedSecretTraceRegistry() + registry.markIncomplete() const [spec] = await buildSimToolSpecs(executionContext(registry), toolInput) const result = await spec.execute({}) expect(result.isError).toBe(true) - expect(result.text).toContain('could not be returned safely') + expect(result.text).toBe( + 'Tool execution settled, but its result could not be returned safely. Do not retry a mutation automatically.' + ) expect(result.text).not.toContain('untrusted output') expect(mockExecuteTool).not.toHaveBeenCalled() }) + + it('keeps the fixed unavailable message unchanged when active provenance contains one character', async () => { + mockToolAdapter() + encryptionMockFns.mockDecryptSecret.mockResolvedValue({ decrypted: 'T' }) + const registry = new ResolvedSecretTraceRegistry([ + { name: 'LETTER', plaintext: 'T', encryptedValue: 'encrypted-letter' }, + ]) + const cyclic: Record = {} + cyclic.self = cyclic + mockExecuteTool.mockImplementation(async (_toolId, _params, options) => { + await options.resolvedSecretTraceRegistry.importProvenance( + { + version: 1, + complete: true, + entries: [{ name: 'LETTER', encryptedValue: 'encrypted-letter' }], + }, + { trusted: true } + ) + return { success: true, output: cyclic } + }) + const [spec] = await buildSimToolSpecs(executionContext(registry), toolInput) + + await expect(spec.execute({})).resolves.toEqual({ + text: 'Tool execution settled, but its result could not be returned safely. Do not retry a mutation automatically.', + isError: true, + }) + }) }) diff --git a/apps/sim/executor/handlers/pi/sim-tools.ts b/apps/sim/executor/handlers/pi/sim-tools.ts index f49c511ce48..8f249ac1c94 100644 --- a/apps/sim/executor/handlers/pi/sim-tools.ts +++ b/apps/sim/executor/handlers/pi/sim-tools.ts @@ -14,10 +14,7 @@ import { getAllBlocks } from '@/blocks/registry' import type { ToolInput } from '@/executor/handlers/agent/types' import type { PiToolResult, PiToolSpec } from '@/executor/handlers/pi/backend' import type { ExecutionContext } from '@/executor/types' -import { - projectResolvedSecretModelContent, - projectResolvedSecretModelControlMessage, -} from '@/executor/utils/resolved-secret-content-projection' +import { projectResolvedSecretModelContent } from '@/executor/utils/resolved-secret-content-projection' import type { ResolvedSecretTraceRegistry } from '@/executor/utils/resolved-secret-trace-registry' import { transformBlockTool } from '@/providers/utils' import { executeTool } from '@/tools' @@ -30,12 +27,11 @@ import { getToolAsync } from '@/tools/utils.server' const logger = createLogger('PiSimTools') const TOOL_RESULT_UNAVAILABLE_MESSAGE = 'Tool execution settled, but its result could not be returned safely. Do not retry a mutation automatically.' +const TOOL_EXECUTION_FAILED_MESSAGE = 'Tool execution failed' -function unavailableToolResult(registry: ResolvedSecretTraceRegistry | undefined): PiToolResult { +function unavailableToolResult(): PiToolResult { return { - text: - projectResolvedSecretModelControlMessage(TOOL_RESULT_UNAVAILABLE_MESSAGE, registry) ?? - TOOL_RESULT_UNAVAILABLE_MESSAGE, + text: TOOL_RESULT_UNAVAILABLE_MESSAGE, isError: true, } } @@ -49,10 +45,28 @@ function projectToolResult( registry: ResolvedSecretTraceRegistry | undefined ): PiToolResultProjection { try { + if (!registry) { + if (!result.success) { + return { + safe: true, + result: { + text: result.error || TOOL_EXECUTION_FAILED_MESSAGE, + isError: true, + }, + } + } + + const text = + typeof result.output === 'string' ? result.output : JSON.stringify(result.output ?? {}) + return typeof text === 'string' + ? { safe: true, result: { text, isError: false } } + : { safe: false, result: unavailableToolResult() } + } + if (result.success) { const projection = projectResolvedSecretModelContent(result.output, registry) if (!projection.safe) { - return { safe: false, result: unavailableToolResult(registry) } + return { safe: false, result: unavailableToolResult() } } const text = @@ -61,18 +75,24 @@ function projectToolResult( : JSON.stringify(projection.value ?? {}) return typeof text === 'string' ? { safe: true, result: { text, isError: false } } - : { safe: false, result: unavailableToolResult(registry) } + : { safe: false, result: unavailableToolResult() } + } + + if (!result.error) { + return registry.isComplete() + ? { + safe: true, + result: { text: TOOL_EXECUTION_FAILED_MESSAGE, isError: true }, + } + : { safe: false, result: unavailableToolResult() } } - const projection = projectResolvedSecretModelContent( - result.error || 'Tool execution failed', - registry - ) + const projection = projectResolvedSecretModelContent(result.error, registry) return projection.safe && typeof projection.value === 'string' ? { safe: true, result: { text: projection.value, isError: true } } - : { safe: false, result: unavailableToolResult(registry) } + : { safe: false, result: unavailableToolResult() } } catch { - return { safe: false, result: unavailableToolResult(registry) } + return { safe: false, result: unavailableToolResult() } } } @@ -88,7 +108,7 @@ export async function buildSimToolSpecs( const specs: PiToolSpec[] = [] - for (const tool of inputTools as ToolInput[]) { + for (const [toolIndex, tool] of (inputTools as ToolInput[]).entries()) { if ((tool.usageControl || 'auto') === 'none') continue if (!tool.type || tool.type === 'mcp' || tool.type === 'custom-tool') continue @@ -123,9 +143,30 @@ export async function buildSimToolSpecs( execute: async (args) => { const params = mergeToolParameters(preseededParams, args as Record) const registry = ctx.resolvedSecretTraceRegistry - const toolCallRegistry = registry?.forkForToolInputValues(Object.values(params)) - if (!registry || !toolCallRegistry?.isComplete()) { - return unavailableToolResult(toolCallRegistry) + const sourcePath = ['tools', String(toolIndex), 'params'] as const + const toolCallRegistry = registry?.forkForInputPaths([sourcePath], { + propagated: true, + }) + if (toolCallRegistry && !toolCallRegistry.isComplete()) { + return unavailableToolResult() + } + + if (toolCallRegistry) { + const inputProjection = toolCallRegistry.projectResolvedInputSelection({ + tools: inputTools, + }) + const projectedTool = inputProjection.complete + ? (inputProjection.value.tools as ToolInput[] | undefined)?.[toolIndex] + : undefined + if (!inputProjection.complete || !projectedTool) { + return unavailableToolResult() + } + const projectedParams = mergeToolParameters( + projectedTool.params || {}, + args as Record + ) + toolCallRegistry.recordTransformedInputProjection(params, projectedParams) + if (!toolCallRegistry.isComplete()) return unavailableToolResult() } try { @@ -156,8 +197,11 @@ export async function buildSimToolSpecs( resolvedSecretTraceRegistry: toolCallRegistry, } ) - const projection = projectToolResult(result, toolCallRegistry) - if (projection.safe && toolCallRegistry.isComplete()) { + const projection = projectToolResult( + result, + toolCallRegistry?.forkForPropagatedEntries() + ) + if (projection.safe && registry && toolCallRegistry?.isComplete()) { registry.mergeToolCallRegistry(toolCallRegistry) } return projection.result @@ -168,9 +212,9 @@ export async function buildSimToolSpecs( output: {}, error: getErrorMessage(error, 'Tool execution failed'), }, - toolCallRegistry + toolCallRegistry?.forkForPropagatedEntries() ) - if (projection.safe && toolCallRegistry.isComplete()) { + if (projection.safe && registry && toolCallRegistry?.isComplete()) { registry.mergeToolCallRegistry(toolCallRegistry) } return projection.result diff --git a/apps/sim/executor/handlers/router/router-handler.test.ts b/apps/sim/executor/handlers/router/router-handler.test.ts index db605abaa1a..c8121843ab5 100644 --- a/apps/sim/executor/handlers/router/router-handler.test.ts +++ b/apps/sim/executor/handlers/router/router-handler.test.ts @@ -1,7 +1,12 @@ import '@sim/testing/mocks/executor' import { createLogger } from '@sim/logger' -import { authOAuthUtilsMock, authOAuthUtilsMockFns } from '@sim/testing' +import { + authOAuthUtilsMock, + authOAuthUtilsMockFns, + encryptionMock, + encryptionMockFns, +} from '@sim/testing' import { beforeEach, describe, expect, it, type Mock, vi } from 'vitest' const { mockResolveAutoModel } = vi.hoisted(() => ({ @@ -9,6 +14,7 @@ const { mockResolveAutoModel } = vi.hoisted(() => ({ })) vi.mock('@/app/api/auth/oauth/utils', () => authOAuthUtilsMock) +vi.mock('@/lib/core/security/encryption', () => encryptionMock) vi.mock('@/lib/credentials/access', () => ({ getCredentialActorContext: vi.fn().mockResolvedValue({ @@ -32,7 +38,11 @@ vi.mock('@/lib/model-router/resolve', () => ({ SIM_AUTO_SYSTEM_PREAMBLE: 'Sim auto system preamble', })) -import { PRIVATE_MODEL_INPUT_PROVENANCE_HEADER } from '@/lib/execution/model-input-provenance' +import { + PRIVATE_MODEL_INPUT_PROVENANCE_HEADER, + PRIVATE_MODEL_INPUT_STATE_HEADER, + PROJECTED_MODEL_INPUT_PATHS_V1, +} from '@/lib/execution/model-input-provenance' import { RESOLVED_SECRET_PROVENANCE_FIELD, RESOLVED_SECRET_PROVENANCE_METADATA_V1, @@ -124,6 +134,7 @@ describe('RouterBlockHandler', () => { } vi.clearAllMocks() + encryptionMockFns.mockDecryptSecret.mockResolvedValue({ decrypted: 'test-decrypted' }) // unstubGlobals removes any module-scope fetch stub before each test, so re-stub here vi.stubGlobal('fetch', mockFetch) @@ -258,7 +269,8 @@ describe('RouterBlockHandler', () => { encryptedValue: 'encrypted-router-credential', }, ]) - registry.recordResolved('PROMPT_SECRET', promptSecret) + registry.recordResolvedAtInputPath('PROMPT_SECRET', promptSecret, ['prompt']) + registry.recordResolvedInputProjection(['prompt'], promptSecret, '{{PROMPT_SECRET}}') registry.recordResolved('API_KEY', credentialSecret) mockContext.resolvedSecretTraceRegistry = registry @@ -273,6 +285,9 @@ describe('RouterBlockHandler', () => { expect((request.headers as Headers).get(PRIVATE_MODEL_INPUT_PROVENANCE_HEADER)).toBe( RESOLVED_SECRET_PROVENANCE_METADATA_V1 ) + expect((request.headers as Headers).get(PRIVATE_MODEL_INPUT_STATE_HEADER)).toBe( + PROJECTED_MODEL_INPUT_PATHS_V1 + ) expect(requestBody[RESOLVED_SECRET_PROVENANCE_FIELD]).toEqual({ version: 1, complete: true, @@ -284,6 +299,100 @@ describe('RouterBlockHandler', () => { ], }) expect(requestBody.apiKey).toBe(credentialSecret) + expect(mockGenerateRouterPrompt).toHaveBeenCalledWith('{{PROMPT_SECRET}}', expect.any(Array)) + }) + + it('omits a prior target state when only aggregate secret provenance is available', async () => { + const stateSecret = 'x' + const encryptedStateSecret = 'encrypted-router-state' + const rawState = { result: stateSecret, ordinary: 'Box remains raw state' } + const registry = new ResolvedSecretTraceRegistry([ + { + name: 'STATE_SECRET', + plaintext: stateSecret, + encryptedValue: encryptedStateSecret, + }, + ]) + mockContext.resolvedSecretTraceRegistry = registry + mockContext.blockStates = new Map([ + [ + mockTargetBlock1.id, + { + output: rawState, + executed: true, + executionTime: 1, + resolvedSecretTraceProvenance: { + version: 1, + complete: true, + entries: [{ name: 'STATE_SECRET', encryptedValue: encryptedStateSecret }], + }, + }, + ], + ]) + encryptionMockFns.mockDecryptSecret.mockImplementation(async (encryptedValue: string) => ({ + decrypted: encryptedValue === encryptedStateSecret ? stateSecret : 'test-decrypted', + })) + + await handler.execute(mockContext, mockBlock, { + prompt: 'Choose the best option.', + model: 'gpt-4o', + }) + + expect(mockGenerateRouterPrompt).toHaveBeenCalledWith( + 'Choose the best option.', + expect.arrayContaining([ + expect.objectContaining({ + id: mockTargetBlock1.id, + subBlocks: expect.objectContaining({ p: 'a' }), + currentState: undefined, + }), + ]) + ) + expect(rawState).toEqual({ result: stateSecret, ordinary: 'Box remains raw state' }) + expect(mockTargetBlock1.config.params).toEqual({ p: 'a' }) + + const requestBody = JSON.parse(mockFetch.mock.calls[0][1].body) + expect(requestBody[RESOLVED_SECRET_PROVENANCE_FIELD]).toEqual({ + version: 1, + complete: true, + entries: [], + }) + }) + + it('keeps an ordinary prior target state with exact-empty provenance unchanged', async () => { + const rawState = { result: 'x', ordinary: 'Box remains raw state' } + mockContext.resolvedSecretTraceRegistry = new ResolvedSecretTraceRegistry([]) + mockContext.blockStates = new Map([ + [ + mockTargetBlock1.id, + { + output: rawState, + executed: true, + executionTime: 1, + resolvedSecretTraceProvenance: { + version: 1, + complete: true, + entries: [], + }, + }, + ], + ]) + + await handler.execute(mockContext, mockBlock, { + prompt: 'Choose the best option.', + model: 'gpt-4o', + }) + + expect(mockGenerateRouterPrompt).toHaveBeenCalledWith( + 'Choose the best option.', + expect.arrayContaining([ + expect.objectContaining({ + id: mockTargetBlock1.id, + currentState: rawState, + }), + ]) + ) + expect(rawState).toEqual({ result: 'x', ordinary: 'Box remains raw state' }) }) it('keeps the legacy router request shape when no provenance registry exists', async () => { @@ -297,6 +406,7 @@ describe('RouterBlockHandler', () => { const requestBody = JSON.parse(request.body) expect(Object.hasOwn(requestBody, RESOLVED_SECRET_PROVENANCE_FIELD)).toBe(false) expect((request.headers as Headers).get(PRIVATE_MODEL_INPUT_PROVENANCE_HEADER)).toBeNull() + expect((request.headers as Headers).get(PRIVATE_MODEL_INPUT_STATE_HEADER)).toBeNull() }) it('bills the cost the provider proxy decided rather than recomputing it', async () => { @@ -665,7 +775,8 @@ describe('RouterBlockHandler V2', () => { encryptedValue: 'encrypted-router-v2-credential', }, ]) - registry.recordResolved('CONTEXT_SECRET', contextSecret) + registry.recordResolvedAtInputPath('CONTEXT_SECRET', contextSecret, ['context']) + registry.recordResolvedInputProjection(['context'], contextSecret, '{{CONTEXT_SECRET}}') registry.recordResolved('API_KEY', credentialSecret) mockContext.resolvedSecretTraceRegistry = registry mockFetch.mockResolvedValueOnce({ @@ -690,6 +801,9 @@ describe('RouterBlockHandler V2', () => { expect((request.headers as Headers).get(PRIVATE_MODEL_INPUT_PROVENANCE_HEADER)).toBe( RESOLVED_SECRET_PROVENANCE_METADATA_V1 ) + expect((request.headers as Headers).get(PRIVATE_MODEL_INPUT_STATE_HEADER)).toBe( + PROJECTED_MODEL_INPUT_PATHS_V1 + ) expect(requestBody[RESOLVED_SECRET_PROVENANCE_FIELD]).toEqual({ version: 1, complete: true, @@ -701,6 +815,7 @@ describe('RouterBlockHandler V2', () => { ], }) expect(requestBody.apiKey).toBe(credentialSecret) + expect(mockGenerateRouterV2Prompt).toHaveBeenCalledWith('{{CONTEXT_SECRET}}', expect.any(Array)) }) it('keeps the router V2 request shape when no provenance registry exists', async () => { @@ -725,6 +840,7 @@ describe('RouterBlockHandler V2', () => { const requestBody = JSON.parse(request.body) expect(Object.hasOwn(requestBody, RESOLVED_SECRET_PROVENANCE_FIELD)).toBe(false) expect((request.headers as Headers).get(PRIVATE_MODEL_INPUT_PROVENANCE_HEADER)).toBeNull() + expect((request.headers as Headers).get(PRIVATE_MODEL_INPUT_STATE_HEADER)).toBeNull() }) it('resolves sim-auto before executing router V2 and preserves its public identity', async () => { diff --git a/apps/sim/executor/handlers/router/router-handler.ts b/apps/sim/executor/handlers/router/router-handler.ts index f531cd60f09..365453e64db 100644 --- a/apps/sim/executor/handlers/router/router-handler.ts +++ b/apps/sim/executor/handlers/router/router-handler.ts @@ -3,6 +3,8 @@ import { getInternalApiBaseUrl } from '@/lib/core/utils/urls' import { addModelInputProvenanceToRequest, createModelInputProvenanceRequestMetadata, + markModelInputProjected, + projectResolvedModelInput, } from '@/lib/execution/model-input-provenance' import { type AutoRoutingResult, @@ -22,9 +24,9 @@ import { } from '@/executor/constants' import type { BlockHandler, ExecutionContext } from '@/executor/types' import { buildAuthHeaders } from '@/executor/utils/http' +import type { ResolvedSecretInputPath } from '@/executor/utils/resolved-secret-trace-registry' import { resolveVertexCredential } from '@/executor/utils/vertex-credential' import { resolveProxiedModelCost } from '@/providers/cost-policy' -import { collectProviderModelInputProvenanceValues } from '@/providers/model-input-provenance' import { isAutoModel, SIM_AUTO_MODEL_ID } from '@/providers/models' import type { ProviderRequest } from '@/providers/types' import { getProviderFromModel } from '@/providers/utils' @@ -71,10 +73,19 @@ export class RouterBlockHandler implements BlockHandler { block: SerializedBlock, inputs: Record ): Promise { + const promptModelInputPaths: ResolvedSecretInputPath[] = [['prompt']] + const modelInputProjection = projectResolvedModelInput( + ctx.resolvedSecretTraceRegistry, + { prompt: inputs.prompt }, + promptModelInputPaths + ) + if (!modelInputProjection.complete) { + throw new Error('Router model input could not be safely projected') + } const targetBlocks = this.getTargetBlocks(ctx, block) const routerConfig = { - prompt: inputs.prompt, + prompt: modelInputProjection.value.prompt, model: inputs.model || ROUTER.DEFAULT_MODEL, apiKey: inputs.apiKey, vertexProject: inputs.vertexProject, @@ -131,14 +142,16 @@ export class RouterBlockHandler implements BlockHandler { } const headers = new Headers(await buildAuthHeaders(ctx.userId)) + const modelInputMetadata = createModelInputProvenanceRequestMetadata( + modelInputProjection.registry, + promptModelInputPaths + ) const requestBody = addModelInputProvenanceToRequest( { provider: providerId, ...providerRequest }, headers, - createModelInputProvenanceRequestMetadata( - ctx.resolvedSecretTraceRegistry, - collectProviderModelInputProvenanceValues(providerRequest, providerId) - ) + modelInputMetadata ) + if (modelInputMetadata) markModelInputProjected(headers) const response = await fetch(url.toString(), { method: 'POST', headers, @@ -226,8 +239,31 @@ export class RouterBlockHandler implements BlockHandler { throw new Error('No routes defined for router') } + const modelInputPaths: ResolvedSecretInputPath[] = [ + ['context'], + ...(Array.isArray(inputs.routes) + ? inputs.routes.map((_, index) => ['routes', String(index), 'value'] as const) + : [['routes'] as const]), + ] + const modelInputProjection = projectResolvedModelInput( + ctx.resolvedSecretTraceRegistry, + { context: inputs.context, routes: inputs.routes }, + modelInputPaths + ) + if (!modelInputProjection.complete) { + throw new Error('Router model input could not be safely projected') + } + const projectedRoutes = this.parseRoutes(modelInputProjection.value.routes) + if (projectedRoutes.length !== routes.length) { + throw new Error('Router model input could not be safely projected') + } + const modelRoutes = routes.map((route, index) => ({ + ...route, + value: projectedRoutes[index]?.value ?? route.value, + })) + const routerConfig = { - context: inputs.context, + context: modelInputProjection.value.context, model: inputs.model || ROUTER.DEFAULT_MODEL, apiKey: inputs.apiKey, vertexProject: inputs.vertexProject, @@ -243,7 +279,7 @@ export class RouterBlockHandler implements BlockHandler { if (ctx.userId) url.searchParams.set('userId', ctx.userId) const messages = [{ role: 'user', content: routerConfig.context }] - const systemPrompt = generateRouterV2Prompt(routerConfig.context, routes) + const systemPrompt = generateRouterV2Prompt(routerConfig.context, modelRoutes) const resolved = await this.resolveModel( ctx, block.id, @@ -303,14 +339,16 @@ export class RouterBlockHandler implements BlockHandler { } const headers = new Headers(await buildAuthHeaders(ctx.userId)) + const modelInputMetadata = createModelInputProvenanceRequestMetadata( + modelInputProjection.registry, + modelInputPaths + ) const requestBody = addModelInputProvenanceToRequest( { provider: providerId, ...providerRequest }, headers, - createModelInputProvenanceRequestMetadata( - ctx.resolvedSecretTraceRegistry, - collectProviderModelInputProvenanceValues(providerRequest, providerId) - ) + modelInputMetadata ) + if (modelInputMetadata) markModelInputProjected(headers) const response = await fetch(url.toString(), { method: 'POST', headers, @@ -495,35 +533,45 @@ export class RouterBlockHandler implements BlockHandler { } private getTargetBlocks(ctx: ExecutionContext, block: SerializedBlock) { - return ctx.workflow?.connections - .filter((conn) => conn.source === block.id) - .map((conn) => { - const targetBlock = ctx.workflow?.blocks.find((b) => b.id === conn.target) - if (!targetBlock) { - throw new Error(`Target block ${conn.target} not found`) - } - - let systemPrompt = '' - if (isAgentBlockType(targetBlock.metadata?.id)) { - const paramsPrompt = targetBlock.config?.params?.systemPrompt - const inputsPrompt = targetBlock.inputs?.systemPrompt - systemPrompt = - (typeof paramsPrompt === 'string' ? paramsPrompt : '') || - (typeof inputsPrompt === 'string' ? inputsPrompt : '') || - '' - } - - return { - id: targetBlock.id, - type: targetBlock.metadata?.id, - title: targetBlock.metadata?.name, - description: targetBlock.metadata?.description, - subBlocks: { - ...targetBlock.config.params, - systemPrompt: systemPrompt, - }, - currentState: ctx.blockStates.get(targetBlock.id)?.output, - } + const targetBlocks = [] + const connections = ctx.workflow?.connections.filter((conn) => conn.source === block.id) ?? [] + + for (const conn of connections) { + const targetBlock = ctx.workflow?.blocks.find((candidate) => candidate.id === conn.target) + if (!targetBlock) { + throw new Error(`Target block ${conn.target} not found`) + } + + let systemPrompt = '' + if (isAgentBlockType(targetBlock.metadata?.id)) { + const paramsPrompt = targetBlock.config?.params?.systemPrompt + const inputsPrompt = targetBlock.inputs?.systemPrompt + systemPrompt = + (typeof paramsPrompt === 'string' ? paramsPrompt : '') || + (typeof inputsPrompt === 'string' ? inputsPrompt : '') || + '' + } + + const targetState = ctx.blockStates.get(targetBlock.id) + const stateProvenance = targetState?.resolvedSecretTraceProvenance + const currentState = + stateProvenance && (!stateProvenance.complete || stateProvenance.entries.length > 0) + ? undefined + : targetState?.output + + targetBlocks.push({ + id: targetBlock.id, + type: targetBlock.metadata?.id, + title: targetBlock.metadata?.name, + description: targetBlock.metadata?.description, + subBlocks: { + ...targetBlock.config.params, + systemPrompt, + }, + currentState, }) + } + + return targetBlocks } } diff --git a/apps/sim/executor/handlers/workflow/custom-block-tool-runner.test.ts b/apps/sim/executor/handlers/workflow/custom-block-tool-runner.test.ts index 85f519ca400..5254a2ad607 100644 --- a/apps/sim/executor/handlers/workflow/custom-block-tool-runner.test.ts +++ b/apps/sim/executor/handlers/workflow/custom-block-tool-runner.test.ts @@ -101,6 +101,7 @@ describe('runCustomBlockTool', () => { const registry = new ResolvedSecretTraceRegistry([ { name: 'API_KEY', plaintext: secret, encryptedValue: 'ciphertext' }, ]) + registry.recordResolved('API_KEY', secret) mockExecute.mockRejectedValue(new Error(message)) const projected = await runCustomBlockTool( diff --git a/apps/sim/executor/handlers/workflow/workflow-handler.test.ts b/apps/sim/executor/handlers/workflow/workflow-handler.test.ts index ea7dfcc04d3..1f50fd48702 100644 --- a/apps/sim/executor/handlers/workflow/workflow-handler.test.ts +++ b/apps/sim/executor/handlers/workflow/workflow-handler.test.ts @@ -1247,6 +1247,7 @@ describe('WorkflowBlockHandler', () => { mockExecutorExecute.mockImplementationOnce(async () => { childRegistry = executorOptions.at(-1)?.contextExtensions .resolvedSecretTraceRegistry as ResolvedSecretTraceRegistry + expect(childRegistry.recordResolved('SECRET', 'publisher-secret')).toBe(true) return { success: true, output: {}, @@ -1273,7 +1274,9 @@ describe('WorkflowBlockHandler', () => { replacement: ANONYMOUS_SECRET_TRACE_REPLACEMENT, }, ]) - expect(childRegistry?.getActiveMatches()).toEqual([]) + expect(childRegistry?.getActiveMatches()).toEqual([ + { plaintext: 'publisher-secret', replacement: '{{SECRET}}' }, + ]) expect(mockSetResolvedSecretTraceRegistry).toHaveBeenCalledTimes(1) }) diff --git a/apps/sim/executor/handlers/workflow/workflow-handler.ts b/apps/sim/executor/handlers/workflow/workflow-handler.ts index c3cd00b84d6..e795c2b99e9 100644 --- a/apps/sim/executor/handlers/workflow/workflow-handler.ts +++ b/apps/sim/executor/handlers/workflow/workflow-handler.ts @@ -705,7 +705,7 @@ export class WorkflowBlockHandler implements BlockHandler { const exposedOutput = this.projectCustomBlockOutput(executionResult, exposedOutputs) if (ctx.resolvedSecretTraceRegistry && childResolvedSecretTraceRegistry) { const crossingProvenance = - childResolvedSecretTraceRegistry.exportCatalogProvenanceForValue(exposedOutput, { + childResolvedSecretTraceRegistry.exportCommittedProvenanceForValue(exposedOutput, { anonymous: true, }) await ctx.resolvedSecretTraceRegistry.importProvenance(crossingProvenance, { diff --git a/apps/sim/executor/orchestrators/loop.ts b/apps/sim/executor/orchestrators/loop.ts index c39a400e5d2..0d54d08f77f 100644 --- a/apps/sim/executor/orchestrators/loop.ts +++ b/apps/sim/executor/orchestrators/loop.ts @@ -143,7 +143,7 @@ export class LoopOrchestrator { } let items: any[] const parentRegistry = ctx.resolvedSecretTraceRegistry - const resolutionRegistry = parentRegistry?.forkForToolInputValues([]) + const resolutionRegistry = parentRegistry?.forkForInputPaths([]) const resolutionCtx = resolutionRegistry ? { ...ctx, resolvedSecretTraceRegistry: resolutionRegistry } : ctx diff --git a/apps/sim/executor/orchestrators/parallel.ts b/apps/sim/executor/orchestrators/parallel.ts index d4454f2e501..901b26cf5cc 100644 --- a/apps/sim/executor/orchestrators/parallel.ts +++ b/apps/sim/executor/orchestrators/parallel.ts @@ -71,7 +71,7 @@ export class ParallelOrchestrator { let branchCount: number let isEmpty = false const parentRegistry = ctx.resolvedSecretTraceRegistry - const resolutionRegistry = parentRegistry?.forkForToolInputValues([]) + const resolutionRegistry = parentRegistry?.forkForInputPaths([]) const resolutionCtx = resolutionRegistry ? { ...ctx, resolvedSecretTraceRegistry: resolutionRegistry } : ctx diff --git a/apps/sim/executor/types.ts b/apps/sim/executor/types.ts index 4408b76a0b8..7d8bdf8d471 100644 --- a/apps/sim/executor/types.ts +++ b/apps/sim/executor/types.ts @@ -332,7 +332,7 @@ export interface BlockState { output: NormalizedBlockOutput executed: boolean executionTime: number - /** Encrypted provenance filtered to this exact output. Absent means legacy/untracked state. */ + /** Encrypted candidates active in this block call. Consumers filter them to the selected value. */ resolvedSecretTraceProvenance?: ResolvedSecretTraceProvenanceV1 } diff --git a/apps/sim/executor/utils/resolved-secret-content-projection.test.ts b/apps/sim/executor/utils/resolved-secret-content-projection.test.ts index aa984a98801..23f1731b0c9 100644 --- a/apps/sim/executor/utils/resolved-secret-content-projection.test.ts +++ b/apps/sim/executor/utils/resolved-secret-content-projection.test.ts @@ -133,6 +133,30 @@ describe('projectResolvedSecretModelContent', () => { }) }) + it('does not apply provenance traversal limits when no secret was active', () => { + const registry = new ResolvedSecretTraceRegistry() + const value = new Array(100_001).fill(null) + + const projection = projectResolvedSecretModelContent(value, registry) + expect(projection.safe).toBe(true) + if (projection.safe) expect(projection.value).toBe(value) + expect(isResolvedSecretModelContentUnchanged(value, registry)).toBe(true) + + const jsonProjection = projectResolvedSecretModelJsonContent(value, registry) + expect(jsonProjection.safe).toBe(true) + if (jsonProjection.safe) { + expect(jsonProjection.value).toHaveLength(value.length) + expect((jsonProjection.value as null[]).at(-1)).toBeNull() + expect(jsonProjection.value).not.toBe(value) + } + + const jsonString = '{\n "preserve": true\n}' + expect(projectResolvedSecretModelJsonStrings([jsonString, undefined], registry)).toEqual({ + safe: true, + value: [jsonString, undefined], + }) + }) + it('keeps longest-match semantics when a known opaque placeholder is nested in a secret', () => { const registry = new ResolvedSecretTraceRegistry([ { name: 'Test', plaintext: 'Test', encryptedValue: 'test-ciphertext' }, @@ -397,33 +421,32 @@ describe('projectResolvedSecretDiagnosticError', () => { }) }) - it('uses a known compiler alias as diagnostic-only provenance', () => { - const secret = 'diagnostic-secret-value' + it('sanitizes an inactive compiler alias without activating or scanning its secret', () => { const registry = new ResolvedSecretTraceRegistry([ - { name: 'API_KEY', plaintext: secret, encryptedValue: 'ciphertext' }, + { name: 'X', plaintext: 'x', encryptedValue: 'ciphertext' }, ]) - const error = new Error(`request failed: ${secret} __var_API_KEY`) + const error = new Error('Box __var_X') expect(projectResolvedSecretDiagnosticError(error, registry)).toEqual( - expect.objectContaining({ error: 'request failed: {{API_KEY}} {{API_KEY}}' }) + expect.objectContaining({ error: 'Box [REDACTED_SECRET]' }) ) expect(registry.getActiveMatches()).toEqual([]) }) - it('falls back to text-free diagnostics for unknown or runtime-only aliases', () => { + it('lexically sanitizes unknown and runtime-only aliases without catalog inference', () => { const registry = new ResolvedSecretTraceRegistry([ { name: 'API_KEY', plaintext: 'secret-value', encryptedValue: 'ciphertext' }, ]) expect( projectResolvedSecretDiagnosticError(new Error('secret-value __var_UNKNOWN'), registry) - ).toEqual({ errorType: 'error', hasStack: true }) + ).toEqual(expect.objectContaining({ error: 'secret-value [REDACTED_SECRET]' })) expect( projectResolvedSecretDiagnosticError( new Error('secret-value __sim_code_1_binding_0'), registry ) - ).toEqual({ errorType: 'error', hasStack: true }) + ).toEqual(expect.objectContaining({ error: 'secret-value [RUNTIME_BINDING]' })) }) it('falls back to text-free structure when provenance is missing or incomplete', () => { diff --git a/apps/sim/executor/utils/resolved-secret-content-projection.ts b/apps/sim/executor/utils/resolved-secret-content-projection.ts index 37d81f96fbc..9f707c566a1 100644 --- a/apps/sim/executor/utils/resolved-secret-content-projection.ts +++ b/apps/sim/executor/utils/resolved-secret-content-projection.ts @@ -89,11 +89,6 @@ interface ProjectionState { maxBytes: number } -interface InternalDiagnosticIdentifierScan { - aliases: Set - foundInternalIdentifier: boolean -} - export interface ResolvedSecretContentProjectionOptions { /** Values already materialized and verified by a boundary-specific projector. */ isOpaqueSafeObject?: (value: object) => boolean @@ -194,65 +189,6 @@ function* arrayDataEntries(value: readonly unknown[]): Generator<[number, unknow } } -function collectInternalDiagnosticIdentifiers( - value: unknown -): InternalDiagnosticIdentifierScan | undefined { - const result: InternalDiagnosticIdentifierScan = { - aliases: new Set(), - foundInternalIdentifier: false, - } - const ancestors = new WeakSet() - let nodes = 0 - - const scanString = (candidate: string): void => { - for (const identifier of candidate.match(INTERNAL_DIAGNOSTIC_IDENTIFIER_PATTERN) ?? []) { - result.foundInternalIdentifier = true - if (identifier.startsWith('__var_')) result.aliases.add(identifier) - } - } - - const visit = (candidate: unknown, depth: number): boolean => { - nodes += 1 - if (nodes > MAX_CONTENT_NODES || depth > MAX_CONTENT_DEPTH) return false - if (typeof candidate === 'string') { - scanString(candidate) - return true - } - if ( - candidate === null || - candidate === undefined || - typeof candidate === 'number' || - typeof candidate === 'boolean' - ) { - return true - } - if (typeof candidate !== 'object') return false - if (!Array.isArray(candidate) && !isPlainRecord(candidate)) return false - if (ancestors.has(candidate)) return false - - ancestors.add(candidate) - try { - if (Array.isArray(candidate)) { - for (const [, item] of arrayDataEntries(candidate)) { - if (!visit(item, depth + 1)) return false - } - return true - } - for (const [key, item] of enumerableDataEntries(candidate)) { - scanString(key) - if (!visit(item, depth + 1)) return false - } - return true - } catch { - return false - } finally { - ancestors.delete(candidate) - } - } - - return visit(value, 0) ? result : undefined -} - function sanitizeContent( value: unknown, matcher: ResolvedSecretMatcher | undefined, @@ -451,29 +387,6 @@ export function getResolvedSecretModelMatcher( } } -/** Produces a nonempty model control message only when the registry can prove it secret-free. */ -export function projectResolvedSecretModelControlMessage( - message: string, - registry: ResolvedSecretTraceRegistry | undefined -): string | undefined { - const projection = projectResolvedSecretModelContent(message, registry) - if (projection.safe && typeof projection.value === 'string' && projection.value.length > 0) { - return projection.value - } - - const snapshot = getResolvedSecretModelMatcher(registry) - if (!snapshot.complete) return undefined - for (let codePoint = 0x21; codePoint <= 0x10ffff; codePoint += 1) { - if (codePoint >= 0xd800 && codePoint <= 0xdfff) { - codePoint = 0xdfff - continue - } - const candidate = String.fromCodePoint(codePoint) - if (!snapshot.matcher || !containsResolvedSecret(candidate, snapshot.matcher)) return candidate - } - return undefined -} - /** * Projects content that is about to become model-visible using committed active provenance. * Trusted runtime boundaries activate exact secret-bearing values before this point; unrelated @@ -488,6 +401,9 @@ export function projectResolvedSecretModelContent( ): ResolvedSecretContentProjection { const snapshot = getResolvedSecretModelMatcher(registry) if (!snapshot.complete) return { safe: false } + if (!snapshot.matcher && options.sanitizeInternalIdentifiers !== true) { + return { safe: true, value } + } return projectContent(value, snapshot.matcher, maxBytes, { projectPrimitiveLiterals: true, @@ -506,7 +422,8 @@ export function projectResolvedSecretModelJsonContent( maxBytes = MAX_INLINE_MATERIALIZATION_BYTES, options: ResolvedSecretContentProjectionOptions = {} ): ResolvedSecretContentProjection { - if (!getResolvedSecretModelMatcher(registry).complete) return { safe: false } + const snapshot = getResolvedSecretModelMatcher(registry) + if (!snapshot.complete) return { safe: false } try { const encoded = JSON.stringify(value) @@ -514,6 +431,9 @@ export function projectResolvedSecretModelJsonContent( return { safe: false } } const normalized: unknown = JSON.parse(encoded) + if (!snapshot.matcher && options.sanitizeInternalIdentifiers !== true) { + return { safe: true, value: normalized } + } const projection = projectResolvedSecretModelContent(normalized, registry, maxBytes, options) if (!projection.safe) return projection @@ -537,17 +457,7 @@ export function projectResolvedSecretDiagnosticContent( registry: ResolvedSecretTraceRegistry | undefined, maxBytes = MAX_INLINE_MATERIALIZATION_BYTES ): ResolvedSecretContentProjection { - const identifiers = collectInternalDiagnosticIdentifiers(value) - if (!identifiers) return { safe: false } - - let diagnosticRegistry = registry - if (identifiers.foundInternalIdentifier) { - if (!registry || identifiers.aliases.size === 0) return { safe: false } - diagnosticRegistry = registry.forkForDiagnosticAliases(identifiers.aliases) - if (!diagnosticRegistry) return { safe: false } - } - - return projectResolvedSecretModelContent(value, diagnosticRegistry, maxBytes, { + return projectResolvedSecretModelContent(value, registry, maxBytes, { sanitizeInternalIdentifiers: true, }) } @@ -597,6 +507,7 @@ export function isResolvedSecretModelContentUnchanged( ): boolean { const snapshot = getResolvedSecretModelMatcher(registry) if (!snapshot.complete) return false + if (!snapshot.matcher) return true const projection = projectContent(value, snapshot.matcher, MAX_INLINE_MATERIALIZATION_BYTES, { projectPrimitiveLiterals: true, @@ -616,6 +527,15 @@ export function projectResolvedSecretModelJsonStrings( ): ResolvedSecretContentProjection { const snapshot = getResolvedSecretModelMatcher(registry) if (!snapshot.complete) return { safe: false } + if (!snapshot.matcher) { + let outputBytes = 0 + for (const value of values) { + if (value === undefined) continue + outputBytes += Buffer.byteLength(value, 'utf8') + if (outputBytes > maxBytes) return { safe: false } + } + return { safe: true, value: [...values] } + } const projected: Array = [] let outputBytes = 0 diff --git a/apps/sim/executor/utils/resolved-secret-input-projection.ts b/apps/sim/executor/utils/resolved-secret-input-projection.ts new file mode 100644 index 00000000000..b10954192df --- /dev/null +++ b/apps/sim/executor/utils/resolved-secret-input-projection.ts @@ -0,0 +1,182 @@ +function makeCanonicalPlaceholdersJsonParseable(value: string): string { + let result = '' + let inString = false + let escaped = false + + for (let index = 0; index < value.length; index++) { + const character = value[index] + if (inString) { + result += character + if (escaped) { + escaped = false + } else if (character === '\\') { + escaped = true + } else if (character === '"') { + inString = false + } + continue + } + + if (character === '"') { + inString = true + result += character + continue + } + + if (character === '{' && value[index + 1] === '{') { + const end = value.indexOf('}}', index + 2) + if (end !== -1) { + const placeholder = value.slice(index, end + 2) + const name = value.slice(index + 2, end).trim() + if (/^[A-Za-z0-9_]+$/.test(name)) { + result += JSON.stringify(placeholder) + index = end + 1 + continue + } + } + } + + result += character + } + + return result +} + +function canonicalPlaceholder(value: string): string | undefined { + const match = /^\{\{([^{}]+)\}\}$/.exec(value.trim()) + if (!match || !/^[A-Za-z0-9_]+$/.test(match[1].trim())) return undefined + return value.trim() +} + +function projectStructuredLeaves(value: unknown, placeholder: string): unknown { + if (value === null || typeof value !== 'object') return placeholder + + const projectedRoot: unknown[] | Record = Array.isArray(value) ? [] : {} + const pending: Array<{ + source: unknown[] | Record + target: unknown[] | Record + }> = [{ source: value as unknown[] | Record, target: projectedRoot }] + + while (pending.length > 0) { + const { source, target } = pending.pop()! + for (const [key, child] of Object.entries(source)) { + if (child !== null && typeof child === 'object') { + const projectedChild: unknown[] | Record = Array.isArray(child) ? [] : {} + ;(target as Record)[key] = projectedChild + pending.push({ + source: child as unknown[] | Record, + target: projectedChild, + }) + } else { + ;(target as Record)[key] = placeholder + } + } + } + + return projectedRoot +} + +function projectFileReferenceString( + key: string, + value: string, + placeholder: string +): string | undefined { + if (key !== 'key' && key !== 'path' && key !== 'url' && key !== 'base64') return undefined + if ((key === 'url' || key === 'path') && value.startsWith('https://')) { + return `https://${placeholder}` + } + if ((key === 'url' || key === 'path') && value.startsWith('http://')) { + return `http://${placeholder}` + } + if (key === 'path' && value.startsWith('/')) return `/${placeholder}` + return placeholder +} + +function isFileDescriptor(value: unknown): boolean { + if (Array.isArray(value)) return value.length > 0 && value.every(isFileDescriptor) + return ( + value !== null && + typeof value === 'object' && + ['key', 'path', 'url'].some( + (key) => typeof (value as Record)[key] === 'string' + ) + ) +} + +function projectFileReferenceLeaves( + value: unknown, + placeholder: string +): { value: unknown; projectedReferences: number } | undefined { + if (!isFileDescriptor(value)) return undefined + + const projectedRoot: unknown[] | Record = Array.isArray(value) ? [] : {} + const pending: Array<{ + source: unknown[] | Record + target: unknown[] | Record + }> = [{ source: value as unknown[] | Record, target: projectedRoot }] + let projectedReferences = 0 + while (pending.length > 0) { + const { source, target } = pending.pop()! + for (const [key, child] of Object.entries(source)) { + if (child !== null && typeof child === 'object') { + const projectedChild: unknown[] | Record = Array.isArray(child) ? [] : {} + ;(target as Record)[key] = projectedChild + pending.push({ + source: child as unknown[] | Record, + target: projectedChild, + }) + continue + } + + const projectedReference = + typeof child === 'string' ? projectFileReferenceString(key, child, placeholder) : undefined + ;(target as Record)[key] = projectedReference ?? child + if (projectedReference !== undefined) projectedReferences += 1 + } + } + return { value: projectedRoot, projectedReferences } +} + +/** + * Makes only schema-declared structured inputs parseable on a private placeholder projection. + * Raw execution inputs are never passed to or changed by this helper. + */ +export function prepareResolvedSecretProjectedInputs( + inputs: Record, + inputSchemas: Record | undefined, + rawInputs?: Record, + options: { preserveFileDescriptorGrammar?: boolean } = {} +): Record { + if (!inputSchemas) return inputs + const prepared = { ...inputs } + for (const [key, inputSchema] of Object.entries(inputSchemas)) { + const inputType = + inputSchema && typeof inputSchema === 'object' + ? (inputSchema as { type?: unknown }).type + : inputSchema + if (inputType !== 'json' && inputType !== 'array') continue + const value = prepared[key] + if (typeof value === 'string') { + const placeholder = canonicalPlaceholder(value) + const rawValue = rawInputs?.[key] + if (placeholder && typeof rawValue === 'string') { + try { + const parsedRawValue = JSON.parse(rawValue.trim()) + if (parsedRawValue !== null && typeof parsedRawValue === 'object') { + const fileProjection = options.preserveFileDescriptorGrammar + ? projectFileReferenceLeaves(parsedRawValue, placeholder) + : undefined + prepared[key] = JSON.stringify( + fileProjection && fileProjection.projectedReferences > 0 + ? fileProjection.value + : projectStructuredLeaves(parsedRawValue, placeholder) + ) + continue + } + } catch {} + } + prepared[key] = makeCanonicalPlaceholdersJsonParseable(value) + } + } + return prepared +} diff --git a/apps/sim/executor/utils/resolved-secret-matcher.test.ts b/apps/sim/executor/utils/resolved-secret-matcher.test.ts index 2e191aada62..c90e7329986 100644 --- a/apps/sim/executor/utils/resolved-secret-matcher.test.ts +++ b/apps/sim/executor/utils/resolved-secret-matcher.test.ts @@ -8,11 +8,34 @@ import { OPAQUE_RESOLVED_SECRET_REPLACEMENT, sanitizeResolvedSecretPrimitive, sanitizeResolvedSecretString, + scanResolvedSecretString, } from '@/executor/utils/resolved-secret-matcher' const PRESERVE_NAMED_PROVENANCE = { preserveNamedProvenanceLabels: true } as const describe('resolved secret matcher', () => { + it('reports each matched literal once across large repeated content', () => { + const matcher = createResolvedSecretMatcher([ + { plaintext: 'x', replacement: '{{SHORT}}' }, + { plaintext: 'xx', replacement: '{{OVERLAP}}' }, + { plaintext: 'abc', replacement: '{{PREFIX}}' }, + { plaintext: 'bc', replacement: '{{SUFFIX}}' }, + ]) + const matches: string[] = [] + + expect(matcher).toBeDefined() + if (!matcher) return + expect( + scanResolvedSecretString( + `${'x'.repeat(1_000_001)}abcabc`, + matcher, + (match) => matches.push(match), + 4 + ) + ).toBe(4) + expect(matches).toEqual(['x', 'xx', 'abc', 'bc']) + }) + it('uses exact matching for typed primitive renderings', () => { const matcher = createResolvedSecretMatcher([{ plaintext: '23', replacement: '{{TOKEN}}' }]) diff --git a/apps/sim/executor/utils/resolved-secret-matcher.ts b/apps/sim/executor/utils/resolved-secret-matcher.ts index 8b8560f9ce9..049f0fe4b14 100644 --- a/apps/sim/executor/utils/resolved-secret-matcher.ts +++ b/apps/sim/executor/utils/resolved-secret-matcher.ts @@ -231,7 +231,7 @@ export function containsResolvedSecretLiteral( return false } -/** Visits exact secret literals with the same bounded automaton used by content projection. */ +/** Visits each distinct exact secret literal once with the content-projection automaton. */ export function scanResolvedSecretString( value: string, matcher: ResolvedSecretMatcher, @@ -240,16 +240,40 @@ export function scanResolvedSecretString( ): number { let node = matcher.root let matchEvents = 0 + const matchedPlaintexts = new Set() + const nextUnmatchedOutput = new WeakMap() + + const findNextUnmatchedOutput = ( + candidate: SecretTrieNode | undefined + ): SecretTrieNode | undefined => { + let current = candidate + const exhaustedPath: SecretTrieNode[] = [] + while (current?.replacement && matchedPlaintexts.has(current.replacement.plaintext)) { + const cached = nextUnmatchedOutput.get(current) + if (cached !== undefined) { + current = cached ?? undefined + continue + } + exhaustedPath.push(current) + current = current.outputLink + } + for (const exhausted of exhaustedPath) { + nextUnmatchedOutput.set(exhausted, current ?? null) + } + return current + } + for (let index = 0; index < value.length; index += 1) { node = advanceMatcher(matcher, node, value[index]) - let outputNode: SecretTrieNode | undefined = node.replacement ? node : node.outputLink + let outputNode = findNextUnmatchedOutput(node.replacement ? node : node.outputLink) while (outputNode?.replacement) { matchEvents += 1 if (matchEvents > maxMatchEvents) { throw new ResolvedSecretMatcherError('Secret matcher event limit exceeded') } + matchedPlaintexts.add(outputNode.replacement.plaintext) onMatch(outputNode.replacement.plaintext) - outputNode = outputNode.outputLink + outputNode = findNextUnmatchedOutput(outputNode) } } return matchEvents 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 91269f82c6a..3967555abf9 100644 --- a/apps/sim/executor/utils/resolved-secret-trace-registry.test.ts +++ b/apps/sim/executor/utils/resolved-secret-trace-registry.test.ts @@ -178,6 +178,234 @@ describe('ResolvedSecretTraceRegistry', () => { } }) + it('projects only resolver-recorded leaves and never rewrites sibling bytes or object keys', () => { + const registry = new ResolvedSecretTraceRegistry([ + { name: 'TOKEN', plaintext: 'x', encryptedValue: 'encrypted-token' }, + ]) + registry.recordResolvedAtInputPath('TOKEN', 'x', ['prompt']) + registry.recordResolvedInputProjection(['prompt'], 'Box x', 'Box {{TOKEN}}') + const unrelatedNonCloneableInput = () => 'unchanged' + + expect( + registry.projectResolvedInputSelection({ + prompt: 'Box x', + auxiliary: 'xylophone', + Box: 'unchanged', + unrelatedNonCloneableInput, + }) + ).toEqual({ + complete: true, + value: { + prompt: 'Box {{TOKEN}}', + auxiliary: 'xylophone', + Box: 'unchanged', + unrelatedNonCloneableInput, + }, + }) + }) + + it('keeps equal secret values causally bound to their own resolver paths', () => { + const registry = new ResolvedSecretTraceRegistry([ + { name: 'FIRST', plaintext: 'true', encryptedValue: 'encrypted-first' }, + { name: 'SECOND', plaintext: 'true', encryptedValue: 'encrypted-second' }, + ]) + registry.recordResolvedAtInputPath('FIRST', 'true', ['first']) + registry.recordResolvedInputProjection(['first'], 'true', '{{FIRST}}') + registry.recordResolvedAtInputPath('SECOND', 'true', ['second']) + registry.recordResolvedInputProjection(['second'], 'true', '{{SECOND}}') + + expect(registry.projectResolvedInputSelection({ first: 'true', second: 'true' })).toEqual({ + complete: true, + value: { first: '{{FIRST}}', second: '{{SECOND}}' }, + }) + expect(registry.exportCommittedProvenanceForInputPaths([['first']])).toMatchObject({ + complete: true, + entries: [{ name: 'FIRST', encryptedValue: 'encrypted-first' }], + }) + }) + + it('isolates incomplete provenance to its known input path', async () => { + const registry = new ResolvedSecretTraceRegistry() + + expect( + await registry.importProvenanceForValueAtInputPath( + { version: 1 }, + 'unknown-value', + ['tools', '0', 'params', 'apiKey'], + { trusted: true } + ) + ).toEqual({ success: false, matched: false }) + + expect(registry.isComplete()).toBe(false) + expect(registry.projectResolvedInputSelection({ userPrompt: 'Public prompt' })).toEqual({ + complete: true, + value: { userPrompt: 'Public prompt' }, + }) + expect(registry.exportCommittedProvenanceForInputPaths([['userPrompt']])).toEqual({ + version: 1, + complete: true, + entries: [], + }) + expect(registry.forkForInputPaths([['userPrompt']]).isComplete()).toBe(true) + }) + + it('propagates authenticated incomplete provenance without classifying it as malformed', async () => { + const scope = { userId: 'user-1', workspaceId: 'workspace-1' } + const registry = new ResolvedSecretTraceRegistry([], scope) + + expect( + await registry.importProvenanceForValueAtInputPath( + { version: 1, complete: false, entries: [], scope }, + 'untrusted value', + ['toolResult'], + { trusted: true } + ) + ).toEqual({ success: true, matched: false }) + expect(registry.forkForInputPaths([['publicPrompt']]).isComplete()).toBe(true) + expect(registry.forkForInputPaths([['toolResult']]).isComplete()).toBe(false) + }) + + it('localizes a failed exact resolution when the resolver supplies its input path', () => { + const registry = new ResolvedSecretTraceRegistry([ + { name: 'TOKEN', plaintext: 'expected', encryptedValue: 'encrypted-token' }, + ]) + + expect( + registry.recordResolvedAtInputPath('TOKEN', 'unexpected', ['tools', '0', 'params', 'apiKey']) + ).toBe(false) + + expect(registry.projectResolvedInputSelection({ userPrompt: 'Public prompt' })).toEqual({ + complete: true, + value: { userPrompt: 'Public prompt' }, + }) + expect(registry.forkForInputPaths([['userPrompt']]).isComplete()).toBe(true) + expect(registry.forkForInputPaths([['tools', '0', 'params']]).isComplete()).toBe(false) + expect(registry.getModelEgressSnapshot()).toEqual({ complete: false }) + }) + + it('fails closed for a selected unknown path and arbitrary output projection', async () => { + const scope = { userId: 'user-1', workspaceId: 'workspace-1' } + const registry = new ResolvedSecretTraceRegistry([], scope) + await registry.importProvenanceForValueAtInputPath( + { version: 1 }, + 'unknown-value', + ['tools', '0', 'params', 'apiKey'], + { trusted: true } + ) + + expect( + registry.projectResolvedInputSelection({ tools: [{ params: { apiKey: 'value' } }] }) + ).toEqual({ complete: false }) + expect(registry.exportCommittedProvenanceForInputPaths([['tools', '0', 'params']])).toEqual({ + version: 1, + complete: false, + entries: [], + scope, + }) + expect(registry.forkForInputPaths([['tools', '0', 'params']]).isComplete()).toBe(false) + expect(registry.getModelEgressSnapshot()).toEqual({ complete: false }) + expect(registry.exportProvenanceForValue('arbitrary output')).toEqual({ + version: 1, + complete: false, + entries: [], + scope, + }) + }) + + it('propagates only selected input-path entries when explicitly requested', () => { + const registry = new ResolvedSecretTraceRegistry([ + { name: 'SELECTED', plaintext: 'selected', encryptedValue: 'encrypted-selected' }, + { name: 'UNSELECTED', plaintext: 'unselected', encryptedValue: 'encrypted-unselected' }, + ]) + registry.recordResolvedAtInputPath('SELECTED', 'selected', ['selected']) + registry.recordResolvedAtInputPath('UNSELECTED', 'unselected', ['unselected']) + + const ordinaryFork = registry.forkForInputPaths([['selected']]) + expect(ordinaryFork.forkForPropagatedEntries().exportProvenance().entries).toEqual([]) + + const propagatedFork = registry.forkForInputPaths([['selected']], { propagated: true }) + expect(propagatedFork.forkForPropagatedEntries().exportProvenance().entries).toEqual([ + { name: 'SELECTED', encryptedValue: 'encrypted-selected' }, + ]) + expect(propagatedFork.exportProvenance().entries).not.toContainEqual({ + name: 'UNSELECTED', + encryptedValue: 'encrypted-unselected', + }) + }) + + it('preserves exact paths through renamed and parsed parameter transforms', () => { + const registry = new ResolvedSecretTraceRegistry([ + { name: 'FIRST', plaintext: 'true', encryptedValue: 'encrypted-first' }, + { name: 'SECOND', plaintext: 'true', encryptedValue: 'encrypted-second' }, + { name: 'UNUSED', plaintext: 'true', encryptedValue: 'encrypted-unused' }, + ]) + registry.recordResolvedAtInputPath('FIRST', 'true', ['rowTemplate']) + registry.recordResolvedAtInputPath('SECOND', 'true', ['rowTemplate']) + registry.recordResolvedInputProjection( + ['rowTemplate'], + '{"first":true,"second":true,"public":true}', + '{"first":{{FIRST}},"second":{{SECOND}},"public":true}' + ) + + registry.recordTransformedInputProjection( + { data: { first: true, second: true, public: true } }, + { data: { first: '{{FIRST}}', second: '{{SECOND}}', public: true } } + ) + + expect( + registry.projectResolvedInputSelection({ + data: { first: true, second: true, public: true }, + }) + ).toEqual({ + complete: true, + value: { + data: { first: '{{FIRST}}', second: '{{SECOND}}', public: true }, + }, + }) + expect(registry.exportCommittedProvenanceForInputPaths([['data', 'first']])).toMatchObject({ + complete: true, + entries: [{ name: 'FIRST', encryptedValue: 'encrypted-first' }], + }) + expect(registry.exportCommittedProvenanceForInputPaths([['data', 'second']])).toMatchObject({ + complete: true, + entries: [{ name: 'SECOND', encryptedValue: 'encrypted-second' }], + }) + expect(registry.exportCommittedProvenanceForInputPaths([['data', 'public']])).toMatchObject({ + complete: true, + entries: [], + }) + }) + + it('fails closed when independent secret paths collapse into one transformed string', () => { + const registry = new ResolvedSecretTraceRegistry([ + { name: 'FIRST', plaintext: 'first', encryptedValue: 'encrypted-first' }, + { name: 'SECOND', plaintext: 'second', encryptedValue: 'encrypted-second' }, + ]) + registry.recordResolvedAtInputPath('FIRST', 'first', ['first']) + registry.recordResolvedInputProjection(['first'], 'first', '{{FIRST}}') + registry.recordResolvedAtInputPath('SECOND', 'second', ['second']) + registry.recordResolvedInputProjection(['second'], 'second', '{{SECOND}}') + + registry.recordTransformedInputProjection( + { combined: 'first:second' }, + { combined: '{{FIRST}}:second' } + ) + registry.recordTransformedInputProjection( + { combined: 'first:second' }, + { combined: 'first:{{SECOND}}' } + ) + + expect(registry.isComplete()).toBe(false) + expect(registry.projectResolvedInputSelection({ unrelated: 'public' })).toEqual({ + complete: true, + value: { unrelated: 'public' }, + }) + expect(registry.projectResolvedInputSelection({ combined: 'first:second' })).toEqual({ + complete: false, + }) + expect(registry.getModelEgressSnapshot()).toEqual({ complete: false }) + }) + it('does not invalidate the model matcher for duplicate activations', () => { const registry = new ResolvedSecretTraceRegistry([ { name: 'API_KEY', plaintext: 'secret-value', encryptedValue: 'encrypted-value' }, @@ -248,6 +476,42 @@ describe('ResolvedSecretTraceRegistry', () => { } }) + it('keeps a named anonymous secret distinct from anonymous provenance', async () => { + mockDecryptSecret.mockResolvedValueOnce({ decrypted: 'same-secret' }) + const registry = new ResolvedSecretTraceRegistry( + [{ name: 'anonymous', plaintext: 'same-secret', encryptedValue: 'shared-ciphertext' }], + { userId: 'user-1', workspaceId: 'workspace-1' } + ) + registry.recordResolved('anonymous', 'same-secret') + await registry.importProvenance( + { + version: 1, + complete: true, + entries: [{ encryptedValue: 'shared-ciphertext' }], + scope: { userId: 'user-1', workspaceId: 'workspace-1' }, + }, + { trusted: true, anonymous: true } + ) + + expect(registry.exportCommittedProvenanceForValue('same-secret')).toEqual({ + version: 1, + complete: true, + entries: [ + { encryptedValue: 'shared-ciphertext' }, + { name: 'anonymous', encryptedValue: 'shared-ciphertext' }, + ], + scope: { userId: 'user-1', workspaceId: 'workspace-1' }, + }) + const snapshot = registry.getModelEgressSnapshot() + expect(snapshot.complete).toBe(true) + if (snapshot.complete) { + expect(snapshot.matches).toContainEqual({ + plaintext: 'same-secret', + replacement: ANONYMOUS_SECRET_TRACE_REPLACEMENT, + }) + } + }) + it('projects committed provenance while temporary activations are pending', () => { const registry = new ResolvedSecretTraceRegistry([ { name: 'API_KEY', plaintext: 'secret-value', encryptedValue: 'encrypted-value' }, @@ -292,45 +556,6 @@ describe('ResolvedSecretTraceRegistry', () => { }) }) - it('seeds a tool child only with active provenance present in that tool input', () => { - const registry = new ResolvedSecretTraceRegistry([ - { name: 'INPUT', plaintext: 'input-secret', encryptedValue: 'input-ciphertext' }, - { name: 'UNRELATED', plaintext: 'Test', encryptedValue: 'unrelated-ciphertext' }, - ]) - registry.recordResolved('INPUT', 'input-secret') - registry.recordResolved('UNRELATED', 'Test') - - const child = registry.forkForToolInput({ authorization: 'Bearer input-secret' }) - - expect(child.getActiveMatches()).toEqual([ - { plaintext: 'input-secret', replacement: '{{INPUT}}' }, - ]) - expect(child.recordResolved('UNRELATED', 'Test')).toBe(true) - }) - - it('forks independent roots without treating static param names or array indexes as data', () => { - const registry = new ResolvedSecretTraceRegistry([ - { name: 'PROMPT', plaintext: 'prompt', encryptedValue: 'prompt-ciphertext' }, - { name: 'ZERO', plaintext: '0', encryptedValue: 'zero-ciphertext' }, - { name: 'VALUE', plaintext: 'input-secret', encryptedValue: 'value-ciphertext' }, - ]) - registry.recordResolved('PROMPT', 'prompt') - registry.recordResolved('ZERO', '0') - registry.recordResolved('VALUE', 'input-secret') - - const child = registry.forkForToolInputValues(['safe', { nested: 'input-secret' }]) - - expect(child.getActiveMatches()).toEqual([ - { plaintext: 'input-secret', replacement: '{{VALUE}}' }, - ]) - expect(registry.forkForToolInputValues([{ prompt: 'safe' }]).getActiveMatches()).toEqual([ - { plaintext: 'prompt', replacement: '{{PROMPT}}' }, - ]) - expect(registry.forkForToolInputValues([0]).getActiveMatches()).toEqual([ - { plaintext: '0', replacement: '{{ZERO}}' }, - ]) - }) - it('uses the workspace catalog entry when personal and workspace names conflict', async () => { const registry = await createResolvedSecretTraceRegistry({ personalEncrypted: { SHARED: 'personal-encrypted' }, @@ -701,6 +926,102 @@ describe('ResolvedSecretTraceRegistry', () => { }) }) + it('exports active provenance for a registered legacy runtime alias', () => { + const registry = new ResolvedSecretTraceRegistry([ + { name: 'API-KEY', plaintext: 'secret-value', encryptedValue: 'present-ciphertext' }, + ]) + registry.recordResolved('API-KEY', 'secret-value') + + expect(registry.exportCommittedProvenanceForValue('prefix __var_API_KEY suffix')).toEqual({ + version: 1, + complete: true, + entries: [{ name: 'API-KEY', encryptedValue: 'present-ciphertext' }], + }) + }) + + it('ignores repeated unrelated runtime aliases without exhausting the scan budget', () => { + const registry = new ResolvedSecretTraceRegistry([ + { name: 'API_KEY', plaintext: 'secret-value', encryptedValue: 'present-ciphertext' }, + ]) + registry.recordResolved('API_KEY', 'secret-value') + + expect( + registry.exportCommittedProvenanceForValue(`${'__var_Z '.repeat(1_000_001)}__var_API_KEY`) + ).toEqual({ + version: 1, + complete: true, + entries: [{ name: 'API_KEY', encryptedValue: 'present-ciphertext' }], + }) + }) + + it('matches legacy runtime aliases as complete tokens instead of prefixes', () => { + const registry = new ResolvedSecretTraceRegistry([ + { name: 'A', plaintext: 'secret-a', encryptedValue: 'ciphertext-a' }, + { name: 'API_KEY', plaintext: 'secret-api', encryptedValue: 'ciphertext-api' }, + ]) + registry.recordResolved('A', 'secret-a') + registry.recordResolved('API_KEY', 'secret-api') + + expect(registry.exportCommittedProvenanceForValue('__var_API_KEY')).toEqual({ + version: 1, + complete: true, + entries: [{ name: 'API_KEY', encryptedValue: 'ciphertext-api' }], + }) + }) + + it('conservatively retains every secret mapped to a colliding runtime alias', () => { + const registry = new ResolvedSecretTraceRegistry([ + { name: 'API-KEY', plaintext: 'first-secret', encryptedValue: 'first-ciphertext' }, + { name: 'API_KEY', plaintext: 'second-secret', encryptedValue: 'second-ciphertext' }, + ]) + registry.recordResolved('API-KEY', 'first-secret') + registry.recordResolved('API_KEY', 'second-secret') + + expect(registry.exportCommittedProvenanceForValue('__var_API_KEY')).toEqual({ + version: 1, + complete: true, + entries: [ + { name: 'API-KEY', encryptedValue: 'first-ciphertext' }, + { name: 'API_KEY', encryptedValue: 'second-ciphertext' }, + ], + }) + }) + + it('retains an alias-specific entry when multiple names share one plaintext', () => { + const registry = new ResolvedSecretTraceRegistry([ + { name: 'FIRST', plaintext: 'shared-secret', encryptedValue: 'first-ciphertext' }, + { name: 'SECOND', plaintext: 'shared-secret', encryptedValue: 'second-ciphertext' }, + ]) + registry.recordResolved('FIRST', 'shared-secret') + registry.recordResolved('SECOND', 'shared-secret') + + expect(registry.exportCommittedProvenanceForValue('__var_SECOND')).toEqual({ + version: 1, + complete: true, + entries: [{ name: 'SECOND', encryptedValue: 'second-ciphertext' }], + }) + }) + + it('conservatively retains every active secret that shares a raw plaintext literal', () => { + const registry = new ResolvedSecretTraceRegistry([ + { name: 'FIRST', plaintext: 'true', encryptedValue: 'first-ciphertext' }, + { name: 'SECOND', plaintext: 'true', encryptedValue: 'second-ciphertext' }, + ]) + registry.recordResolved('FIRST', 'true') + registry.recordResolved('SECOND', 'true') + + const expected = { + version: 1 as const, + complete: true, + entries: [ + { name: 'FIRST', encryptedValue: 'first-ciphertext' }, + { name: 'SECOND', encryptedValue: 'second-ciphertext' }, + ], + } + expect(registry.exportCommittedProvenanceForValue('true')).toEqual(expected) + expect(registry.exportCommittedProvenanceForValue(true)).toEqual(expected) + }) + it('exports active numeric, boolean, and null literals crossing a value boundary', () => { const registry = new ResolvedSecretTraceRegistry([ { name: 'NUMBER', plaintext: '1234', encryptedValue: 'number-ciphertext' }, @@ -800,11 +1121,13 @@ describe('ResolvedSecretTraceRegistry', () => { registry.recordResolved('A_TOKEN', 'same') registry.recordResolved('EMPTY', '') - expect(registry.getActiveMatches()).toEqual([{ plaintext: 'same', replacement: '{{A_TOKEN}}' }]) + expect(registry.getActiveMatches()).toEqual([ + { plaintext: 'same', replacement: ANONYMOUS_SECRET_TRACE_REPLACEMENT }, + ]) registry.recordResolved('A', 'A') expect(registry.getActiveMatches()).toEqual([ - { plaintext: 'same', replacement: '{{A_TOKEN}}' }, + { plaintext: 'same', replacement: ANONYMOUS_SECRET_TRACE_REPLACEMENT }, { plaintext: 'A', replacement: '{{A}}' }, ]) }) @@ -825,6 +1148,22 @@ describe('ResolvedSecretTraceRegistry', () => { expect(registry.exportProvenance().entries).toEqual([]) }) + it('does not poison unrelated inputs when dormant catalog entries exceed the hard cap', () => { + const entries = Array.from({ length: 10_001 }, (_, index) => ({ + name: `SECRET_${index}`, + plaintext: `value-${index}`, + encryptedValue: `ciphertext-${index}`, + })) + const registry = new ResolvedSecretTraceRegistry(entries) + + expect(registry.isComplete()).toBe(true) + expect(registry.recordResolvedAtInputPath('SECRET_10000', 'value-10000', ['userPrompt'])).toBe( + false + ) + expect(registry.forkForInputPaths([['systemPrompt']]).isComplete()).toBe(true) + expect(registry.forkForInputPaths([['userPrompt']]).isComplete()).toBe(false) + }) + it('bounds provenance by serialized JSON bytes including control-character escapes', () => { const encryptedValue = '\u0000'.repeat(1_400_000) const provenance: ResolvedSecretTraceProvenanceV1 = { @@ -892,7 +1231,7 @@ describe('ResolvedSecretTraceRegistry', () => { ) }) - it('stops consuming a dormant catalog when its entry cap is exceeded', () => { + it('does not let a large dormant catalog poison unrelated execution provenance', () => { let yieldedEntries = 0 function* catalogEntries() { for (let index = 0; index < 20_000; index++) { @@ -908,11 +1247,11 @@ describe('ResolvedSecretTraceRegistry', () => { const registry = new ResolvedSecretTraceRegistry(catalogEntries()) expect(yieldedEntries).toBe(10_001) - expect(registry.isComplete()).toBe(false) + expect(registry.isComplete()).toBe(true) expect(registry.exportProvenance().entries).toEqual([]) }) - it('marks an oversized dormant catalog value incomplete without retaining it', () => { + it('keeps an oversized dormant value inert until that exact secret is resolved', () => { const oversizedPlaintext = 'x'.repeat(8 * 1024 * 1024) const registry = new ResolvedSecretTraceRegistry([ { @@ -920,10 +1259,23 @@ describe('ResolvedSecretTraceRegistry', () => { plaintext: oversizedPlaintext, encryptedValue: 'ciphertext', }, + { + name: 'NORMAL', + plaintext: 'normal-secret', + encryptedValue: 'normal-ciphertext', + }, ]) - expect(registry.isComplete()).toBe(false) - expect(registry.recordResolved('OVERSIZED', oversizedPlaintext)).toBe(false) - expect(registry.getActiveMatches()).toEqual([]) + expect(registry.isComplete()).toBe(true) + expect(registry.getModelEgressSnapshot()).toEqual({ complete: true, matches: [] }) + expect(registry.recordResolvedAtInputPath('NORMAL', 'normal-secret', ['systemPrompt'])).toBe( + true + ) + expect( + registry.recordResolvedAtInputPath('OVERSIZED', oversizedPlaintext, ['userPrompt']) + ).toBe(false) + expect(registry.forkForInputPaths([['systemPrompt']]).isComplete()).toBe(true) + expect(registry.forkForInputPaths([['userPrompt']]).isComplete()).toBe(false) + expect(registry.getModelEgressSnapshot()).toEqual({ complete: false }) }) }) diff --git a/apps/sim/executor/utils/resolved-secret-trace-registry.ts b/apps/sim/executor/utils/resolved-secret-trace-registry.ts index 00b2414f5e9..6cda63975e7 100644 --- a/apps/sim/executor/utils/resolved-secret-trace-registry.ts +++ b/apps/sim/executor/utils/resolved-secret-trace-registry.ts @@ -1,6 +1,7 @@ import { decryptSecret } from '@/lib/core/security/encryption' import { isLargeArrayManifest } from '@/lib/execution/payloads/large-array-manifest-metadata' import { isLargeValueRef } from '@/lib/execution/payloads/large-value-ref' +import { MAX_INLINE_MATERIALIZATION_BYTES } from '@/lib/execution/payloads/limits' import { createResolvedSecretMatcher, OPAQUE_RESOLVED_SECRET_REPLACEMENT, @@ -17,8 +18,9 @@ const MAX_SERIALIZED_PROVENANCE_BYTES = 8 * 1024 * 1024 const MAX_TRACE_CATALOG_ENTRIES = MAX_PROVENANCE_ENTRIES const MAX_TRACE_CATALOG_BYTES = 8 * 1024 * 1024 const MAX_PROVENANCE_FILTER_NODES = 50_000 -const MAX_PROVENANCE_FILTER_CHARACTERS = 16 * 1024 * 1024 +const MAX_PROVENANCE_FILTER_CHARACTERS = MAX_INLINE_MATERIALIZATION_BYTES const MAX_PROVENANCE_FILTER_MATCH_EVENTS = 1_000_000 +const LEGACY_RUNTIME_ALIAS_PATTERN = /__var_[A-Za-z0-9_]+/g const ERROR_CONTENT_PROPERTY_NAMES = ['name', 'message', 'stack', 'cause', 'errors'] as const const PROVENANCE_PROPERTY_NAMES = new Set(['version', 'complete', 'entries', 'scope']) const PROVENANCE_ENTRY_PROPERTY_NAMES = new Set(['encryptedValue', 'name']) @@ -35,6 +37,12 @@ export interface ResolvedSecretTraceMatch { replacement: string } +export type ResolvedSecretInputPath = readonly string[] + +export type ResolvedSecretInputProjection = + | { complete: true; value: Record } + | { complete: false } + export type ResolvedSecretModelEgressSnapshot = | { complete: true; matches: readonly ResolvedSecretTraceMatch[] } | { complete: false } @@ -60,6 +68,24 @@ interface ActiveSecretEntry extends ResolvedSecretTraceCatalogEntry { anonymous: boolean } +interface ResolvedInputPathState { + path: string[] + entryKeys: Set + rawValue?: unknown + projectedValue?: unknown +} + +interface PreparedProvenanceFilter { + candidatesByScanLiteral: ReadonlyMap + candidatesByAlias: ReadonlyMap + candidateEntryKeys: ReadonlySet + matcher?: ResolvedSecretMatcher +} + +type PreparedProvenanceFilterResult = + | { complete: true; filter: PreparedProvenanceFilter } + | { complete: false } + export interface ImportResolvedSecretTraceProvenanceOptions { trusted: boolean anonymous?: boolean @@ -69,6 +95,11 @@ export interface ExportResolvedSecretTraceProvenanceForValueOptions { anonymous?: boolean } +export interface ImportResolvedSecretTraceProvenanceForValueResult { + success: boolean + matched: boolean +} + export interface CreateResolvedSecretTraceRegistryOptions { personalEncrypted: Record workspaceEncrypted: Record @@ -95,6 +126,74 @@ function cloneProvenanceScope(scope: ResolvedSecretTraceScopeV1): ResolvedSecret } } +function createLegacyRuntimeAlias(name: string): string { + return `__var_${name.replace(/[^a-zA-Z0-9_]/g, '_')}` +} + +function activeEntryKey(entry: ActiveSecretEntry): string { + return entry.anonymous + ? `anonymous\u0000${entry.encryptedValue}` + : `named\u0000${entry.name}\u0000${entry.encryptedValue}` +} + +function inputPathKey(path: ResolvedSecretInputPath): string { + return JSON.stringify(path) +} + +function isInputPathWithin(path: readonly string[], root: readonly string[]): boolean { + return root.length <= path.length && root.every((segment, index) => segment === path[index]) +} + +function inputPathsOverlap(left: readonly string[], right: readonly string[]): boolean { + return isInputPathWithin(left, right) || isInputPathWithin(right, left) +} + +function readInputPath(root: unknown, path: readonly string[]): unknown { + let current = root + for (const segment of path) { + if (current === null || typeof current !== 'object') return undefined + current = (current as Record)[segment] + } + return current +} + +function writeInputPathOnProjectedCopy( + sourceRoot: Record, + projectedRoot: Record, + path: readonly string[], + value: unknown, + projectedContainers: WeakMap +): boolean { + if (path.length === 0) return false + let source: unknown = sourceRoot + let projected: unknown = projectedRoot + for (let index = 0; index < path.length - 1; index++) { + if ( + source === null || + typeof source !== 'object' || + projected === null || + typeof projected !== 'object' + ) { + return false + } + const sourceChild = (source as Record)[path[index]] + if (sourceChild === null || typeof sourceChild !== 'object') return false + let projectedChild = projectedContainers.get(sourceChild) + if (!projectedChild) { + projectedChild = Array.isArray(sourceChild) + ? [...sourceChild] + : { ...(sourceChild as Record) } + projectedContainers.set(sourceChild, projectedChild) + } + ;(projected as Record)[path[index]] = projectedChild + source = sourceChild + projected = projectedChild + } + if (projected === null || typeof projected !== 'object') return false + ;(projected as Record)[path.at(-1)!] = value + return true +} + function serializedJsonStringByteSize(value: string): number { let byteSize = 2 for (let index = 0; index < value.length; index++) { @@ -142,6 +241,14 @@ function serializedProvenanceEntryByteSize(entry: ResolvedSecretTraceProvenanceE return byteSize + 1 } +function catalogEntryByteSize(entry: ResolvedSecretTraceCatalogEntry): number { + return ( + Buffer.byteLength(entry.name, 'utf8') + + Buffer.byteLength(entry.plaintext, 'utf8') + + Buffer.byteLength(entry.encryptedValue, 'utf8') + ) +} + function serializedProvenanceEnvelopeByteSize( complete: boolean, scope: ResolvedSecretTraceScopeV1 | undefined @@ -228,14 +335,6 @@ function isExactProvenanceEntriesArray(value: unknown): value is unknown[] { } } -function catalogEntryByteSize(entry: ResolvedSecretTraceCatalogEntry): number { - return ( - Buffer.byteLength(entry.name, 'utf8') + - Buffer.byteLength(entry.plaintext, 'utf8') + - Buffer.byteLength(entry.encryptedValue, 'utf8') - ) -} - function buildEffectiveCatalogEntry( options: CreateResolvedSecretTraceRegistryOptions, failedNames: ReadonlySet, @@ -432,10 +531,17 @@ export class ResolvedSecretTraceRegistry { private readonly catalog = new Map() private catalogBytes = 0 private readonly activeEntries = new Map() + private readonly propagatedEntryKeys = new Set() + private readonly resolvedInputPaths = new Map() + private readonly incompleteInputPaths = new Map() private activeProvenanceEntryBytes = 0 private complete = true private pendingActivations = 0 private modelEgressRevision = 0 + private activeProvenanceFilterCache?: { + revision: number + result: PreparedProvenanceFilterResult + } private readonly scope?: ResolvedSecretTraceScopeV1 private readonly completeProvenanceEnvelopeBytes: number @@ -451,11 +557,8 @@ export class ResolvedSecretTraceRegistry { let catalogEntriesSeen = 0 for (const entry of catalogEntries) { catalogEntriesSeen++ - if (catalogEntriesSeen > MAX_TRACE_CATALOG_ENTRIES) { - this.markIncomplete() - break - } - if (!this.addCatalogEntry(entry)) break + if (catalogEntriesSeen > MAX_TRACE_CATALOG_ENTRIES) break + this.addCatalogEntry(entry) } } @@ -470,110 +573,421 @@ export class ResolvedSecretTraceRegistry { forkForToolCall(): ResolvedSecretTraceRegistry { const fork = new ResolvedSecretTraceRegistry(this.catalog.values(), this.scope) for (const entry of this.activeEntries.values()) { - fork.addActiveEntry({ ...entry }) + fork.addActiveEntry( + { ...entry }, + { propagated: this.propagatedEntryKeys.has(activeEntryKey(entry)) } + ) } + this.copyResolvedInputPathsTo(fork) + this.copyIncompleteInputPathsTo(fork) if (!this.complete) fork.markIncomplete() return fork } - /** - * Creates an isolated tool registry seeded only with committed parent provenance present in - * that call's resolved input. The full catalog remains available so the call can activate a - * direct environment read, while unrelated secrets resolved by earlier calls cannot rewrite an - * otherwise public result that happens to contain the same bytes. - */ - forkForToolInput(input: unknown): ResolvedSecretTraceRegistry { - return this.forkForToolInputValues([input]) - } - - /** - * Creates an isolated tool registry from independent input roots without treating a synthetic - * container's keys as data. Callers use this when top-level parameter names are trusted grammar - * but nested object keys remain user-controlled input. - */ - forkForToolInputValues(values: Iterable): ResolvedSecretTraceRegistry { + /** Creates an isolated registry from resolver-recorded input paths without plaintext matching. */ + forkForInputPaths( + paths: readonly ResolvedSecretInputPath[], + options: { propagated?: boolean } = {} + ): ResolvedSecretTraceRegistry { const fork = new ResolvedSecretTraceRegistry(this.catalog.values(), this.scope) if (!this.complete) { fork.markIncomplete() return fork } - const provenance = this.exportProvenanceForValuesFromEntries( - values, - this.activeEntries.values(), - {} - ) - if (!provenance.complete) { + + if (this.hasIncompleteInputPathOverlapping(paths)) { fork.markIncomplete() return fork } - const selectedEntries = new Set( - provenance.entries.map((entry) => `${entry.name ?? ''}\u0000${entry.encryptedValue}`) - ) - for (const entry of this.activeEntries.values()) { - const provenanceEntry = toProvenanceEntry(entry) - if ( - selectedEntries.has(`${provenanceEntry.name ?? ''}\u0000${provenanceEntry.encryptedValue}`) - ) { - fork.addActiveEntry({ ...entry }) - } + const selectedKeys = this.collectInputPathEntryKeys(paths) + for (const [key, entry] of this.activeEntries) { + if (!selectedKeys.has(key)) continue + fork.addActiveEntry( + { ...entry }, + { propagated: options.propagated === true || this.propagatedEntryKeys.has(key) } + ) } - if (!this.complete) fork.markIncomplete() + this.copyResolvedInputPathsTo(fork, paths) return fork } - /** - * Creates a diagnostic-only registry for compiler aliases present in one log payload. - * Every alias must bind to the local catalog; an unknown alias returns `undefined` so - * callers can fall back to text-free diagnostics instead of exposing adjacent plaintext. - */ - forkForDiagnosticAliases(aliases: Iterable): ResolvedSecretTraceRegistry | undefined { - const fork = this.forkForToolCall() - if (!this.complete) return fork - - const requestedAliases = new Set(aliases) - const entriesByAlias = new Map() - for (const entry of this.catalog.values()) { - const alias = `__var_${entry.name.replace(/[^a-zA-Z0-9_]/g, '_')}` - const entries = entriesByAlias.get(alias) ?? [] - entries.push(entry) - entriesByAlias.set(alias, entries) - } - for (const alias of requestedAliases) { - const entries = entriesByAlias.get(alias) - if (!entries) return undefined - for (const entry of entries) { - fork.addActiveEntry({ ...entry, anonymous: false }) + /** Creates a model/output registry containing only provenance explicitly carried by a result. */ + forkForPropagatedEntries(): ResolvedSecretTraceRegistry { + const fork = new ResolvedSecretTraceRegistry(this.catalog.values(), this.scope) + for (const entry of this.activeEntries.values()) { + if (this.propagatedEntryKeys.has(activeEntryKey(entry))) { + fork.addActiveEntry({ ...entry }, { propagated: true }) } } + if (this.isPermanentlyIncomplete()) fork.markIncomplete() return fork } /** Merges one settled tool-call registry into the turn-scoped registry. */ mergeToolCallRegistry(child: ResolvedSecretTraceRegistry): void { - if (!scopesMatch(this.scope, child.scope) || !child.complete || child.pendingActivations > 0) { + if (!scopesMatch(this.scope, child.scope) || !child.isComplete()) { this.markIncomplete() return } for (const entry of child.activeEntries.values()) { - this.addActiveEntry({ ...entry }) + this.addActiveEntry( + { ...entry }, + { propagated: child.propagatedEntryKeys.has(activeEntryKey(entry)) } + ) } + child.copyResolvedInputPathsTo(this) } /** Activates a configured secret only when the resolved runtime value matches its catalog value. */ - recordResolved(name: string, resolvedValue: string): boolean { + recordResolved( + name: string, + resolvedValue: string, + options: { propagated?: boolean } = {} + ): boolean { if (resolvedValue.length === 0) return false - const catalogEntry = this.catalog.get(name) - if (!catalogEntry || catalogEntry.plaintext !== resolvedValue) { + const entry = this.getVerifiedResolvedEntry(name, resolvedValue) + if (!entry) { this.markIncomplete() return false } - this.addActiveEntry({ ...catalogEntry, anonymous: false }) + this.addActiveEntry(entry, options) + return true + } + + /** Records the exact resolved input leaf that activated a configured secret. */ + recordResolvedAtInputPath( + name: string, + resolvedValue: string, + path: ResolvedSecretInputPath | undefined, + options: { propagated?: boolean } = {} + ): boolean { + if (!path || path.length === 0) return this.recordResolved(name, resolvedValue, options) + if (resolvedValue.length === 0) return false + + const entry = this.getVerifiedResolvedEntry(name, resolvedValue) + if (!entry) { + this.markInputPathIncomplete(path) + return false + } + + this.addActiveEntry(entry, options) + this.bindResolvedInputPathEntries(path, [entry]) return true } + private getVerifiedResolvedEntry( + name: string, + resolvedValue: string + ): ActiveSecretEntry | undefined { + const catalogEntry = this.catalog.get(name) + return catalogEntry?.plaintext === resolvedValue + ? { ...catalogEntry, anonymous: false } + : undefined + } + + private bindResolvedInputPathEntries( + path: ResolvedSecretInputPath, + entries: Iterable + ): void { + const key = inputPathKey(path) + const state = this.resolvedInputPaths.get(key) ?? { + path: [...path], + entryKeys: new Set(), + } + for (const entry of entries) state.entryKeys.add(activeEntryKey(entry)) + this.resolvedInputPaths.set(key, state) + } + + /** Stores the exact placeholder-preserving copy produced while resolving one string leaf. */ + recordResolvedInputProjection( + path: ResolvedSecretInputPath | undefined, + rawValue: unknown, + projectedValue: unknown + ): void { + if (!path || path.length === 0) return + const key = inputPathKey(path) + const state = this.resolvedInputPaths.get(key) + if (!state || state.entryKeys.size === 0) return + state.rawValue = rawValue + state.projectedValue = projectedValue + } + + /** Returns whether the resolver recorded any placeholder-preserving input copies. */ + hasResolvedInputProjections(): boolean { + for (const state of this.resolvedInputPaths.values()) { + if (state.rawValue !== undefined && state.projectedValue !== undefined) return true + } + return false + } + + /** + * Carries resolver-recorded provenance through one deterministic block-parameter transform. + * + * The caller supplies the real transformed params and the result of applying the same transform + * to the resolver's placeholder-preserving input copy. Only canonical placeholders in that + * private projected copy establish the mapping; raw values are never searched or rewritten. + */ + recordTransformedInputProjection( + rawTransformed: Record, + projectedTransformed: Record, + options: { targetPaths?: readonly ResolvedSecretInputPath[] } = {} + ): void { + if (!this.complete) return + + const eligibleEntryKeys = new Set() + for (const state of this.resolvedInputPaths.values()) { + if (state.rawValue === undefined || state.projectedValue === undefined) continue + for (const entryKey of state.entryKeys) eligibleEntryKeys.add(entryKey) + } + if (eligibleEntryKeys.size === 0) return + + const entryKeysByName = new Map>() + for (const entryKey of eligibleEntryKeys) { + const entry = this.activeEntries.get(entryKey) + if (!entry || entry.anonymous || entry.name.length === 0) continue + const keys = entryKeysByName.get(entry.name) ?? new Set() + keys.add(entryKey) + entryKeysByName.set(entry.name, keys) + } + if (entryKeysByName.size === 0) return + + const canonicalPlaceholderName = (value: string): string | undefined => { + const match = /^\{\{([^{}]+)\}\}$/.exec(value.trim()) + if (!match) return undefined + const name = match[1].trim() + return /^[A-Za-z0-9_]+$/.test(name) ? name : undefined + } + + const placeholderEntryKeys = (value: unknown): Set => { + const keys = new Set() + if (typeof value !== 'string') return keys + for (const match of value.matchAll(/\{\{([^{}]+)\}\}/g)) { + const name = match[1].trim() + if (!/^[A-Za-z0-9_]+$/.test(name)) continue + for (const entryKey of entryKeysByName.get(name) ?? []) keys.add(entryKey) + } + return keys + } + + const recordState = ( + path: string[], + entryKeys: ReadonlySet, + rawValue: unknown, + projectedValue: unknown + ): void => { + if (path.length === 0 || rawValue === undefined || projectedValue === undefined) return + const key = inputPathKey(path) + const state = this.resolvedInputPaths.get(key) ?? { + path: [...path], + entryKeys: new Set(), + } + if ( + state.rawValue === rawValue && + typeof state.projectedValue === 'string' && + typeof projectedValue === 'string' && + state.projectedValue !== projectedValue + ) { + this.markInputPathIncomplete(path) + return + } + for (const entryKey of entryKeys) state.entryKeys.add(entryKey) + state.rawValue = rawValue + state.projectedValue = projectedValue + this.resolvedInputPaths.set(key, state) + } + + const recordProjectedMarkerAcrossRawLeaves = ( + rawValue: unknown, + projectedValue: string, + path: string[], + entryKeys: ReadonlySet + ): void => { + const pending: Array<{ value: unknown; path: string[] }> = [{ value: rawValue, path }] + const visited = new WeakSet() + while (pending.length > 0) { + const current = pending.pop()! + if (current.value === null || typeof current.value !== 'object') { + recordState(current.path, entryKeys, current.value, projectedValue) + continue + } + if (visited.has(current.value)) continue + visited.add(current.value) + for (const [key, value] of Object.entries(current.value)) { + pending.push({ value, path: [...current.path, key] }) + } + } + } + + const pending: Array<{ raw: unknown; projected: unknown; path: string[] }> = + options.targetPaths === undefined + ? Object.keys(projectedTransformed).map((key) => ({ + raw: rawTransformed[key], + projected: projectedTransformed[key], + path: [key], + })) + : options.targetPaths + .filter((path) => path.length > 0) + .map((path) => ({ + raw: readInputPath(rawTransformed, path), + projected: readInputPath(projectedTransformed, path), + path: [...path], + })) + const visitedPairs = new WeakMap>() + + while (pending.length > 0) { + const current = pending.pop()! + if (Object.is(current.raw, current.projected)) continue + + const projectedEntryKeys = placeholderEntryKeys(current.projected) + if (projectedEntryKeys.size > 0) { + if (current.raw !== null && typeof current.raw === 'object') { + const standaloneName = canonicalPlaceholderName(current.projected as string) + if (!standaloneName || !entryKeysByName.has(standaloneName)) { + this.markInputPathIncomplete(current.path) + return + } + recordProjectedMarkerAcrossRawLeaves( + current.raw, + current.projected as string, + current.path, + projectedEntryKeys + ) + } else { + recordState(current.path, projectedEntryKeys, current.raw, current.projected) + } + continue + } + + if ( + current.raw === null || + typeof current.raw !== 'object' || + current.projected === null || + typeof current.projected !== 'object' + ) { + continue + } + + const projectedObjectsSeen = visitedPairs.get(current.raw) ?? new WeakSet() + if (projectedObjectsSeen.has(current.projected)) continue + projectedObjectsSeen.add(current.projected) + visitedPairs.set(current.raw, projectedObjectsSeen) + + for (const key of Object.keys(current.projected)) { + pending.push({ + raw: (current.raw as Record)[key], + projected: (current.projected as Record)[key], + path: [...current.path, key], + }) + } + } + } + + private projectResolvedInputStates( + selected: Record, + states: Iterable + ): ResolvedSecretInputProjection { + const projected = { ...selected } + const projectedContainers = new WeakMap([[selected, projected]]) + + for (const state of states) { + if (state.rawValue === undefined || state.projectedValue === undefined) continue + if (!Object.hasOwn(selected, state.path[0])) continue + if (readInputPath(selected, state.path) !== state.rawValue) continue + if ( + !writeInputPathOnProjectedCopy( + selected, + projected, + state.path, + state.projectedValue, + projectedContainers + ) + ) { + return { complete: false } + } + } + return { complete: true, value: projected } + } + + /** Projects only resolver-recorded leaves and preserves every object key byte-for-byte. */ + projectResolvedInputSelection(selected: Record): ResolvedSecretInputProjection { + if ( + !this.complete || + this.hasIncompleteInputPathOverlapping(Object.keys(selected).map((key) => [key])) + ) { + return { complete: false } + } + return this.projectResolvedInputStates(selected, this.resolvedInputPaths.values()) + } + + /** + * Produces one causal projection per resolved input path. + * + * Replaying transforms independently keeps a secret-valued discriminator from changing the + * control flow used to trace a different secret-valued input. + */ + projectResolvedInputSelections(selected: Record): + | { + complete: true + values: Array<{ + path: ResolvedSecretInputPath + rawValue: unknown + projectedValue: unknown + value: Record + }> + } + | { complete: false } { + if ( + !this.complete || + this.hasIncompleteInputPathOverlapping(Object.keys(selected).map((key) => [key])) + ) { + return { complete: false } + } + + const values: Array<{ + path: ResolvedSecretInputPath + rawValue: unknown + projectedValue: unknown + value: Record + }> = [] + for (const state of this.resolvedInputPaths.values()) { + if (state.rawValue === undefined || state.projectedValue === undefined) continue + if (!Object.hasOwn(selected, state.path[0])) continue + if (readInputPath(selected, state.path) !== state.rawValue) continue + const projection = this.projectResolvedInputStates(selected, [state]) + if (!projection.complete) return projection + values.push({ + path: state.path, + rawValue: state.rawValue, + projectedValue: state.projectedValue, + value: projection.value, + }) + } + return { complete: true, values } + } + + /** Exports resolver-recorded provenance for selected input paths without inspecting values. */ + exportCommittedProvenanceForInputPaths( + paths: readonly ResolvedSecretInputPath[], + options: ExportResolvedSecretTraceProvenanceForValueOptions = {} + ): ResolvedSecretTraceProvenanceV1 { + if (!this.complete || this.hasIncompleteInputPathOverlapping(paths)) { + return this.incompleteProvenance() + } + const selectedKeys = this.collectInputPathEntryKeys(paths) + const entries = [...this.activeEntries] + .filter(([key]) => selectedKeys.has(key)) + .map(([, entry]) => entry) + return { + version: 1, + complete: true, + entries: this.buildProvenanceEntries(entries, options.anonymous), + ...(this.scope ? { scope: cloneProvenanceScope(this.scope) } : {}), + } + } + /** Imports encrypted provenance only from a boundary that has already established trust. */ async importProvenance( provenance: unknown, @@ -593,12 +1007,15 @@ export class ResolvedSecretTraceRegistry { for (const entry of provenance.entries) { try { const { decrypted } = await decryptSecret(entry.encryptedValue) - this.addActiveEntry({ - name: entry.name ?? '', - plaintext: decrypted, - encryptedValue: entry.encryptedValue, - anonymous: options.anonymous === true || !sameScope || entry.name === undefined, - }) + this.addActiveEntry( + { + name: entry.name ?? '', + plaintext: decrypted, + encryptedValue: entry.encryptedValue, + anonymous: options.anonymous === true || !sameScope || entry.name === undefined, + }, + { propagated: true } + ) } catch { importedAll = false this.markIncomplete() @@ -618,16 +1035,67 @@ export class ResolvedSecretTraceRegistry { value: unknown, options: { trusted: boolean } ): Promise { + const result = await this.importProvenanceForValueInternal(provenance, value, options) + return result.success + } + + /** Imports exact crossing provenance and binds only its matched entries to one resolved input. */ + async importProvenanceForValueAtInputPath( + provenance: unknown, + value: unknown, + inputPath: ResolvedSecretInputPath | undefined, + options: { trusted: boolean } + ): Promise { + return this.importProvenanceForValueInternal(provenance, value, { + ...options, + inputPath, + }) + } + + private async importProvenanceForValueInternal( + provenance: unknown, + value: unknown, + options: { trusted: boolean; inputPath?: ResolvedSecretInputPath } + ): Promise { if (!options.trusted || !isResolvedSecretTraceProvenanceV1(provenance)) { - this.markIncomplete() - return false + this.markInputPathIncomplete(options.inputPath) + return { success: false, matched: false } } const sourceRegistry = new ResolvedSecretTraceRegistry([], provenance.scope) const sourceImported = await sourceRegistry.importProvenance(provenance, { trusted: true }) const filteredProvenance = sourceRegistry.exportProvenanceForValue(value) + if (!sourceImported) { + this.markInputPathIncomplete(options.inputPath) + return { success: false, matched: false } + } + if (!filteredProvenance.complete) { + this.markInputPathIncomplete(options.inputPath) + return { success: true, matched: false } + } const filteredImported = await this.importProvenance(filteredProvenance, { trusted: true }) - return sourceImported && filteredImported + if (options.inputPath && options.inputPath.length > 0 && filteredProvenance.complete) { + const sameScope = scopesMatch(filteredProvenance.scope, this.scope) + this.bindResolvedInputPathEntries( + options.inputPath, + filteredProvenance.entries.flatMap((entry) => { + const anonymous = !sameScope || entry.name === undefined + const importedEntry = this.activeEntries.get( + activeEntryKey({ + name: entry.name ?? '', + plaintext: '', + encryptedValue: entry.encryptedValue, + anonymous, + }) + ) + return importedEntry ? [importedEntry] : [] + }) + ) + } + return { + success: sourceImported && filteredImported, + matched: filteredProvenance.complete && filteredProvenance.entries.length > 0, + } } /** Imports only provenance present in the exact crossing value, preserving names in-scope. */ @@ -646,12 +1114,12 @@ export class ResolvedSecretTraceRegistry { /** * Returns committed literals that must be removed before content can cross into a model. - * Trusted execution boundaries scan their full local catalog and activate exact values before - * exposing a result. Temporary work in another call is intentionally excluded: until that call - * commits a result, its pending state is neither data nor provenance for this projection. + * Only entries activated by an exact resolver or trusted provenance boundary participate; + * configured-but-unused catalog values remain inert. Temporary work in another call is + * intentionally excluded until that call commits a result. */ getModelEgressSnapshot(): ResolvedSecretModelEgressSnapshot { - if (!this.complete) return { complete: false } + if (this.isPermanentlyIncomplete()) return { complete: false } const modelEntries = [...this.activeEntries.values()] const legacyAliasEntries = modelEntries @@ -659,7 +1127,7 @@ export class ResolvedSecretTraceRegistry { .map( (entry): ActiveSecretEntry => ({ ...entry, - plaintext: `__var_${entry.name.replace(/[^a-zA-Z0-9_]/g, '_')}`, + plaintext: createLegacyRuntimeAlias(entry.name), }) ) const matches = this.withJsonStringEncodedMatches( @@ -683,13 +1151,10 @@ export class ResolvedSecretTraceRegistry { const addMatch = (plaintext: string, replacement: string): void => { if (!plaintext) return const existing = replacementByPlaintext.get(plaintext) - if ( - existing === undefined || - replacement === ANONYMOUS_SECRET_TRACE_REPLACEMENT || - (existing !== ANONYMOUS_SECRET_TRACE_REPLACEMENT && - compareStrings(replacement, existing) < 0) - ) { + if (existing === undefined) { replacementByPlaintext.set(plaintext, replacement) + } else if (existing !== replacement) { + replacementByPlaintext.set(plaintext, ANONYMOUS_SECRET_TRACE_REPLACEMENT) } } @@ -722,12 +1187,13 @@ export class ResolvedSecretTraceRegistry { const matches = [...candidatesByPlaintext.keys()].map((plaintext) => { const candidates = candidatesByPlaintext.get(plaintext) ?? [] const anonymous = candidates.some((candidate) => candidate.anonymous) - const firstNamed = candidates - .filter((candidate) => !candidate.anonymous) - .sort((left, right) => compareStrings(left.name, right.name))[0] - const replacement = anonymous - ? ANONYMOUS_SECRET_TRACE_REPLACEMENT - : `{{${firstNamed?.name ?? ''}}}` + const names = new Set( + candidates.filter((candidate) => !candidate.anonymous).map((candidate) => candidate.name) + ) + const replacement = + anonymous || names.size !== 1 + ? ANONYMOUS_SECRET_TRACE_REPLACEMENT + : `{{${names.values().next().value}}}` return { plaintext, replacement } }) @@ -741,11 +1207,11 @@ export class ResolvedSecretTraceRegistry { } isComplete(): boolean { - return this.complete && this.pendingActivations === 0 + return this.complete && this.incompleteInputPaths.size === 0 && this.pendingActivations === 0 } isPermanentlyIncomplete(): boolean { - return !this.complete + return !this.complete || this.incompleteInputPaths.size > 0 } markIncomplete(): void { @@ -790,13 +1256,12 @@ export class ResolvedSecretTraceRegistry { * their guard as permanent incompleteness would incorrectly poison a later resume. */ exportCheckpointProvenance(): ResolvedSecretTraceProvenanceV1 { - const entries = this.complete - ? this.buildProvenanceEntries([...this.activeEntries.values()]) - : [] + const complete = this.complete && this.incompleteInputPaths.size === 0 + const entries = complete ? this.buildProvenanceEntries([...this.activeEntries.values()]) : [] return { version: 1, - complete: this.complete, + complete, entries, ...(this.scope ? { scope: cloneProvenanceScope(this.scope) } : {}), } @@ -810,9 +1275,13 @@ export class ResolvedSecretTraceRegistry { value: unknown, options: ExportResolvedSecretTraceProvenanceForValueOptions = {} ): ResolvedSecretTraceProvenanceV1 { - if (!this.isComplete()) return { version: 1, complete: false, entries: [] } + if (!this.isComplete()) return this.incompleteProvenance() - return this.exportProvenanceForValueFromEntries(value, this.activeEntries.values(), options) + return this.exportProvenanceForValueWithPreparedFilter( + value, + this.getPreparedActiveProvenanceFilter(), + options + ) } /** @@ -824,29 +1293,11 @@ export class ResolvedSecretTraceRegistry { value: unknown, options: ExportResolvedSecretTraceProvenanceForValueOptions = {} ): ResolvedSecretTraceProvenanceV1 { - if (!this.complete) return { version: 1, complete: false, entries: [] } - - return this.exportProvenanceForValueFromEntries(value, this.activeEntries.values(), options) - } - - /** - * Reconstructs provenance at a trusted compatibility boundary by scanning only the current - * catalog and committed active entries for exact values in the bounded payload. Callers must - * not use this for arbitrary external results: catalog scanning is reserved for persisted - * legacy state or locally executed outputs whose older producer could not record activation. - */ - exportCatalogProvenanceForValue( - value: unknown, - options: ExportResolvedSecretTraceProvenanceForValueOptions = {} - ): ResolvedSecretTraceProvenanceV1 { - if (!this.complete) return { version: 1, complete: false, entries: [] } + if (this.isPermanentlyIncomplete()) return this.incompleteProvenance() - const catalogEntries = [...this.catalog.values()].map( - (entry): ActiveSecretEntry => ({ ...entry, anonymous: false }) - ) - return this.exportProvenanceForValueFromEntries( + return this.exportProvenanceForValueWithPreparedFilter( value, - [...catalogEntries, ...this.activeEntries.values()], + this.getPreparedActiveProvenanceFilter(), options ) } @@ -864,7 +1315,27 @@ export class ResolvedSecretTraceRegistry { candidateEntries: Iterable, options: ExportResolvedSecretTraceProvenanceForValueOptions ): ResolvedSecretTraceProvenanceV1 { - const candidatesByPlaintext = new Map() + return this.exportProvenanceForValuesWithPreparedFilter( + values, + this.prepareProvenanceFilter(candidateEntries), + options + ) + } + + private getPreparedActiveProvenanceFilter(): PreparedProvenanceFilterResult { + if (this.activeProvenanceFilterCache?.revision === this.modelEgressRevision) { + return this.activeProvenanceFilterCache.result + } + + const result = this.prepareProvenanceFilter(this.activeEntries.values()) + this.activeProvenanceFilterCache = { revision: this.modelEgressRevision, result } + return result + } + + private prepareProvenanceFilter( + candidateEntries: Iterable + ): PreparedProvenanceFilterResult { + const candidatesByPlaintext = new Map() const sortedCandidateEntries = [...candidateEntries].sort( (left, right) => compareStrings(left.name, right.name) || @@ -872,33 +1343,80 @@ export class ResolvedSecretTraceRegistry { ) for (const entry of sortedCandidateEntries) { if (entry.plaintext.length === 0) continue - const existing = candidatesByPlaintext.get(entry.plaintext) - if (!existing || (!existing.anonymous && entry.anonymous)) { - candidatesByPlaintext.set(entry.plaintext, entry) + const candidates = candidatesByPlaintext.get(entry.plaintext) ?? [] + const entryKey = activeEntryKey(entry) + if (!candidates.some((candidate) => activeEntryKey(candidate) === entryKey)) { + candidates.push(entry) + candidatesByPlaintext.set(entry.plaintext, candidates) } } const candidatesByScanLiteral = new Map() + const candidateEntryKeys = new Set() const addScanLiteral = (literal: string, entry: ActiveSecretEntry): void => { if (literal.length === 0) return const candidates = candidatesByScanLiteral.get(literal) ?? [] - if (!candidates.some((candidate) => candidate.plaintext === entry.plaintext)) { + const entryKey = activeEntryKey(entry) + if (!candidates.some((candidate) => activeEntryKey(candidate) === entryKey)) { candidates.push(entry) candidatesByScanLiteral.set(literal, candidates) } + candidateEntryKeys.add(entryKey) } - for (const entry of candidatesByPlaintext.values()) { - addScanLiteral(entry.plaintext, entry) - addScanLiteral(JSON.stringify(entry.plaintext).slice(1, -1), entry) + for (const candidates of candidatesByPlaintext.values()) { + for (const entry of candidates) { + addScanLiteral(entry.plaintext, entry) + addScanLiteral(JSON.stringify(entry.plaintext).slice(1, -1), entry) + } + } + + const candidatesByAlias = new Map() + for (const entry of sortedCandidateEntries) { + if (!entry.name) continue + const alias = createLegacyRuntimeAlias(entry.name) + const candidates = candidatesByAlias.get(alias) ?? [] + const entryKey = activeEntryKey(entry) + if (!candidates.some((candidate) => activeEntryKey(candidate) === entryKey)) { + candidates.push(entry) + candidatesByAlias.set(alias, candidates) + } + candidateEntryKeys.add(entryKey) } - const matchedEntries = new Map() let matcher: ResolvedSecretMatcher | undefined try { matcher = createResolvedSecretMatcher( [...candidatesByScanLiteral.keys()].map((plaintext) => ({ plaintext, replacement: '' })) ) } catch { + return { complete: false } + } + + return { + complete: true, + filter: { + candidatesByScanLiteral, + candidatesByAlias, + candidateEntryKeys, + ...(matcher ? { matcher } : {}), + }, + } + } + + private exportProvenanceForValueWithPreparedFilter( + value: unknown, + prepared: PreparedProvenanceFilterResult, + options: ExportResolvedSecretTraceProvenanceForValueOptions + ): ResolvedSecretTraceProvenanceV1 { + return this.exportProvenanceForValuesWithPreparedFilter([value], prepared, options) + } + + private exportProvenanceForValuesWithPreparedFilter( + values: Iterable, + prepared: PreparedProvenanceFilterResult, + options: ExportResolvedSecretTraceProvenanceForValueOptions + ): ResolvedSecretTraceProvenanceV1 { + if (!prepared.complete) { return { version: 1, complete: false, @@ -906,6 +1424,10 @@ export class ResolvedSecretTraceRegistry { ...(this.scope ? { scope: cloneProvenanceScope(this.scope) } : {}), } } + + const { candidatesByScanLiteral, candidatesByAlias, candidateEntryKeys, matcher } = + prepared.filter + const matchedEntries = new Map() const pendingValues: unknown[] = [] try { for (const value of values) { @@ -933,23 +1455,36 @@ export class ResolvedSecretTraceRegistry { let matchEvents = 0 let enumeratedProperties = 0 let scanComplete = true + const matchedAliases = new Set() const scanString = (candidate: string): boolean => { scannedCharacters += candidate.length if (scannedCharacters > MAX_PROVENANCE_FILTER_CHARACTERS) return false - if (!matcher) return true try { - matchEvents += scanResolvedSecretString( - candidate, - matcher, - (scanLiteral) => { - for (const entry of candidatesByScanLiteral.get(scanLiteral) ?? []) { - matchedEntries.set(entry.plaintext, entry) - } - }, - MAX_PROVENANCE_FILTER_MATCH_EVENTS - matchEvents - ) + if (matcher) { + matchEvents += scanResolvedSecretString( + candidate, + matcher, + (scanLiteral) => { + for (const entry of candidatesByScanLiteral.get(scanLiteral) ?? []) { + matchedEntries.set(activeEntryKey(entry), entry) + } + }, + MAX_PROVENANCE_FILTER_MATCH_EVENTS - matchEvents + ) + } + for (const match of candidate.matchAll(LEGACY_RUNTIME_ALIAS_PATTERN)) { + const alias = match[0] + const candidates = candidatesByAlias.get(alias) + if (!candidates || matchedAliases.has(alias)) continue + matchedAliases.add(alias) + matchEvents++ + if (matchEvents > MAX_PROVENANCE_FILTER_MATCH_EVENTS) return false + for (const entry of candidates) { + matchedEntries.set(activeEntryKey(entry), entry) + } + } } catch { return false } @@ -960,7 +1495,7 @@ export class ResolvedSecretTraceRegistry { if (scannedNodes + pendingValues.length >= MAX_PROVENANCE_FILTER_NODES) return false scannedNodes++ if (!scanString(key)) return false - if (matchedEntries.size >= candidatesByPlaintext.size) return true + if (matchedEntries.size >= candidateEntryKeys.size) return true if ('value' in descriptor) { if (scannedNodes + pendingValues.length >= MAX_PROVENANCE_FILTER_NODES) return false @@ -971,7 +1506,7 @@ export class ResolvedSecretTraceRegistry { return true } - while (pendingValues.length > 0 && matchedEntries.size < candidatesByPlaintext.size) { + while (pendingValues.length > 0 && matchedEntries.size < candidateEntryKeys.size) { const current = pendingValues.pop() scannedNodes++ if (scannedNodes > MAX_PROVENANCE_FILTER_NODES) { @@ -1007,9 +1542,9 @@ export class ResolvedSecretTraceRegistry { scanComplete = false break } - if (matchedEntries.size >= candidatesByPlaintext.size) break + if (matchedEntries.size >= candidateEntryKeys.size) break } - if (!scanComplete || matchedEntries.size >= candidatesByPlaintext.size) break + if (!scanComplete || matchedEntries.size >= candidateEntryKeys.size) break for (const key in current as Record) { enumeratedProperties++ @@ -1024,7 +1559,7 @@ export class ResolvedSecretTraceRegistry { scanComplete = false break } - if (matchedEntries.size >= candidatesByPlaintext.size) break + if (matchedEntries.size >= candidateEntryKeys.size) break } if (!scanComplete) break } catch { @@ -1033,7 +1568,7 @@ export class ResolvedSecretTraceRegistry { } } - const complete = this.complete && scanComplete + const complete = !this.isPermanentlyIncomplete() && scanComplete const entries = complete ? this.buildProvenanceEntries([...matchedEntries.values()], options.anonymous) : [] @@ -1045,10 +1580,75 @@ export class ResolvedSecretTraceRegistry { } } - private addActiveEntry(entry: ActiveSecretEntry): void { - const key = `${entry.anonymous ? 'anonymous' : entry.name}\u0000${entry.encryptedValue}` + private collectInputPathEntryKeys(paths: readonly ResolvedSecretInputPath[]): Set { + const selected = new Set() + for (const state of this.resolvedInputPaths.values()) { + if (!paths.some((path) => isInputPathWithin(state.path, path))) continue + for (const entryKey of state.entryKeys) selected.add(entryKey) + } + return selected + } + + private incompleteProvenance(): ResolvedSecretTraceProvenanceV1 { + return { + version: 1, + complete: false, + entries: [], + ...(this.scope ? { scope: cloneProvenanceScope(this.scope) } : {}), + } + } + + private copyResolvedInputPathsTo( + target: ResolvedSecretTraceRegistry, + roots?: readonly ResolvedSecretInputPath[] + ): void { + for (const [key, state] of this.resolvedInputPaths) { + if (roots && !roots.some((root) => isInputPathWithin(state.path, root))) continue + const targetState = target.resolvedInputPaths.get(key) ?? { + path: [...state.path], + entryKeys: new Set(), + } + for (const entryKey of state.entryKeys) targetState.entryKeys.add(entryKey) + if (state.rawValue !== undefined && state.projectedValue !== undefined) { + targetState.rawValue = state.rawValue + targetState.projectedValue = state.projectedValue + } + target.resolvedInputPaths.set(key, targetState) + } + } + + private hasIncompleteInputPathOverlapping(paths: readonly ResolvedSecretInputPath[]): boolean { + return [...this.incompleteInputPaths.values()].some((incompletePath) => + paths.some((path) => inputPathsOverlap(incompletePath, path)) + ) + } + + private markInputPathIncomplete(path: ResolvedSecretInputPath | undefined): void { + if (!path || path.length === 0) { + this.markIncomplete() + return + } + const key = inputPathKey(path) + if (this.incompleteInputPaths.has(key)) return + this.incompleteInputPaths.set(key, [...path]) + this.modelEgressRevision += 1 + } + + private copyIncompleteInputPathsTo( + target: ResolvedSecretTraceRegistry, + roots?: readonly ResolvedSecretInputPath[] + ): void { + for (const [key, path] of this.incompleteInputPaths) { + if (roots && !roots.some((root) => inputPathsOverlap(path, root))) continue + target.incompleteInputPaths.set(key, [...path]) + } + } + + private addActiveEntry(entry: ActiveSecretEntry, options: { propagated?: boolean } = {}): void { + const key = activeEntryKey(entry) const existing = this.activeEntries.get(key) if (existing) { + if (options.propagated) this.propagatedEntryKeys.add(key) if ( existing.name === entry.name && existing.plaintext === entry.plaintext && @@ -1076,6 +1676,7 @@ export class ResolvedSecretTraceRegistry { return } this.activeEntries.set(key, entry) + if (options.propagated) this.propagatedEntryKeys.add(key) this.activeProvenanceEntryBytes += separatorBytes + entryBytes this.modelEgressRevision += 1 } @@ -1088,7 +1689,6 @@ export class ResolvedSecretTraceRegistry { catalogEntryByteSize(entry) const nextCatalogSize = this.catalog.size + (existing ? 0 : 1) if (nextCatalogSize > MAX_TRACE_CATALOG_ENTRIES || nextCatalogBytes > MAX_TRACE_CATALOG_BYTES) { - this.markIncomplete() return false } diff --git a/apps/sim/executor/variables/resolver.test.ts b/apps/sim/executor/variables/resolver.test.ts index ccec07b2aa4..6f246b5bdff 100644 --- a/apps/sim/executor/variables/resolver.test.ts +++ b/apps/sim/executor/variables/resolver.test.ts @@ -3,6 +3,7 @@ */ import { loggerMock } from '@sim/testing' import { beforeEach, describe, expect, it, vi } from 'vitest' +import { projectResolvedModelInput } from '@/lib/execution/model-input-provenance' import { LARGE_ARRAY_MANIFEST_VERSION, type LargeArrayManifest, @@ -26,6 +27,10 @@ vi.mock('@/lib/execution/payloads/store', () => ({ materializeLargeValueRef: vi.fn(), })) +vi.mock('@/lib/core/security/encryption', () => ({ + decryptSecret: vi.fn(async (encryptedValue: string) => ({ decrypted: encryptedValue })), +})) + function createBlock(id: string, name: string, type: string, params = {}): SerializedBlock { return { id, @@ -115,6 +120,7 @@ function createResolver( return { block: functionBlock, ctx, + state, resolver: new VariableResolver(workflow, {}, state, options), } } @@ -187,6 +193,109 @@ describe('VariableResolver function block inputs', () => { ]) }) + it('binds propagated references to exact model-selected inputs without changing runtime values', async () => { + const secret = 'x' + const provenance = { + version: 1 as const, + complete: true, + entries: [{ name: 'TOKEN', encryptedValue: secret }], + } + const producer = createBlock('producer', 'Producer', BlockType.API) + const loop = createBlock('loop-1', 'Loop1', BlockType.LOOP) + const parallel = createBlock('parallel-1', 'Parallel1', BlockType.PARALLEL) + const consumer = createBlock('consumer', 'Consumer', BlockType.API) + const workflowVariables = { + 'var-1': { id: 'var-1', name: 'token', type: 'string', value: secret }, + } + const workflow: SerializedWorkflow = { + version: '1', + blocks: [producer, loop, parallel, consumer], + connections: [], + loops: { + 'loop-1': { id: 'loop-1', nodes: [], iterations: 1, loopType: 'for' }, + }, + parallels: { + 'parallel-1': { + id: 'parallel-1', + nodes: [], + parallelType: 'count', + count: 1, + }, + }, + } + const state = new ExecutionState() + state.setBlockOutput('producer', { result: secret }, 0, provenance) + state.setBlockOutput('loop-1', { results: [secret] }, 0, provenance) + state.setBlockOutput('parallel-1', { results: [secret] }, 0, provenance) + const registry = new ResolvedSecretTraceRegistry() + const ctx = { + blockStates: state.getBlockStates(), + blockLogs: [], + environmentVariables: {}, + workflowVariables, + workflowVariableResolvedSecretTraceProvenance: { 'var-1': provenance }, + resolvedSecretTraceRegistry: registry, + decisions: { router: new Map(), condition: new Map() }, + loopExecutions: new Map(), + parallelExecutions: new Map(), + executedBlocks: new Set(), + activeExecutionPath: new Set(), + completedLoops: new Set(), + metadata: {}, + } as ExecutionContext + const resolver = new VariableResolver(workflow, workflowVariables, state, { + navigatePathAsync, + }) + const inputs = { + blockPrompt: 'Box: ', + workflowPrompt: 'Workflow: ', + loopPrompt: 'Loop: ', + parallelPrompt: 'Parallel: ', + } + + const resolved = await resolver.resolveInputs(ctx, consumer.id, inputs, consumer) + + expect(resolved).toEqual({ + blockPrompt: `Box: ${secret}`, + workflowPrompt: `Workflow: ${secret}`, + loopPrompt: `Loop: ${secret}`, + parallelPrompt: `Parallel: ${secret}`, + }) + const projection = projectResolvedModelInput( + registry, + resolved, + Object.keys(inputs).map((key) => [key]) + ) + expect(projection.complete).toBe(true) + if (!projection.complete) throw new Error('Expected complete model projection') + expect(projection.value).toEqual(inputs) + }) + + it('preserves the destination path when resolving one whole reference directly', async () => { + const secret = 'resolved-secret' + const provenance = { + version: 1 as const, + complete: true, + entries: [{ name: 'TOKEN', encryptedValue: secret }], + } + const { ctx, resolver, state } = createResolver() + state.setBlockOutput('producer', { result: secret }, 0, provenance) + ctx.blockStates = state.getBlockStates() + const registry = new ResolvedSecretTraceRegistry() + ctx.resolvedSecretTraceRegistry = registry + + await expect( + resolver.resolveSingleReference(ctx, 'function', '', undefined, { + inputPath: ['prompt'], + }) + ).resolves.toBe(secret) + expect(registry.exportCommittedProvenanceForInputPaths([['prompt']])).toEqual(provenance) + expect(registry.projectResolvedInputSelection({ prompt: secret })).toEqual({ + complete: true, + value: { prompt: '' }, + }) + }) + it('returns empty inputs when params are missing', async () => { const { block, ctx, resolver } = createResolver() diff --git a/apps/sim/executor/variables/resolver.ts b/apps/sim/executor/variables/resolver.ts index c5a19566df9..b2cbd009555 100644 --- a/apps/sim/executor/variables/resolver.ts +++ b/apps/sim/executor/variables/resolver.ts @@ -215,11 +215,15 @@ export class VariableResolver { resolved[key] = resolvedItems display[key] = displayItems } else { - resolved[key] = await this.resolveValue(ctx, currentNodeId, value, undefined, block) + resolved[key] = await this.resolveValue(ctx, currentNodeId, value, undefined, block, { + inputPath: [key], + }) display[key] = resolved[key] } } else { - resolved[key] = await this.resolveValue(ctx, currentNodeId, value, undefined, block) + resolved[key] = await this.resolveValue(ctx, currentNodeId, value, undefined, block, { + inputPath: [key], + }) display[key] = resolved[key] } } @@ -256,14 +260,20 @@ export class VariableResolver { if (Array.isArray(conditions)) { resolved.conditions = await Promise.all( - conditions.map(async (condition) => { + conditions.map(async (condition, conditionIndex) => { if (!condition || typeof condition !== 'object') return condition const value = Reflect.get(condition, 'value') return { ...condition, value: typeof value === 'string' - ? await this.resolveTemplateWithoutConditionFormatting(ctx, currentNodeId, value) + ? await this.resolveTemplateWithoutConditionFormatting( + ctx, + currentNodeId, + value, + undefined, + ['conditions', String(conditionIndex), 'value'] + ) : value, } }) @@ -274,7 +284,8 @@ export class VariableResolver { currentNodeId, conditions, undefined, - block + block, + { inputPath: ['conditions'] } ) } } @@ -285,6 +296,7 @@ export class VariableResolver { } resolved[key] = await this.resolveValue(ctx, currentNodeId, value, undefined, block, { allowLargeValueRefs: this.canResolveInputToLargeValueRef(block, key), + inputPath: [key], }) } return resolved @@ -307,7 +319,7 @@ export class VariableResolver { currentNodeId: string, reference: string, loopScope?: LoopScope, - options: { allowLargeValueRefs?: boolean } = {} + options: { allowLargeValueRefs?: boolean; inputPath?: readonly string[] } = {} ): Promise { if (typeof reference === 'string') { const trimmed = reference.trim() @@ -318,17 +330,21 @@ export class VariableResolver { currentNodeId, loopScope, allowLargeValueRefs: options.allowLargeValueRefs, + inputPath: options.inputPath, } const result = await this.resolveReference(trimmed, resolutionContext) - if (result === RESOLVED_EMPTY) { - return null - } - return result + const resolved = result === RESOLVED_EMPTY ? null : result + ctx.resolvedSecretTraceRegistry?.recordResolvedInputProjection( + options.inputPath, + resolved, + trimmed + ) + return resolved } } - return this.resolveValue(ctx, currentNodeId, reference, loopScope) + return this.resolveValue(ctx, currentNodeId, reference, loopScope, undefined, options) } private async resolveValue( @@ -337,7 +353,7 @@ export class VariableResolver { value: any, loopScope?: LoopScope, block?: SerializedBlock, - options: { allowLargeValueRefs?: boolean } = {} + options: { allowLargeValueRefs?: boolean; inputPath?: readonly string[] } = {} ): Promise { if (value === null || value === undefined) { return value @@ -345,16 +361,24 @@ export class VariableResolver { if (Array.isArray(value)) { return Promise.all( - value.map((v) => this.resolveValue(ctx, currentNodeId, v, loopScope, block, options)) + value.map((v, index) => + this.resolveValue(ctx, currentNodeId, v, loopScope, block, { + ...options, + inputPath: options.inputPath ? [...options.inputPath, String(index)] : undefined, + }) + ) ) } if (typeof value === 'object') { const entries = await Promise.all( - Object.entries(value).map(async ([key, val]) => [ - key, - await this.resolveValue(ctx, currentNodeId, val, loopScope, block, options), - ]) + Object.entries(value).map(async ([key, val]) => { + const resolvedValue = await this.resolveValue(ctx, currentNodeId, val, loopScope, block, { + ...options, + inputPath: options.inputPath ? [...options.inputPath, key] : undefined, + }) + return [key, resolvedValue] + }) ) return Object.fromEntries(entries) } @@ -1237,7 +1261,7 @@ export class VariableResolver { template: string, loopScope?: LoopScope, block?: SerializedBlock, - options: { allowLargeValueRefs?: boolean } = {} + options: { allowLargeValueRefs?: boolean; inputPath?: readonly string[] } = {} ): Promise { const resolutionContext: ResolutionContext = { executionContext: ctx, @@ -1245,6 +1269,7 @@ export class VariableResolver { currentNodeId, loopScope, allowLargeValueRefs: options.allowLargeValueRefs, + inputPath: options.inputPath, } let replacementError: Error | null = null @@ -1257,28 +1282,48 @@ export class VariableResolver { | undefined) : undefined - let result = await replaceValidReferencesAsync(template, async (match) => { + let projectedReferenceResult = '' + let projectedReferenceCursor = 0 + let result = await replaceValidReferencesAsync(template, async (match, index) => { if (replacementError) return match + projectedReferenceResult += template.slice(projectedReferenceCursor, index) + projectedReferenceCursor = index + match.length + let containsResolvedSecret = false + const referenceContext: ResolutionContext = { + ...resolutionContext, + onResolvedSecretReference: () => { + containsResolvedSecret = true + }, + } + try { - const resolved = await this.resolveReference(match, resolutionContext) + const resolved = await this.resolveReference(match, referenceContext) if (resolved === undefined) { + projectedReferenceResult += match return match } if (resolved === RESOLVED_EMPTY) { if (blockType === BlockType.FUNCTION) { - return this.blockResolver.formatValueForBlock(null, blockType, language) + const formatted = this.blockResolver.formatValueForBlock(null, blockType, language) + projectedReferenceResult += formatted + return formatted } + projectedReferenceResult += '' return '' } - return this.blockResolver.formatValueForBlock(resolved, blockType, language) + const formatted = this.blockResolver.formatValueForBlock(resolved, blockType, language) + projectedReferenceResult += containsResolvedSecret ? match : formatted + return formatted } catch (error) { replacementError = toError(error) + projectedReferenceResult += match return match } }) + projectedReferenceResult += template.slice(projectedReferenceCursor) if (replacementError !== null) { throw replacementError @@ -1288,6 +1333,11 @@ export class VariableResolver { const resolved = await this.resolveReference(match, resolutionContext) return typeof resolved === 'string' ? resolved : match }) + ctx.resolvedSecretTraceRegistry?.recordResolvedInputProjection( + options.inputPath, + result, + projectedReferenceResult + ) return result } @@ -1295,27 +1345,43 @@ export class VariableResolver { ctx: ExecutionContext, currentNodeId: string, template: string, - loopScope?: LoopScope + loopScope?: LoopScope, + inputPath?: readonly string[] ): Promise { const resolutionContext: ResolutionContext = { executionContext: ctx, executionState: this.state, currentNodeId, loopScope, + inputPath, } let replacementError: Error | null = null - let result = await replaceValidReferencesAsync(template, async (match) => { + let projectedReferenceResult = '' + let projectedReferenceCursor = 0 + let result = await replaceValidReferencesAsync(template, async (match, index) => { if (replacementError) return match + projectedReferenceResult += template.slice(projectedReferenceCursor, index) + projectedReferenceCursor = index + match.length + let containsResolvedSecret = false + const referenceContext: ResolutionContext = { + ...resolutionContext, + onResolvedSecretReference: () => { + containsResolvedSecret = true + }, + } + try { - const resolved = await this.resolveReference(match, resolutionContext) + const resolved = await this.resolveReference(match, referenceContext) if (resolved === undefined) { + projectedReferenceResult += match return match } if (resolved === RESOLVED_EMPTY) { + projectedReferenceResult += 'null' return 'null' } @@ -1327,17 +1393,25 @@ export class VariableResolver { .replace(/\r/g, '\\r') .replace(/\u2028/g, '\\u2028') .replace(/\u2029/g, '\\u2029') - return `'${escaped}'` + const formatted = `'${escaped}'` + projectedReferenceResult += containsResolvedSecret ? match : formatted + return formatted } if (typeof resolved === 'object' && resolved !== null) { - return JSON.stringify(resolved) + const formatted = JSON.stringify(resolved) + projectedReferenceResult += containsResolvedSecret ? match : formatted + return formatted } - return String(resolved) + const formatted = String(resolved) + projectedReferenceResult += containsResolvedSecret ? match : formatted + return formatted } catch (error) { replacementError = toError(error) + projectedReferenceResult += match return match } }) + projectedReferenceResult += template.slice(projectedReferenceCursor) if (replacementError !== null) { throw replacementError @@ -1347,6 +1421,11 @@ export class VariableResolver { const resolved = await this.resolveReference(match, resolutionContext) return typeof resolved === 'string' ? resolved : match }) + ctx.resolvedSecretTraceRegistry?.recordResolvedInputProjection( + inputPath, + result, + projectedReferenceResult + ) return result } diff --git a/apps/sim/executor/variables/resolvers/block.test.ts b/apps/sim/executor/variables/resolvers/block.test.ts index c13e43f0464..7fa1576742b 100644 --- a/apps/sim/executor/variables/resolvers/block.test.ts +++ b/apps/sim/executor/variables/resolvers/block.test.ts @@ -1,7 +1,9 @@ import { describe, expect, it, vi } from 'vitest' +import { isLargeValueRef } from '@/lib/execution/payloads/large-value-ref' import { compactExecutionPayload } from '@/lib/execution/payloads/serializer' import { ExecutionState } from '@/executor/execution/state' import { ResolvedSecretTraceRegistry } from '@/executor/utils/resolved-secret-trace-registry' +import { navigatePathAsync } from '@/executor/variables/resolvers/reference-async.server' import { BlockResolver } from './block' import { RESOLVED_EMPTY, type ResolutionContext } from './reference' @@ -640,6 +642,58 @@ describe('BlockResolver', () => { { plaintext: 'secret-value', replacement: '{{API_KEY}}' }, ]) }) + + it('filters compacted block candidates against the exact selected leaf', async () => { + const workflow = createTestWorkflow([{ id: 'source' }]) + const resolver = new BlockResolver(workflow, navigatePathAsync) + const compacted = await compactExecutionPayload( + { + result: { + huge: 'p'.repeat(9 * 1024 * 1024), + public: 'ok', + secret: 'secret-value', + }, + }, + { + preserveRoot: true, + workspaceId: 'workspace-1', + workflowId: 'workflow-1', + executionId: 'execution-1', + } + ) + expect(isLargeValueRef(compacted.result.huge)).toBe(true) + const candidateProvenance = { + version: 1 as const, + complete: true, + entries: [{ name: 'API_KEY', encryptedValue: 'secret-value' }], + } + + const publicRegistry = new ResolvedSecretTraceRegistry() + const publicContext = createTestContext('current') + publicContext.inputPath = ['prompt'] + publicContext.executionContext.resolvedSecretTraceRegistry = publicRegistry + publicContext.executionState.setBlockOutput('source', compacted, 0, candidateProvenance) + + await expect(resolver.resolveAsync('', publicContext)).resolves.toBe( + 'ok' + ) + expect(publicRegistry.isComplete()).toBe(true) + expect(publicRegistry.getActiveMatches()).toEqual([]) + + const secretRegistry = new ResolvedSecretTraceRegistry() + const secretContext = createTestContext('current') + secretContext.inputPath = ['prompt'] + secretContext.executionContext.resolvedSecretTraceRegistry = secretRegistry + secretContext.executionState.setBlockOutput('source', compacted, 0, candidateProvenance) + + await expect(resolver.resolveAsync('', secretContext)).resolves.toBe( + 'secret-value' + ) + expect(secretRegistry.isComplete()).toBe(true) + expect(secretRegistry.getActiveMatches()).toEqual([ + { plaintext: 'secret-value', replacement: '{{API_KEY}}' }, + ]) + }) }) describe('formatValueForBlock', () => { diff --git a/apps/sim/executor/variables/resolvers/block.ts b/apps/sim/executor/variables/resolvers/block.ts index acdd607fa4d..b057a97501c 100644 --- a/apps/sim/executor/variables/resolvers/block.ts +++ b/apps/sim/executor/variables/resolvers/block.ts @@ -332,11 +332,14 @@ export class BlockResolver implements Resolver { ) { return value } - await context.executionContext.resolvedSecretTraceRegistry.importProvenanceForValue( - state.resolvedSecretTraceProvenance, - value, - { trusted: true } - ) + const imported = + await context.executionContext.resolvedSecretTraceRegistry.importProvenanceForValueAtInputPath( + state.resolvedSecretTraceProvenance, + value, + context.inputPath, + { trusted: true } + ) + if (imported.matched) context.onResolvedSecretReference?.() return value } diff --git a/apps/sim/executor/variables/resolvers/env.ts b/apps/sim/executor/variables/resolvers/env.ts index c0ddfeef259..4e4483965c6 100644 --- a/apps/sim/executor/variables/resolvers/env.ts +++ b/apps/sim/executor/variables/resolvers/env.ts @@ -17,7 +17,11 @@ export class EnvResolver implements Resolver { return reference } if (Object.hasOwn(context.executionContext.environmentVariables, varName)) { - context.executionContext.resolvedSecretTraceRegistry?.recordResolved(varName, value) + context.executionContext.resolvedSecretTraceRegistry?.recordResolvedAtInputPath( + varName, + value, + context.inputPath + ) } return value } diff --git a/apps/sim/executor/variables/resolvers/loop.ts b/apps/sim/executor/variables/resolvers/loop.ts index 1c43c22a47c..3c29af6a45c 100644 --- a/apps/sim/executor/variables/resolvers/loop.ts +++ b/apps/sim/executor/variables/resolvers/loop.ts @@ -279,7 +279,13 @@ export class LoopResolver implements Resolver { const resolvedValue = await value const registry = context.executionContext.resolvedSecretTraceRegistry if (!registry || !provenance) return resolvedValue - await registry.importProvenanceForValue(provenance, resolvedValue, { trusted: true }) + const imported = await registry.importProvenanceForValueAtInputPath( + provenance, + resolvedValue, + context.inputPath, + { trusted: true } + ) + if (imported.matched) context.onResolvedSecretReference?.() return resolvedValue } diff --git a/apps/sim/executor/variables/resolvers/parallel.ts b/apps/sim/executor/variables/resolvers/parallel.ts index 77857001ef8..fdec9d5cdd0 100644 --- a/apps/sim/executor/variables/resolvers/parallel.ts +++ b/apps/sim/executor/variables/resolvers/parallel.ts @@ -386,7 +386,13 @@ export class ParallelResolver implements Resolver { const resolvedValue = await value const registry = context.executionContext.resolvedSecretTraceRegistry if (!registry || !provenance) return resolvedValue - await registry.importProvenanceForValue(provenance, resolvedValue, { trusted: true }) + const imported = await registry.importProvenanceForValueAtInputPath( + provenance, + resolvedValue, + context.inputPath, + { trusted: true } + ) + if (imported.matched) context.onResolvedSecretReference?.() return resolvedValue } diff --git a/apps/sim/executor/variables/resolvers/reference.ts b/apps/sim/executor/variables/resolvers/reference.ts index 1f71fa7fb1b..a5005fcf4bc 100644 --- a/apps/sim/executor/variables/resolvers/reference.ts +++ b/apps/sim/executor/variables/resolvers/reference.ts @@ -34,6 +34,8 @@ export interface ResolutionContext { currentNodeId: string loopScope?: LoopScope allowLargeValueRefs?: boolean + inputPath?: readonly string[] + onResolvedSecretReference?: () => void } export interface Resolver { diff --git a/apps/sim/executor/variables/resolvers/workflow.ts b/apps/sim/executor/variables/resolvers/workflow.ts index 6e4716d3477..7bc0a6299ad 100644 --- a/apps/sim/executor/variables/resolvers/workflow.ts +++ b/apps/sim/executor/variables/resolvers/workflow.ts @@ -129,7 +129,13 @@ export class WorkflowResolver implements Resolver { context.executionContext.workflowVariableResolvedSecretTraceProvenance?.[variableId] if (!registry || !provenance) return value - await registry.importProvenanceForValue(provenance, value, { trusted: true }) + const imported = await registry.importProvenanceForValueAtInputPath( + provenance, + value, + context.inputPath, + { trusted: true } + ) + if (imported.matched) context.onResolvedSecretReference?.() return value } diff --git a/apps/sim/lib/copilot/chat/post.ts b/apps/sim/lib/copilot/chat/post.ts index 0dad00dc5d0..4147aa87894 100644 --- a/apps/sim/lib/copilot/chat/post.ts +++ b/apps/sim/lib/copilot/chat/post.ts @@ -4,7 +4,6 @@ import { copilotChats } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { getErrorMessage } from '@sim/utils/errors' import { generateId } from '@sim/utils/id' -import { isPlainRecord } from '@sim/utils/object' import { eq } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' import { z } from 'zod' @@ -66,8 +65,6 @@ import { isWorkspaceAccessDeniedError, type PermissionType, } from '@/lib/workspaces/permissions/utils' -import { projectResolvedSecretModelContent } from '@/executor/utils/resolved-secret-content-projection' -import type { ResolvedSecretTraceRegistry } from '@/executor/utils/resolved-secret-trace-registry' import type { ChatContext } from '@/stores/panel' export const maxDuration = 3600 @@ -522,36 +519,6 @@ async function resolveAgentContexts(params: { return agentContexts } -function projectAgentContextInputs( - message: string, - contexts: UnifiedChatRequest['contexts'], - registry: ResolvedSecretTraceRegistry | undefined -): { message: string; contexts: UnifiedChatRequest['contexts'] } { - const labels = (contexts ?? []).map((context) => context.label ?? null) - const projection = projectResolvedSecretModelContent({ message, labels }, registry) - if (!projection.safe || !isPlainRecord(projection.value)) { - throw new Error('Agent context input could not be safely projected') - } - const projectedMessage = projection.value.message - const projectedLabels = projection.value.labels - if ( - typeof projectedMessage !== 'string' || - !Array.isArray(projectedLabels) || - projectedLabels.length !== labels.length || - !projectedLabels.every((label) => label === null || typeof label === 'string') - ) { - throw new Error('Agent context input could not be safely projected') - } - - return { - message: projectedMessage, - contexts: contexts?.map((context, index) => ({ - ...context, - ...(projectedLabels[index] === null ? {} : { label: projectedLabels[index] }), - })), - } -} - async function persistUserMessage(params: { chatId?: string userMessageId: string @@ -1211,12 +1178,7 @@ export async function handleUnifiedChatPost(req: NextRequest) { }), activeOtelRoot.context ) - const agentContextsPromise = executionContextPromise.then((executionContext) => { - const projected = projectAgentContextInputs( - body.message, - normalizedContexts, - executionContext.resolvedSecretTraceRegistry - ) + const agentContextsPromise = executionContextPromise.then(() => { return withCopilotSpan( TraceSpan.CopilotChatResolveAgentContexts, { @@ -1225,10 +1187,10 @@ export async function handleUnifiedChatPost(req: NextRequest) { }, () => resolveAgentContexts({ - contexts: projected.contexts, + contexts: normalizedContexts, resourceAttachments: body.resourceAttachments, userId: authenticatedUserId, - message: projected.message, + message: body.message, workspaceId, chatId: actualChatId, requestId, diff --git a/apps/sim/lib/copilot/mcp-tools.test.ts b/apps/sim/lib/copilot/mcp-tools.test.ts index 19583f5e92c..defa40f1507 100644 --- a/apps/sim/lib/copilot/mcp-tools.test.ts +++ b/apps/sim/lib/copilot/mcp-tools.test.ts @@ -50,130 +50,39 @@ describe('mothership MCP tool schemas', () => { ]) }) - it('reports tagged-server discovery provenance without placing it in tool schemas', async () => { - const provenance = { - version: 1, - complete: true, - entries: [{ name: 'MCP_TOKEN', encryptedValue: 'encrypted-token' }], - scope: { userId: 'user-1', workspaceId: 'ws-1' }, - } - discoverServerTools.mockImplementationOnce( - async ( - _userId: string, - _serverId: string, - _workspaceId: string, - _forceRefresh: boolean, - report: (value: unknown) => void - ) => { - report(provenance) - return [ - { - serverId: 'mcp-server-1', - name: 'search', - description: 'Search docs', - inputSchema: { type: 'object' }, - }, - ] - } - ) - const recordProvenance = vi.fn() - - const tools = await buildTaggedMcpToolSchemas( - 'user-1', - 'ws-1', - ['mcp-server-1'], - recordProvenance - ) - - expect(discoverServerTools).toHaveBeenCalledWith( - 'user-1', - 'mcp-server-1', - 'ws-1', - false, - expect.any(Function) - ) - expect(recordProvenance).toHaveBeenCalledWith(provenance) - expect(JSON.stringify(tools)).not.toContain('encrypted-token') - expect(JSON.stringify(tools)).not.toContain('resolvedSecretTraceProvenance') - }) - - it('reports incomplete provenance when tagged-server discovery returns no report', async () => { - discoverServerTools.mockResolvedValue([]) - const recordProvenance = vi.fn() - - await buildTaggedMcpToolSchemas('user-1', 'ws-1', ['mcp-server-1'], recordProvenance) - - expect(recordProvenance).toHaveBeenCalledWith({ - version: 1, - complete: false, - entries: [], - scope: { userId: 'user-1', workspaceId: 'ws-1' }, - }) - }) - it('uses a selected block tool cached schema without discovering the server', async () => { - const recordProvenance = vi.fn() - const tools = await buildSelectedMcpToolSchemas( - 'user-1', - 'ws-1', - [ - { - type: 'mcp', - params: { serverId: 'mcp-server-1', toolName: 'search', serverName: 'Docs' }, - schema: { type: 'object', properties: { query: { type: 'string' } } }, - }, - ], - recordProvenance - ) + const tools = await buildSelectedMcpToolSchemas('user-1', 'ws-1', [ + { + type: 'mcp', + params: { serverId: 'mcp-server-1', toolName: 'search', serverName: 'Docs' }, + schema: { type: 'object', properties: { query: { type: 'string' } } }, + }, + ]) expect(discoverServerTools).not.toHaveBeenCalled() - expect(recordProvenance).not.toHaveBeenCalled() expect(tools[0]).toMatchObject({ name: 'mcp-server-1-search', input_schema: { type: 'object', properties: { query: { type: 'string' } } }, }) }) - it('reports provenance from selected tools that require server discovery', async () => { - const provenance = { - version: 1, - complete: true, - entries: [{ name: 'MCP_TOKEN', encryptedValue: 'encrypted-token' }], - scope: { userId: 'user-1', workspaceId: 'ws-1' }, - } - discoverServerTools.mockImplementationOnce( - async ( - _userId: string, - _serverId: string, - _workspaceId: string, - _forceRefresh: boolean, - report: (value: unknown) => void - ) => { - report(provenance) - return [ - { - serverId: 'mcp-server-1', - name: 'search', - inputSchema: { type: 'object' }, - }, - ] - } - ) - const recordProvenance = vi.fn() + it('discovers a selected legacy tool without a cached schema', async () => { + discoverServerTools.mockResolvedValueOnce([ + { + serverId: 'mcp-server-1', + name: 'search', + inputSchema: { type: 'object' }, + }, + ]) - const tools = await buildSelectedMcpToolSchemas( - 'user-1', - 'ws-1', - [ - { - type: 'mcp', - params: { serverId: 'mcp-server-1', toolName: 'search' }, - }, - ], - recordProvenance - ) + const tools = await buildSelectedMcpToolSchemas('user-1', 'ws-1', [ + { + type: 'mcp', + params: { serverId: 'mcp-server-1', toolName: 'search' }, + }, + ]) - expect(recordProvenance).toHaveBeenCalledWith(provenance) + expect(discoverServerTools).toHaveBeenCalledWith('user-1', 'mcp-server-1', 'ws-1') expect(tools[0]).toMatchObject({ name: 'mcp-server-1-search' }) }) }) diff --git a/apps/sim/lib/copilot/mcp-tools.ts b/apps/sim/lib/copilot/mcp-tools.ts index 7d915035ca7..24856365e08 100644 --- a/apps/sim/lib/copilot/mcp-tools.ts +++ b/apps/sim/lib/copilot/mcp-tools.ts @@ -8,8 +8,6 @@ import type { ToolInput } from '@/executor/handlers/agent/types' const logger = createLogger('CopilotMcpTools') -type ResolvedSecretTraceProvenanceCallback = (provenance: unknown) => void - function toMothershipMcpTool(tool: { serverId: string serverName?: string @@ -53,26 +51,11 @@ function dedupeMcpTools(tools: ToolSchema[]): ToolSchema[] { async function discoverServerTools( userId: string, workspaceId: string, - serverId: string, - onResolvedSecretTraceProvenance?: ResolvedSecretTraceProvenanceCallback + serverId: string ): Promise { - let provenanceReported = false try { const { mcpService } = await import('@/lib/mcp/service') - if (!onResolvedSecretTraceProvenance) { - return await mcpService.discoverServerTools(userId, serverId, workspaceId) - } - - return await mcpService.discoverServerTools( - userId, - serverId, - workspaceId, - false, - (provenance) => { - provenanceReported = true - onResolvedSecretTraceProvenance(provenance) - } - ) + return await mcpService.discoverServerTools(userId, serverId, workspaceId) } catch (error) { logger.warn('Failed to resolve tagged MCP server tools', { serverId, @@ -80,15 +63,6 @@ async function discoverServerTools( error: toError(error).message, }) return [] - } finally { - if (onResolvedSecretTraceProvenance && !provenanceReported) { - onResolvedSecretTraceProvenance({ - version: 1, - complete: false, - entries: [], - scope: { userId, workspaceId }, - }) - } } } @@ -99,17 +73,14 @@ async function discoverServerTools( export async function buildTaggedMcpToolSchemas( userId: string, workspaceId: string, - serverIds: string[], - onResolvedSecretTraceProvenance?: ResolvedSecretTraceProvenanceCallback + serverIds: string[] ): Promise { const uniqueServerIds = [...new Set(serverIds.filter(Boolean))] if (uniqueServerIds.length === 0) return [] await validateMcpToolsAllowed(userId, workspaceId) const discovered = await Promise.all( - uniqueServerIds.map((serverId) => - discoverServerTools(userId, workspaceId, serverId, onResolvedSecretTraceProvenance) - ) + uniqueServerIds.map((serverId) => discoverServerTools(userId, workspaceId, serverId)) ) return dedupeMcpTools(discovered.flat().map(toMothershipMcpTool)) } @@ -122,8 +93,7 @@ export async function buildTaggedMcpToolSchemas( export async function buildSelectedMcpToolSchemas( userId: string, workspaceId: string, - selections: ToolInput[], - onResolvedSecretTraceProvenance?: ResolvedSecretTraceProvenanceCallback + selections: ToolInput[] ): Promise { const selected = selections.filter( (tool) => @@ -160,12 +130,7 @@ export async function buildSelectedMcpToolSchemas( let discovery = discoveredByServer.get(serverId) if (!discovery) { - discovery = discoverServerTools( - userId, - workspaceId, - serverId, - onResolvedSecretTraceProvenance - ) + discovery = discoverServerTools(userId, workspaceId, serverId) discoveredByServer.set(serverId, discovery) } const match = (await discovery).find((tool) => tool.name === toolName) diff --git a/apps/sim/lib/copilot/model-visible-schema.test.ts b/apps/sim/lib/copilot/model-visible-schema.test.ts deleted file mode 100644 index 89d06d85fc1..00000000000 --- a/apps/sim/lib/copilot/model-visible-schema.test.ts +++ /dev/null @@ -1,149 +0,0 @@ -/** - * @vitest-environment node - */ -import { describe, expect, it } from 'vitest' -import { - collectModelVisibleSchemaContent, - restoreModelVisibleSchemaValues, -} from '@/lib/copilot/model-visible-schema' - -describe('model-visible schema classification', () => { - it('projects display text, preserves public grammar, and guards dynamic semantics', () => { - const schema = { - type: 'object', - properties: { - tokenField: { - type: 'string', - title: 'Visible title', - description: 'Visible description', - enum: ['semantic-value'], - default: 'semantic-default', - }, - }, - required: ['tokenField'], - } - - const content = collectModelVisibleSchemaContent(schema) - - expect(content.projectedValues).toEqual(['Visible title', 'Visible description']) - expect(content.guardedValues).toEqual( - expect.arrayContaining(['tokenField', ['semantic-value'], 'semantic-default', ['tokenField']]) - ) - expect( - restoreModelVisibleSchemaValues(schema, ['Projected title', 'Projected description']) - ).toEqual({ - type: 'object', - properties: { - tokenField: { - type: 'string', - title: 'Projected title', - description: 'Projected description', - enum: ['semantic-value'], - default: 'semantic-default', - }, - }, - required: ['tokenField'], - }) - }) - - it('preserves validated controls while guarding arbitrary or invalid semantic values', () => { - const schema = { - type: ['object', 'null'], - nullable: true, - readOnly: false, - format: 'secret-format', - $schema: 'secret-schema-uri', - contentEncoding: 'secret-encoding', - contentMediaType: 'secret-media-type', - properties: { - invalidType: { type: 'secret-type' }, - invalidBoolean: { deprecated: 'secret-deprecated' }, - }, - } - - expect(collectModelVisibleSchemaContent(schema).guardedValues).toEqual( - expect.arrayContaining([ - 'secret-format', - 'secret-schema-uri', - 'secret-encoding', - 'secret-media-type', - 'invalidType', - 'secret-type', - 'invalidBoolean', - 'secret-deprecated', - ]) - ) - }) - - it.each([ - ['string', { type: 'string' }], - ['true', { nullable: true }], - ])('preserves the validated public control %s outside secret matching', (_secret, schema) => { - expect(collectModelVisibleSchemaContent(schema).guardedValues).toEqual([]) - }) - - it.each([ - { items: 'not-a-schema' }, - { allOf: ['not-a-schema'] }, - { properties: { field: 'not-a-schema' } }, - ])('rejects malformed child schemas instead of silently preserving them', (schema) => { - expect(() => collectModelVisibleSchemaContent(schema)).toThrow( - 'Model-visible schema content could not be safely projected' - ) - expect(() => restoreModelVisibleSchemaValues(schema, [])).toThrow( - 'Model-visible schema content could not be safely projected' - ) - }) - - it('accepts boolean schemas at every schema-child position', () => { - const schema = { - additionalProperties: false, - allOf: [true], - properties: { field: false }, - } - - expect(collectModelVisibleSchemaContent(schema)).toEqual({ - projectedValues: [], - guardedValues: ['field'], - }) - expect(restoreModelVisibleSchemaValues(schema, [])).toEqual(schema) - }) - - it('guards arbitrary keys at the root and within child schemas', () => { - const schema = { - 'root-semantic-key': true, - properties: { - field: { - 'child-semantic-key': 'value', - }, - }, - } - - expect(collectModelVisibleSchemaContent(schema).guardedValues).toEqual( - expect.arrayContaining(['root-semantic-key', 'child-semantic-key']) - ) - }) - - it('restores safe canonical controls byte-for-byte', () => { - const schema = { - type: ['object', 'null'], - nullable: true, - readOnly: false, - properties: { value: { type: 'string' } }, - } - - expect(restoreModelVisibleSchemaValues(schema, [])).toEqual(schema) - }) - - it('rejects oversized schema collections before duplicating them', () => { - const oversized = new Array(100_001) - const schema = { allOf: oversized } - - expect(() => collectModelVisibleSchemaContent(schema)).toThrow( - 'Model-visible schema content could not be safely projected' - ) - expect(() => restoreModelVisibleSchemaValues(schema, [])).toThrow( - 'Model-visible schema content could not be safely projected' - ) - }) -}) diff --git a/apps/sim/lib/copilot/model-visible-schema.ts b/apps/sim/lib/copilot/model-visible-schema.ts deleted file mode 100644 index 17c4d1c05f4..00000000000 --- a/apps/sim/lib/copilot/model-visible-schema.ts +++ /dev/null @@ -1,361 +0,0 @@ -import { isPlainRecord } from '@sim/utils/object' - -const MAX_SCHEMA_NODES = 100_000 -const MAX_SCHEMA_DEPTH = 100 -const SCHEMA_DISPLAY_KEYS = new Set(['description', 'title', '$comment', 'example', 'examples']) -const SCHEMA_SINGLE_CHILD_KEYS = new Set([ - 'additionalProperties', - 'contains', - 'contentSchema', - 'else', - 'if', - 'items', - 'not', - 'propertyNames', - 'then', - 'unevaluatedItems', - 'unevaluatedProperties', -]) -const SCHEMA_ARRAY_CHILD_KEYS = new Set(['allOf', 'anyOf', 'oneOf', 'prefixItems']) -const SCHEMA_MAP_CHILD_KEYS = new Set([ - '$defs', - 'definitions', - 'dependentSchemas', - 'patternProperties', - 'properties', -]) -const SCHEMA_TYPE_NAMES = new Set([ - 'array', - 'boolean', - 'integer', - 'null', - 'number', - 'object', - 'string', -]) -const SCHEMA_BOOLEAN_CONTROL_KEYS = new Set([ - 'deprecated', - 'nullable', - 'readOnly', - 'uniqueItems', - 'writeOnly', -]) -const SCHEMA_NUMBER_CONTROL_KEYS = new Set([ - 'exclusiveMaximum', - 'exclusiveMinimum', - 'maximum', - 'minimum', - 'multipleOf', -]) -const SCHEMA_NONNEGATIVE_INTEGER_CONTROL_KEYS = new Set([ - 'maxContains', - 'maxItems', - 'maxLength', - 'maxProperties', - 'minContains', - 'minItems', - 'minLength', - 'minProperties', -]) -const SCHEMA_KNOWN_KEYS = new Set([ - '$anchor', - '$comment', - '$defs', - '$dynamicAnchor', - '$dynamicRef', - '$id', - '$ref', - '$schema', - '$vocabulary', - 'additionalProperties', - 'allOf', - 'anyOf', - 'const', - 'contains', - 'contentEncoding', - 'contentMediaType', - 'contentSchema', - 'default', - 'definitions', - 'deprecated', - 'dependentRequired', - 'dependentSchemas', - 'description', - 'else', - 'enum', - 'example', - 'examples', - 'exclusiveMaximum', - 'exclusiveMinimum', - 'format', - 'if', - 'items', - 'maxContains', - 'maxItems', - 'maxLength', - 'maxProperties', - 'maximum', - 'minContains', - 'minItems', - 'minLength', - 'minProperties', - 'minimum', - 'multipleOf', - 'not', - 'nullable', - 'oneOf', - 'pattern', - 'patternProperties', - 'prefixItems', - 'properties', - 'propertyNames', - 'readOnly', - 'required', - 'then', - 'title', - 'type', - 'unevaluatedItems', - 'unevaluatedProperties', - 'uniqueItems', - 'writeOnly', -]) - -export type ModelVisibleSchemaAction = - | 'preserve' - | 'project' - | 'traverse' - | 'verify' - | 'traverse-verify-key' - | 'verify-key-value' - -export class ModelVisibleSchemaError extends Error { - constructor() { - super('Model-visible schema content could not be safely projected') - this.name = 'ModelVisibleSchemaError' - } -} - -export function getModelVisibleSchemaAction( - parentKey: string | undefined, - key: string, - value?: unknown -): ModelVisibleSchemaAction { - if (parentKey !== undefined && SCHEMA_MAP_CHILD_KEYS.has(parentKey)) { - return isSchemaNode(value) ? 'traverse-verify-key' : 'verify-key-value' - } - if (SCHEMA_DISPLAY_KEYS.has(key)) return 'project' - if (SCHEMA_SINGLE_CHILD_KEYS.has(key)) return isSchemaNode(value) ? 'traverse' : 'verify' - if (SCHEMA_ARRAY_CHILD_KEYS.has(key)) return Array.isArray(value) ? 'traverse' : 'verify' - if (SCHEMA_MAP_CHILD_KEYS.has(key)) return isPlainRecord(value) ? 'traverse' : 'verify' - if (isPublicSchemaControl(key, value)) return 'preserve' - return 'verify' -} - -interface SchemaTraversalState { - nodes: number - ancestors: WeakSet -} - -function visitSchemaNode(state: SchemaTraversalState, depth: number): void { - state.nodes += 1 - if (state.nodes > MAX_SCHEMA_NODES || depth > MAX_SCHEMA_DEPTH) { - throw new ModelVisibleSchemaError() - } -} - -function isSchemaNode(value: unknown): value is boolean | Record { - return typeof value === 'boolean' || isPlainRecord(value) -} - -function isPublicSchemaControl(key: string, value: unknown): boolean { - if (key === 'type') { - if (typeof value === 'string') return SCHEMA_TYPE_NAMES.has(value) - return ( - Array.isArray(value) && - value.length > 0 && - value.every((item) => typeof item === 'string' && SCHEMA_TYPE_NAMES.has(item)) - ) - } - if (SCHEMA_BOOLEAN_CONTROL_KEYS.has(key)) return typeof value === 'boolean' - if (SCHEMA_NUMBER_CONTROL_KEYS.has(key)) { - return typeof value === 'number' && Number.isFinite(value) - } - if (SCHEMA_NONNEGATIVE_INTEGER_CONTROL_KEYS.has(key)) { - return typeof value === 'number' && Number.isInteger(value) && value >= 0 - } - return false -} - -function schemaRecordEntries(value: Record): Array<[string, unknown]> { - const keys = Reflect.ownKeys(value) - if (keys.length > MAX_SCHEMA_NODES) throw new ModelVisibleSchemaError() - const entries: Array<[string, unknown]> = [] - for (const key of keys) { - if (typeof key !== 'string') throw new ModelVisibleSchemaError() - const descriptor = Object.getOwnPropertyDescriptor(value, key) - if (!descriptor?.enumerable || !('value' in descriptor)) { - throw new ModelVisibleSchemaError() - } - entries.push([key, descriptor.value]) - } - return entries -} - -function schemaArrayValues(value: unknown[]): unknown[] { - if (Object.getPrototypeOf(value) !== Array.prototype) throw new ModelVisibleSchemaError() - if (value.length > MAX_SCHEMA_NODES) throw new ModelVisibleSchemaError() - const values = new Array(value.length) - let entries = 0 - for (const key of Reflect.ownKeys(value)) { - if (key === 'length') continue - if (typeof key !== 'string') throw new ModelVisibleSchemaError() - const index = Number(key) - if (!Number.isInteger(index) || index < 0 || index >= value.length || String(index) !== key) { - throw new ModelVisibleSchemaError() - } - const descriptor = Object.getOwnPropertyDescriptor(value, key) - if (!descriptor?.enumerable || !('value' in descriptor)) { - throw new ModelVisibleSchemaError() - } - values[index] = descriptor.value - entries += 1 - } - if (entries !== value.length) throw new ModelVisibleSchemaError() - return values -} - -function schemaChildren(key: string, value: unknown): unknown[] { - if (SCHEMA_SINGLE_CHILD_KEYS.has(key)) return [value] - if (SCHEMA_ARRAY_CHILD_KEYS.has(key)) { - if (!Array.isArray(value)) throw new ModelVisibleSchemaError() - return schemaArrayValues(value) - } - if (SCHEMA_MAP_CHILD_KEYS.has(key)) { - if (!isPlainRecord(value)) throw new ModelVisibleSchemaError() - return schemaRecordEntries(value).map(([, child]) => child) - } - return [] -} - -export interface ModelVisibleSchemaContent { - projectedValues: unknown[] - guardedValues: unknown[] -} - -/** Splits schema display text from semantic fields whose exact bytes must remain unchanged. */ -export function collectModelVisibleSchemaContent(schema: unknown): ModelVisibleSchemaContent { - const projectedValues: unknown[] = [] - const guardedValues: unknown[] = [] - const state: SchemaTraversalState = { nodes: 0, ancestors: new WeakSet() } - - const visit = (candidate: unknown, depth: number): void => { - visitSchemaNode(state, depth) - if (typeof candidate === 'boolean') return - if (!isPlainRecord(candidate)) throw new ModelVisibleSchemaError() - if (state.ancestors.has(candidate)) throw new ModelVisibleSchemaError() - - state.ancestors.add(candidate) - try { - for (const [key, value] of schemaRecordEntries(candidate)) { - const action = getModelVisibleSchemaAction(undefined, key, value) - if (action === 'project') { - projectedValues.push(value) - continue - } - if (action === 'preserve') continue - if (action === 'verify') { - if ( - SCHEMA_SINGLE_CHILD_KEYS.has(key) || - SCHEMA_ARRAY_CHILD_KEYS.has(key) || - SCHEMA_MAP_CHILD_KEYS.has(key) - ) { - throw new ModelVisibleSchemaError() - } - if (!SCHEMA_KNOWN_KEYS.has(key)) guardedValues.push(key) - guardedValues.push(value) - continue - } - if (action !== 'traverse') continue - if (SCHEMA_MAP_CHILD_KEYS.has(key)) { - if (!isPlainRecord(value)) throw new ModelVisibleSchemaError() - for (const [childKey, child] of schemaRecordEntries(value)) { - guardedValues.push(childKey) - visit(child, depth + 1) - } - continue - } - for (const child of schemaChildren(key, value)) visit(child, depth + 1) - } - } finally { - state.ancestors.delete(candidate) - } - } - - visit(schema, 0) - return { projectedValues, guardedValues } -} - -export function collectModelVisibleSchemaValues(schema: unknown): unknown[] { - return collectModelVisibleSchemaContent(schema).projectedValues -} - -export function restoreModelVisibleSchemaValues(schema: unknown, projected: unknown): unknown { - if (!Array.isArray(projected)) throw new ModelVisibleSchemaError() - const state: SchemaTraversalState = { nodes: 0, ancestors: new WeakSet() } - let cursor = 0 - - const visit = (candidate: unknown, depth: number): unknown => { - visitSchemaNode(state, depth) - if (typeof candidate === 'boolean') return candidate - if (!isPlainRecord(candidate)) throw new ModelVisibleSchemaError() - if (state.ancestors.has(candidate)) throw new ModelVisibleSchemaError() - - state.ancestors.add(candidate) - try { - let restored = candidate - for (const [key, value] of schemaRecordEntries(candidate)) { - const action = getModelVisibleSchemaAction(undefined, key, value) - let nextValue = value - if (action === 'project') { - if (cursor >= projected.length) throw new ModelVisibleSchemaError() - nextValue = projected[cursor] - cursor += 1 - } else if ( - action === 'verify' && - (SCHEMA_SINGLE_CHILD_KEYS.has(key) || - SCHEMA_ARRAY_CHILD_KEYS.has(key) || - SCHEMA_MAP_CHILD_KEYS.has(key)) - ) { - throw new ModelVisibleSchemaError() - } else if (action === 'traverse' || action === 'traverse-verify-key') { - if (SCHEMA_SINGLE_CHILD_KEYS.has(key)) { - nextValue = visit(value, depth + 1) - } else if (SCHEMA_ARRAY_CHILD_KEYS.has(key)) { - if (!Array.isArray(value)) throw new ModelVisibleSchemaError() - nextValue = schemaArrayValues(value).map((child) => visit(child, depth + 1)) - } else if (SCHEMA_MAP_CHILD_KEYS.has(key)) { - if (!isPlainRecord(value)) throw new ModelVisibleSchemaError() - nextValue = Object.fromEntries( - schemaRecordEntries(value).map(([childKey, child]) => [ - childKey, - visit(child, depth + 1), - ]) - ) - } - } - - if (nextValue !== value) { - if (restored === candidate) restored = { ...candidate } - restored[key] = nextValue - } - } - return restored - } finally { - state.ancestors.delete(candidate) - } - } - - const restored = visit(schema, 0) - if (cursor !== projected.length) throw new ModelVisibleSchemaError() - return restored -} diff --git a/apps/sim/lib/copilot/request/handlers/handlers.test.ts b/apps/sim/lib/copilot/request/handlers/handlers.test.ts index b98cbb79438..65843584f16 100644 --- a/apps/sim/lib/copilot/request/handlers/handlers.test.ts +++ b/apps/sim/lib/copilot/request/handlers/handlers.test.ts @@ -473,7 +473,7 @@ describe('sse-handlers tool lifecycle', () => { expect(updated?.result?.output).toBe('done') }) - it('projects resolved Function secrets before every Copilot-visible result sink', async () => { + it('projects resolved Function output while leaving resource metadata unchanged', async () => { const registry = new ResolvedSecretTraceRegistry([ { name: 'SECRET', @@ -485,7 +485,9 @@ describe('sse-handlers tool lifecycle', () => { execContext.resolvedSecretTraceRegistry = registry execContext.chatId = 'chat-1' executeTool.mockImplementationOnce(async (_name, _params, toolContext) => { - toolContext.resolvedSecretTraceRegistry?.recordResolved('SECRET', 'secret-value') + toolContext.resolvedSecretTraceRegistry?.recordResolved('SECRET', 'secret-value', { + propagated: true, + }) return { success: true, output: { @@ -542,12 +544,11 @@ describe('sse-handlers tool lifecycle', () => { resource: { type: 'file', id: 'file-1', - title: '{{SECRET}}.txt', + title: 'secret-value.txt', }, }, }) expect(JSON.stringify(completeAsyncToolCall.mock.calls)).not.toContain('secret-value') - expect(JSON.stringify(onEvent.mock.calls)).not.toContain('secret-value') }) it('emits a structural result for a detached background workflow tool', async () => { @@ -1661,6 +1662,58 @@ describe('sse-handlers tool lifecycle', () => { ) }) + it('forwards workspace secret references unchanged to the resolved integration operation', async () => { + isSimExecuted.mockReturnValue(false) + executeTool.mockResolvedValueOnce({ success: true, output: { searchResults: [] } }) + + await sseHandlers.tool( + { + type: MothershipStreamV1EventType.tool, + payload: { + toolCallId: 'gateway-serper', + toolName: 'call_integration_tool', + executor: MothershipStreamV1ToolExecutor.go, + mode: MothershipStreamV1ToolMode.sync, + phase: MothershipStreamV1ToolPhase.call, + status: 'generating', + partial: true, + }, + } satisfies StreamEvent, + context, + execContext, + { interactive: false, timeout: 1000 } + ) + + await sseHandlers.tool( + { + type: MothershipStreamV1EventType.tool, + payload: { + toolCallId: 'gateway-serper', + toolName: 'serper_search', + arguments: { + query: 'invoice', + apiKey: '{{SERPER_API_KEY}}', + }, + executor: MothershipStreamV1ToolExecutor.sim, + mode: MothershipStreamV1ToolMode.async, + phase: MothershipStreamV1ToolPhase.call, + }, + } satisfies StreamEvent, + context, + execContext, + { interactive: false, timeout: 1000 } + ) + + await sleep(0) + + expect(executeTool).toHaveBeenCalledOnce() + expect(executeTool).toHaveBeenCalledWith( + 'serper_search', + { query: 'invoice', apiKey: '{{SERPER_API_KEY}}' }, + expect.any(Object) + ) + }) + it('clears pending continuation state when a run resumes', async () => { context.awaitingAsyncContinuation = { checkpointId: 'cp-1', diff --git a/apps/sim/lib/copilot/request/lifecycle/run.test.ts b/apps/sim/lib/copilot/request/lifecycle/run.test.ts index 15c328f8904..f0469c0e038 100644 --- a/apps/sim/lib/copilot/request/lifecycle/run.test.ts +++ b/apps/sim/lib/copilot/request/lifecycle/run.test.ts @@ -142,7 +142,7 @@ import { runCopilotLifecycle } from '@/lib/copilot/request/lifecycle/run' afterAll(resetEnvFlagsMock) -const ARBITRARY_SCHEMA_CONTROL_KEYS = [ +const SCHEMA_CONTROL_KEYS = [ '$schema', 'format', 'contentEncoding', @@ -279,76 +279,56 @@ describe('runCopilotLifecycle', () => { }) }) - it('projects secrets in every model-visible initial Go payload field without rewriting foreign aliases', async () => { + it('preserves ordinary initial Go payload fields that collide with a configured secret', async () => { const secret = 'mothership-secret' const registry = new ResolvedSecretTraceRegistry([ { name: 'TOKEN', plaintext: secret, encryptedValue: 'ciphertext' }, ]) - registry.recordResolved('TOKEN', secret) - let capturedRequestBody = '' - mockRunStreamLoop.mockImplementationOnce(async (_url: string, request: RequestInit) => { - capturedRequestBody = String(request.body) - }) - - await runCopilotLifecycle( - { - message: `message ${secret} __var_FOREIGN`, - messages: [{ role: 'user', content: secret }], - context: [{ type: 'resource', content: secret }], - contexts: [{ type: 'mcp', content: secret }], - workspaceContext: `workspace ${secret}`, - integrationTools: [{ name: 'tool', description: secret }], - mothershipTools: [{ name: 'mcp', description: '__sim_code_2_binding_0' }], - fileAttachments: [ - { - name: `${secret}.txt`, - key: 'raw-storage-key', - source: { type: 'base64', data: 'c2FmZQ==' }, - }, - ], - workspaceId: 'ws-1', - messageId: 'stream-model-projection', - }, - { - userId: 'user-1', - workspaceId: 'ws-1', - executionContext: { - userId: 'user-1', - workflowId: '', - workspaceId: 'ws-1', - }, - resolvedSecretTraceRegistry: registry, - } - ) - - expect(capturedRequestBody).not.toContain(secret) - expect(capturedRequestBody).toContain('__var_FOREIGN') - expect(capturedRequestBody).toContain('__sim_code_2_binding_0') - expect(JSON.parse(capturedRequestBody)).toMatchObject({ - message: 'message {{TOKEN}} __var_FOREIGN', - messages: [{ role: 'user', content: '{{TOKEN}}' }], - workspaceContext: 'workspace {{TOKEN}}', + const payload = { + message: `message ${secret} __var_FOREIGN`, + messages: [{ role: 'user', content: secret }], + context: [{ type: 'resource', content: secret }], + contexts: [{ type: 'mcp', content: secret }], + workspaceContext: `workspace ${secret}`, + integrationTools: [{ name: 'tool', description: secret }], mothershipTools: [{ name: 'mcp', description: '__sim_code_2_binding_0' }], - workspaceId: 'ws-1', fileAttachments: [ { - name: '{{TOKEN}}.txt', + name: `${secret}.txt`, key: 'raw-storage-key', source: { type: 'base64', data: 'c2FmZQ==' }, }, ], + workspaceId: 'ws-1', + messageId: 'stream-model-projection', + } + let capturedRequestBody = '' + mockRunStreamLoop.mockImplementationOnce(async (_url: string, request: RequestInit) => { + capturedRequestBody = String(request.body) }) + + await runCopilotLifecycle(payload, { + userId: 'user-1', + workspaceId: 'ws-1', + executionContext: { + userId: 'user-1', + workflowId: '', + workspaceId: 'ws-1', + }, + resolvedSecretTraceRegistry: registry, + }) + + const { enterpriseByokEligible, ...sent } = JSON.parse(capturedRequestBody) + expect(enterpriseByokEligible).toBe(false) + expect(sent).toEqual(payload) }) - it('projects large tool catalogs at the tool-definition boundary', async () => { + it('preserves large ordinary tool catalogs without scanning configured secret values', async () => { const registry = new ResolvedSecretTraceRegistry([ { name: 'TOKEN', plaintext: 'catalog-secret', encryptedValue: 'ciphertext' }, ]) - registry.recordResolved('TOKEN', 'catalog-secret') const toolCount = 4_000 const propertiesPerTool = 8 - // These definitions are individually small, but flattening their semantic fields into one - // synthetic projection value creates 108,001 traversal nodes and crosses the per-value budget. const integrationTools = Array.from({ length: toolCount }, (_, toolIndex) => { const properties = Object.fromEntries( Array.from({ length: propertiesPerTool }, (_, propertyIndex) => [ @@ -392,17 +372,13 @@ describe('runCopilotLifecycle', () => { expect(result.success).toBe(true) expect(mockRunStreamLoop).toHaveBeenCalledOnce() const sent = JSON.parse(capturedRequestBody) - expect(sent.integrationTools).toHaveLength(integrationTools.length) - expect(sent.integrationTools[0].description).toBe('Tool 0 uses {{TOKEN}}') - expect(sent.integrationTools.at(-1).name).toBe(`tool_${toolCount - 1}`) - expect(capturedRequestBody).not.toContain('catalog-secret') + expect(sent.integrationTools).toEqual(integrationTools) }) - it('projects selected JSON and attachment fields exactly once when plaintext overlaps its alias', async () => { + it('preserves ordinary JSON and attachment fields when plaintext overlaps its alias', async () => { const registry = new ResolvedSecretTraceRegistry([ { name: 'TOKEN', plaintext: 'TOKEN', encryptedValue: 'ciphertext' }, ]) - registry.recordResolved('TOKEN', 'TOKEN') let capturedRequestBody = '' mockRunStreamLoop.mockImplementationOnce(async (_url: string, request: RequestInit) => { capturedRequestBody = String(request.body) @@ -449,25 +425,23 @@ describe('runCopilotLifecycle', () => { ) const sent = JSON.parse(capturedRequestBody) - expect(sent.message).toBe('{{TOKEN}}') + expect(sent.message).toBe('TOKEN') expect(sent.messages[0]).toMatchObject({ - content: '{{TOKEN}}', - function_call: { arguments: JSON.stringify({ value: '{{TOKEN}}' }) }, + content: 'TOKEN', + function_call: { arguments: JSON.stringify({ value: 'TOKEN' }) }, tool_calls: [ { - function: { arguments: JSON.stringify({ value: '{{TOKEN}}' }) }, + function: { arguments: JSON.stringify({ value: 'TOKEN' }) }, }, ], files: [ { - name: '{{TOKEN}}.txt', - context: 'Context {{TOKEN}}', + name: 'TOKEN.txt', + context: 'Context TOKEN', }, ], }) - expect(sent.fileAttachments).toEqual([{ name: '{{TOKEN}}.txt', key: 'safe-key' }]) - expect(capturedRequestBody.replaceAll('{{TOKEN}}', '')).not.toContain('TOKEN') - expect(capturedRequestBody).not.toContain('{{{{TOKEN}}}}') + expect(sent.fileAttachments).toEqual([{ name: 'TOKEN.txt', key: 'safe-key' }]) }) it('omits only unsafe durable attachments before the initial Go request', async () => { @@ -521,375 +495,242 @@ describe('runCopilotLifecycle', () => { }) it.each(['123', 'true'])( - 'keeps low-entropy Copilot JSON valid while separating content from controls (%s)', + 'preserves low-entropy configured-secret collisions across Copilot JSON (%s)', async (secret) => { const registry = new ResolvedSecretTraceRegistry([ { name: 'TOKEN', plaintext: secret, encryptedValue: 'ciphertext' }, ]) - registry.recordResolved('TOKEN', secret) const converted = secret === '123' ? 123 : true let capturedRequestBody = '' mockRunStreamLoop.mockImplementationOnce(async (_url: string, request: RequestInit) => { capturedRequestBody = String(request.body) }) - await runCopilotLifecycle( - { - message: `Message ${secret}`, - messages: [ - { - id: secret, - role: secret, - name: 'assistant-safe', - content: `Transcript ${secret}`, - function_call: { - name: 'legacy-safe', - arguments: JSON.stringify({ value: secret, converted }), - }, - tool_calls: [ - { - id: secret, - type: secret, - function: { - name: 'tool-safe', - arguments: JSON.stringify({ value: secret, converted }), - }, - }, - ], - fileAttachments: [ - { - id: secret, - key: secret, - filename: `${secret}.txt`, - media_type: secret, - }, - ], - contexts: [{ kind: secret, label: `Label ${secret}`, serverId: secret }], - contentBlocks: [ - { - type: secret, - content: `Block ${secret}`, - toolCall: { - id: secret, - name: 'nested-tool-safe', - state: secret, - params: { value: secret }, - result: { success: true, output: { value: secret, converted } }, - display: { title: `Title ${secret}` }, - }, - }, - ], - }, - ], - context: [ - { type: secret, tag: secret, path: secret, content: `Unsafe ${secret}` }, - { - type: secret, - tag: secret, - path: 'files/safe.txt', - content: `Context ${secret}`, - }, - ], - contexts: [ - { - kind: secret, - serverId: secret, - label: `Context label ${secret}`, - }, - ], - integrationTools: [ - { - name: 'safe_tool', - description: `Description ${secret}`, - input_schema: { - type: 'object', - properties: { - value: { - type: 'string', - title: `Title ${secret}`, - description: `Field ${secret}`, - enum: ['public'], - }, - }, - required: ['value'], - }, - params: { runtimeControl: secret }, - service: secret, - operation: secret, - oauth: { required: true, provider: secret }, - }, - { - name: 'unsafe_schema_tool', - description: 'Unsafe schema', - input_schema: { - type: 'object', - properties: { [secret]: { type: 'string' } }, - required: [secret], - }, - }, - { - name: secret, - description: 'Unsafe name', - input_schema: { type: 'object', properties: {}, required: [] }, - }, - ], - responseFormat: { - name: 'safe_response', - schema: { - type: 'object', - properties: { - value: { - type: 'string', - description: `Result ${secret}`, - enum: ['public'], - }, - }, - required: ['value'], - }, - }, - fileAttachments: [ - { - id: secret, - name: `${secret}.txt`, - key: secret, - mimeType: secret, + const payload = { + message: `Message ${secret}`, + messages: [ + { + id: secret, + role: secret, + name: 'assistant-safe', + content: `Transcript ${secret}`, + function_call: { + name: 'legacy-safe', + arguments: JSON.stringify({ value: secret, converted }), }, - ], - vfs: { - workspace: { id: secret, ownerId: secret, name: `Workspace ${secret}` }, - files: [ + tool_calls: [ { id: secret, - path: secret, - folderPath: secret, type: secret, - name: `File ${secret}`, - }, - { - id: 'safe-file-id', - path: 'files/safe.txt', - folderPath: 'files', - type: 'text/plain', - name: `Safe ${secret}`, - }, - ], - mcpServers: [ - { id: secret, name: `Unsafe ${secret}`, url: `https://${secret}.example` }, - { - id: 'safe-mcp-id', - name: `Safe MCP ${secret}`, - url: 'https://mcp.example', + function: { + name: 'tool-safe', + arguments: JSON.stringify({ value: secret, converted }), + }, }, ], - }, - userTimezone: secret, - userMetadata: { - name: `User ${secret}`, - email: `owner+${secret}@example.com`, - timezone: secret, - }, - desktopCapabilities: { - terminal: true, - terminals: [ + fileAttachments: [ { id: secret, - cwd: `/workspace/${secret}`, - running: `command ${secret}`, - active: true, - }, - { - id: 'safe-terminal-id', - cwd: '/workspace/safe', - running: `safe command ${secret}`, - active: true, + key: secret, + filename: `${secret}.txt`, + media_type: secret, }, ], - browser: true, - browserSessions: [ - { hostname: secret, evidence: 'cookies', lastObservedAt: '2026-01-01T00:00:00.000Z' }, + contexts: [{ kind: secret, label: `Label ${secret}`, serverId: secret }], + contentBlocks: [ { - hostname: 'safe.example', - evidence: 'sign-in-completed', - lastObservedAt: '2026-02-01T00:00:00.000Z', + type: secret, + content: `Block ${secret}`, + toolCall: { + id: secret, + name: 'nested-tool-safe', + state: secret, + params: { value: secret }, + result: { success: true, output: { value: secret, converted } }, + display: { title: `Title ${secret}` }, + }, }, ], }, - workspaceId: 'ws-1', - messageId: `stream-low-entropy-${secret}`, - }, - { - userId: 'user-1', - workspaceId: 'ws-1', - executionContext: { - userId: 'user-1', - workflowId: '', - workspaceId: 'ws-1', - }, - resolvedSecretTraceRegistry: registry, - } - ) - - const sent = JSON.parse(capturedRequestBody) - expect(sent.messages[0]).toMatchObject({ - id: secret, - role: secret, - name: 'assistant-safe', - content: 'Transcript {{TOKEN}}', - function_call: { - name: 'legacy-safe', - }, - tool_calls: [ + ], + context: [ + { type: secret, tag: secret, path: secret, content: `Unsafe ${secret}` }, { - id: secret, type: secret, - function: { - name: 'tool-safe', - }, + tag: secret, + path: 'files/safe.txt', + content: `Context ${secret}`, }, ], - fileAttachments: [ + contexts: [ { - id: secret, - key: secret, - filename: '{{TOKEN}}.txt', - media_type: secret, + kind: secret, + serverId: secret, + label: `Context label ${secret}`, }, ], - contexts: [{ kind: secret, label: 'Label {{TOKEN}}', serverId: secret }], - contentBlocks: [ + integrationTools: [ { - type: secret, - content: 'Block {{TOKEN}}', - toolCall: { - id: secret, - name: 'nested-tool-safe', - state: secret, - params: { value: '{{TOKEN}}' }, - result: { - success: true, - output: { value: '{{TOKEN}}', converted: '{{TOKEN}}' }, + name: 'safe_tool', + description: `Description ${secret}`, + input_schema: { + type: 'object', + properties: { + value: { + type: 'string', + title: `Title ${secret}`, + description: `Field ${secret}`, + enum: ['public'], + }, }, - display: { title: 'Title {{TOKEN}}' }, - }, - }, - ], - }) - expect(JSON.parse(sent.messages[0].function_call.arguments)).toEqual({ - value: '{{TOKEN}}', - converted: '{{TOKEN}}', - }) - expect(JSON.parse(sent.messages[0].tool_calls[0].function.arguments)).toEqual({ - value: '{{TOKEN}}', - converted: '{{TOKEN}}', - }) - expect(sent.context).toEqual([ - { - type: secret, - tag: '{{TOKEN}}', - path: 'files/safe.txt', - content: 'Context {{TOKEN}}', - }, - ]) - expect(sent.contexts).toEqual([ - { - kind: secret, - serverId: secret, - label: 'Context label {{TOKEN}}', - }, - ]) - expect(sent.integrationTools).toHaveLength(1) - expect(sent.integrationTools[0]).toMatchObject({ - name: 'safe_tool', - description: 'Description {{TOKEN}}', - input_schema: { - properties: { - value: { - title: 'Title {{TOKEN}}', - description: 'Field {{TOKEN}}', - enum: ['public'], + required: ['value'], }, + params: { runtimeControl: secret }, + service: secret, + operation: secret, + oauth: { required: true, provider: secret }, }, - required: ['value'], - }, - params: { runtimeControl: secret }, - service: secret, - operation: secret, - oauth: { required: true, provider: secret }, - }) - expect(sent.responseFormat).toMatchObject({ - name: 'safe_response', - schema: { - properties: { - value: { description: 'Result {{TOKEN}}', enum: ['public'] }, - }, - required: ['value'], - }, - }) - expect(sent.fileAttachments[0]).toEqual({ - id: secret, - name: '{{TOKEN}}.txt', - key: secret, - mimeType: secret, - }) - expect(sent.vfs).toEqual({ - workspace: { id: secret, ownerId: secret, name: 'Workspace {{TOKEN}}' }, - files: [ { - id: 'safe-file-id', - path: 'files/safe.txt', - folderPath: 'files', - type: 'text/plain', - name: 'Safe {{TOKEN}}', + name: 'unsafe_schema_tool', + description: 'Unsafe schema', + input_schema: { + type: 'object', + properties: { [secret]: { type: 'string' } }, + required: [secret], + }, }, - ], - mcpServers: [ { - id: 'safe-mcp-id', - name: 'Safe MCP {{TOKEN}}', - url: 'https://mcp.example', + name: secret, + description: 'Unsafe name', + input_schema: { type: 'object', properties: {}, required: [] }, }, ], - }) - expect(sent).not.toHaveProperty('userTimezone') - expect(sent.userMetadata).toEqual({ - name: 'User {{TOKEN}}', - email: 'owner+{{TOKEN}}@example.com', - }) - expect(sent.desktopCapabilities).toEqual({ - terminal: true, - terminals: [ - { - id: 'safe-terminal-id', - cwd: '/workspace/safe', - running: 'safe command {{TOKEN}}', - active: true, + responseFormat: { + name: 'safe_response', + schema: { + type: 'object', + properties: { + value: { + type: 'string', + description: `Result ${secret}`, + enum: ['public'], + }, + }, + required: ['value'], }, - ], - browser: true, - browserSessions: [ + }, + fileAttachments: [ { - hostname: 'safe.example', - evidence: 'sign-in-completed', - lastObservedAt: '2026-02-01T00:00:00.000Z', + id: secret, + name: `${secret}.txt`, + key: secret, + mimeType: secret, }, ], + vfs: { + workspace: { id: secret, ownerId: secret, name: `Workspace ${secret}` }, + files: [ + { + id: secret, + path: secret, + folderPath: secret, + type: secret, + name: `File ${secret}`, + }, + { + id: 'safe-file-id', + path: 'files/safe.txt', + folderPath: 'files', + type: 'text/plain', + name: `Safe ${secret}`, + }, + ], + mcpServers: [ + { id: secret, name: `Unsafe ${secret}`, url: `https://${secret}.example` }, + { + id: 'safe-mcp-id', + name: `Safe MCP ${secret}`, + url: 'https://mcp.example', + }, + ], + }, + userTimezone: secret, + userMetadata: { + name: `User ${secret}`, + email: `owner+${secret}@example.com`, + timezone: secret, + }, + desktopCapabilities: { + terminal: true, + terminals: [ + { + id: secret, + cwd: `/workspace/${secret}`, + running: `command ${secret}`, + active: true, + }, + { + id: 'safe-terminal-id', + cwd: '/workspace/safe', + running: `safe command ${secret}`, + active: true, + }, + ], + browser: true, + browserSessions: [ + { hostname: secret, evidence: 'cookies', lastObservedAt: '2026-01-01T00:00:00.000Z' }, + { + hostname: 'safe.example', + evidence: 'sign-in-completed', + lastObservedAt: '2026-02-01T00:00:00.000Z', + }, + ], + }, + workspaceId: 'ws-1', + messageId: `stream-low-entropy-${secret}`, + } + + await runCopilotLifecycle(payload, { + userId: 'user-1', + workspaceId: 'ws-1', + executionContext: { + userId: 'user-1', + workflowId: '', + workspaceId: 'ws-1', + }, + resolvedSecretTraceRegistry: registry, }) + + const { enterpriseByokEligible, ...sent } = JSON.parse(capturedRequestBody) + expect(enterpriseByokEligible).toBe(false) + expect(sent).toEqual(payload) } ) - it.each(ARBITRARY_SCHEMA_CONTROL_KEYS)( - 'guards arbitrary %s schema controls before initial Copilot model egress', + it.each(SCHEMA_CONTROL_KEYS)( + 'preserves ordinary %s schema controls without scanning configured secret values', async (controlKey) => { const secret = `copilot-schema-control-secret-${controlKey}` const registry = new ResolvedSecretTraceRegistry([ { name: 'TOKEN', plaintext: secret, encryptedValue: 'ciphertext' }, ]) - registry.recordResolved('TOKEN', secret) - const unsafeSchema = { + const schema = { type: 'object', properties: {}, [controlKey]: secret, } + const integrationTools = [ + { + name: 'schema_tool', + description: 'Schema with an ordinary configured-secret collision', + input_schema: schema, + }, + { + name: 'safe_tool', + description: 'Safe schema', + input_schema: { type: 'object', properties: {} }, + }, + ] let capturedRequestBody = '' mockRunStreamLoop.mockImplementationOnce(async (_url: string, request: RequestInit) => { capturedRequestBody = String(request.body) @@ -899,18 +740,7 @@ describe('runCopilotLifecycle', () => { { message: 'Use a safe tool', messageId: `stream-schema-tool-${controlKey}`, - integrationTools: [ - { - name: 'unsafe_tool', - description: 'Unsafe schema control', - input_schema: unsafeSchema, - }, - { - name: 'safe_tool', - description: 'Safe schema', - input_schema: { type: 'object', properties: {} }, - }, - ], + integrationTools, }, { userId: 'user-1', @@ -920,9 +750,7 @@ describe('runCopilotLifecycle', () => { } ) - expect(JSON.parse(capturedRequestBody).integrationTools).toEqual([ - expect.objectContaining({ name: 'safe_tool' }), - ]) + expect(JSON.parse(capturedRequestBody).integrationTools).toEqual(integrationTools) mockRunStreamLoop.mockClear() mockRunStreamLoop.mockImplementationOnce(async (_url: string, request: RequestInit) => { @@ -932,7 +760,7 @@ describe('runCopilotLifecycle', () => { { message: 'Use a response schema', messageId: `stream-schema-response-${controlKey}`, - responseFormat: { name: 'unsafe_response', schema: unsafeSchema }, + responseFormat: { name: 'ordinary_response', schema }, }, { userId: 'user-1', @@ -943,13 +771,15 @@ describe('runCopilotLifecycle', () => { ) expect(result.success).toBe(true) - expect(JSON.parse(capturedRequestBody)).not.toHaveProperty('responseFormat') - expect(capturedRequestBody).not.toContain(secret) + expect(JSON.parse(capturedRequestBody).responseFormat).toEqual({ + name: 'ordinary_response', + schema, + }) expect(mockRunStreamLoop).toHaveBeenCalledOnce() } ) - it('omits malformed and oversized optional Copilot response schemas', async () => { + it('does not couple optional Copilot response schemas to secret provenance', async () => { const registry = new ResolvedSecretTraceRegistry() for (const [index, schema] of [ { properties: { field: 'not-a-schema' } }, @@ -976,7 +806,9 @@ describe('runCopilotLifecycle', () => { ) expect(result.success).toBe(true) - expect(JSON.parse(capturedRequestBody)).not.toHaveProperty('responseFormat') + expect(JSON.stringify(JSON.parse(capturedRequestBody).responseFormat)).toBe( + JSON.stringify({ name: 'unsafe_response', schema }) + ) expect(mockRunStreamLoop).toHaveBeenCalledOnce() } }) @@ -1082,7 +914,7 @@ describe('runCopilotLifecycle', () => { expect(JSON.parse(capturedRequestBody).responseFormat.schema).toEqual(schema) }) - it('fails before the initial Go request when model projection is incomplete', async () => { + it('does not block ordinary initial Go payloads on unrelated incomplete provenance', async () => { const registry = new ResolvedSecretTraceRegistry() registry.markIncomplete() @@ -1100,11 +932,12 @@ describe('runCopilotLifecycle', () => { } ) - expect(result).toMatchObject({ - success: false, - error: 'Copilot model input could not be safely projected', + expect(result.success).toBe(true) + expect(JSON.parse(String(mockRunStreamLoop.mock.calls[0]?.[1].body))).toMatchObject({ + message: 'possibly secret', + messageId: 'stream-incomplete-projection', }) - expect(mockRunStreamLoop).not.toHaveBeenCalled() + expect(mockRunStreamLoop).toHaveBeenCalledOnce() }) describe('tool permission feature flag', () => { @@ -1526,11 +1359,10 @@ describe('runCopilotLifecycle', () => { } }) - it('fails closed instead of sending a secret-bearing tool name on resume', async () => { + it('preserves a resume tool name that collides with a configured secret', async () => { const registry = new ResolvedSecretTraceRegistry([ { name: 'TOKEN', plaintext: 'unsafe-tool', encryptedValue: 'ciphertext' }, ]) - registry.recordResolved('TOKEN', 'unsafe-tool') mockRunStreamLoop.mockImplementationOnce( async ( _fetchUrl: string, @@ -1559,11 +1391,11 @@ describe('runCopilotLifecycle', () => { } ) - expect(result).toMatchObject({ - success: false, - error: 'Copilot model input could not be safely projected', + expect(result.success).toBe(true) + expect(mockRunStreamLoop).toHaveBeenCalledTimes(2) + expect(JSON.parse(String(mockRunStreamLoop.mock.calls[1]?.[1].body))).toMatchObject({ + results: [{ callId: 'tool-1', name: 'unsafe-tool', success: true }], }) - expect(mockRunStreamLoop).toHaveBeenCalledTimes(1) }) it('runs legacy-v0 during Sim-first deployment without guessed billing aliases', async () => { diff --git a/apps/sim/lib/copilot/request/lifecycle/run.ts b/apps/sim/lib/copilot/request/lifecycle/run.ts index 42160ffe4e7..c03744bb340 100644 --- a/apps/sim/lib/copilot/request/lifecycle/run.ts +++ b/apps/sim/lib/copilot/request/lifecycle/run.ts @@ -4,7 +4,7 @@ import type { PermissionType } from '@sim/platform-authz/workspace' import { toError } from '@sim/utils/errors' import { sleep } from '@sim/utils/helpers' import { generateId } from '@sim/utils/id' -import { isPlainRecord, omit } from '@sim/utils/object' +import { omit } from '@sim/utils/object' import { type AttributedBillingRequestEnvelope, assertBillingAttributionSnapshot, @@ -28,20 +28,6 @@ import { MothershipStreamV1RunKind, MothershipStreamV1ToolOutcome, } from '@/lib/copilot/generated/mothership-stream-v1' -import { - COPILOT_CONTEXT_MODEL_TEXT_KEYS, - COPILOT_CONTEXT_ROUTING_KEYS, - COPILOT_DESKTOP_MODEL_TEXT_KEYS, - COPILOT_MESSAGE_DISPLAY_KEYS, - COPILOT_USER_METADATA_MODEL_TEXT_KEYS, - COPILOT_VFS_MODEL_TEXT_KEYS, - COPILOT_VFS_ROUTING_KEYS, - isCopilotModelTextKey, -} from '@/lib/copilot/model-visible-content' -import { - collectModelVisibleSchemaContent, - getModelVisibleSchemaAction, -} from '@/lib/copilot/model-visible-schema' import { getAutoAllowedTools } from '@/lib/copilot/persistence/tool-permission/auto-allow' import { createStreamingContext } from '@/lib/copilot/request/context/request-context' import { buildToolCallSummaries } from '@/lib/copilot/request/context/result' @@ -83,63 +69,17 @@ import { isHosted, } from '@/lib/core/config/env-flags' import { filterModelSafeWorkspaceFileAttachments } from '@/lib/uploads/contexts/workspace/workspace-file-secret-provenance' -import { - isResolvedSecretModelContentUnchanged, - projectResolvedSecretModelContent, - projectResolvedSecretModelJsonStrings, -} from '@/executor/utils/resolved-secret-content-projection' import type { ResolvedSecretTraceRegistry } from '@/executor/utils/resolved-secret-trace-registry' const logger = createLogger('CopilotLifecycle') const MAX_RESUME_ATTEMPTS = 3 const RESUME_BACKOFF_MS = [250, 500, 1000] as const -const MAX_SELECTED_CONTENT_NODES = 100_000 -const MAX_SELECTED_CONTENT_DEPTH = 100 -const SIMPLE_MODEL_CONTENT_KEYS = [ - 'message', - 'systemPrompt', - 'workspaceContext', - 'commands', - 'implicitFeedback', - 'workflowName', -] as const -const TOOL_PAYLOAD_KEYS = ['tools', 'integrationTools', 'mothershipTools'] as const -const TOOL_SCHEMA_KEYS = new Set(['input_schema', 'parameters', 'outputs']) const MOTHERSHIP_CODE_TOOL_ROUTES = new Set([ '/api/copilot', '/api/mothership', '/api/mothership/execute', ]) -const MESSAGE_CONTAINER_KEYS = new Set([ - 'contentBlocks', - 'contexts', - 'display', - 'fileAttachments', - 'files', - 'function', - 'function_call', - 'result', - 'toolCall', - 'tool_calls', -]) -const MESSAGE_OPAQUE_CONTENT_KEYS = new Set(['error', 'output', 'params']) -const ATTACHMENT_PARENT_KEYS = new Set(['attachments', 'fileAttachments', 'files']) -const MESSAGE_HANDLE_PARENT_KEYS = new Set(['function', 'function_call', 'toolCall']) - -type SelectedContentAction = - | 'preserve' - | 'project' - | 'project-json' - | 'traverse' - | 'traverse-verify-key' - | 'verify-key-value' - | 'verify' -type SelectedContentSelector = ( - path: readonly string[], - key: string, - value: unknown -) => SelectedContentAction class CopilotModelContentProjectionError extends Error { constructor() { @@ -148,451 +88,6 @@ class CopilotModelContentProjectionError extends Error { } } -interface SelectedContentTraversalState { - nodes: number - ancestors: WeakSet -} - -interface SelectedContentBuckets { - projected: unknown[] - jsonStrings: string[] - guarded: unknown[] -} - -function visitSelectedContentNode(state: SelectedContentTraversalState, depth: number): void { - state.nodes += 1 - if (state.nodes > MAX_SELECTED_CONTENT_NODES || depth > MAX_SELECTED_CONTENT_DEPTH) { - throw new CopilotModelContentProjectionError() - } -} - -function projectModelContent(value: unknown, registry: ResolvedSecretTraceRegistry): unknown { - const projection = projectResolvedSecretModelContent(value, registry) - if (!projection.safe) throw new CopilotModelContentProjectionError() - return projection.value -} - -function collectSelectedContent( - value: unknown, - selector: SelectedContentSelector, - selected: SelectedContentBuckets, - path: readonly string[] = [], - state: SelectedContentTraversalState = { nodes: 0, ancestors: new WeakSet() }, - depth = 0 -): void { - visitSelectedContentNode(state, depth) - if (value === null || typeof value !== 'object') return - if (state.ancestors.has(value)) throw new CopilotModelContentProjectionError() - - state.ancestors.add(value) - try { - if (Array.isArray(value)) { - for (const item of value) { - collectSelectedContent(item, selector, selected, [...path, '*'], state, depth + 1) - } - return - } - if (!isPlainRecord(value)) throw new CopilotModelContentProjectionError() - - for (const [key, item] of Object.entries(value)) { - const action = selector(path, key, item) - if (action === 'project') { - selected.projected.push(item) - } else if (action === 'project-json') { - if (typeof item !== 'string') throw new CopilotModelContentProjectionError() - selected.jsonStrings.push(item) - } else if (action === 'verify') { - selected.guarded.push(item) - } else if (action === 'verify-key-value') { - selected.guarded.push(key, item) - } else if (action === 'traverse' || action === 'traverse-verify-key') { - if (action === 'traverse-verify-key') selected.guarded.push(key) - collectSelectedContent(item, selector, selected, [...path, key], state, depth + 1) - } - } - } finally { - state.ancestors.delete(value) - } -} - -function restoreSelectedContent( - value: unknown, - selector: SelectedContentSelector, - projected: readonly unknown[], - projectedJsonStrings: readonly string[], - cursor: { projected: number; jsonStrings: number }, - path: readonly string[] = [], - state: SelectedContentTraversalState = { nodes: 0, ancestors: new WeakSet() }, - depth = 0 -): unknown { - visitSelectedContentNode(state, depth) - if (value === null || typeof value !== 'object') return value - if (state.ancestors.has(value)) throw new CopilotModelContentProjectionError() - - state.ancestors.add(value) - try { - if (Array.isArray(value)) { - return value.map((item) => - restoreSelectedContent( - item, - selector, - projected, - projectedJsonStrings, - cursor, - [...path, '*'], - state, - depth + 1 - ) - ) - } - if (!isPlainRecord(value)) throw new CopilotModelContentProjectionError() - - const restored: Record = { ...value } - for (const [key, item] of Object.entries(value)) { - const action = selector(path, key, item) - if (action === 'project') { - if (cursor.projected >= projected.length) throw new CopilotModelContentProjectionError() - restored[key] = projected[cursor.projected] - cursor.projected += 1 - } else if (action === 'project-json') { - if (cursor.jsonStrings >= projectedJsonStrings.length) { - throw new CopilotModelContentProjectionError() - } - restored[key] = projectedJsonStrings[cursor.jsonStrings] - cursor.jsonStrings += 1 - } else if (action === 'traverse' || action === 'traverse-verify-key') { - restored[key] = restoreSelectedContent( - item, - selector, - projected, - projectedJsonStrings, - cursor, - [...path, key], - state, - depth + 1 - ) - } - } - return restored - } finally { - state.ancestors.delete(value) - } -} - -function projectSelectedContent( - value: unknown, - registry: ResolvedSecretTraceRegistry, - selector: SelectedContentSelector -): unknown { - const selected: SelectedContentBuckets = { projected: [], jsonStrings: [], guarded: [] } - collectSelectedContent(value, selector, selected) - if (!isResolvedSecretModelContentUnchanged(selected.guarded, registry)) { - throw new CopilotModelContentProjectionError() - } - - const projected = projectModelContent(selected.projected, registry) - if (!Array.isArray(projected) || projected.length !== selected.projected.length) { - throw new CopilotModelContentProjectionError() - } - const jsonProjection = projectResolvedSecretModelJsonStrings(selected.jsonStrings, registry) - if ( - !jsonProjection.safe || - !Array.isArray(jsonProjection.value) || - !jsonProjection.value.every((item) => typeof item === 'string') || - jsonProjection.value.length !== selected.jsonStrings.length - ) { - throw new CopilotModelContentProjectionError() - } - const cursor = { projected: 0, jsonStrings: 0 } - const restored = restoreSelectedContent(value, selector, projected, jsonProjection.value, cursor) - if (cursor.projected !== projected.length || cursor.jsonStrings !== jsonProjection.value.length) { - throw new CopilotModelContentProjectionError() - } - return restored -} - -function projectStructuredContent( - value: unknown, - registry: ResolvedSecretTraceRegistry, - selector: SelectedContentSelector, - shape: 'array' | 'record' | 'record-or-array' -): unknown { - if ( - (shape === 'array' && !Array.isArray(value)) || - (shape === 'record' && !isPlainRecord(value)) || - (shape === 'record-or-array' && !Array.isArray(value) && !isPlainRecord(value)) - ) { - throw new CopilotModelContentProjectionError() - } - return projectSelectedContent(value, registry, selector) -} - -function schemaContentAction( - path: readonly string[], - key: string, - value: unknown -): SelectedContentAction { - return getModelVisibleSchemaAction(path.at(-1), key, value) -} - -const toolContentSelector: SelectedContentSelector = (path, key, value) => { - if (path.length === 0 && key === 'description') return 'project' - if (path.length === 0 && key === 'name') return 'verify' - if (path.length === 0 && TOOL_SCHEMA_KEYS.has(key)) return 'traverse' - - const schemaRootIndex = path.findIndex((segment) => TOOL_SCHEMA_KEYS.has(segment)) - if (schemaRootIndex >= 0) { - return schemaContentAction(path.slice(schemaRootIndex + 1), key, value) - } - return 'preserve' -} - -const contextContentSelector: SelectedContentSelector = (_path, key, value) => { - if (isCopilotModelTextKey(COPILOT_CONTEXT_MODEL_TEXT_KEYS, key)) return 'project' - return value !== null && typeof value === 'object' ? 'traverse' : 'preserve' -} - -function nearestPathContainer(path: readonly string[]): string | undefined { - for (let index = path.length - 1; index >= 0; index -= 1) { - if (path[index] !== '*') return path[index] - } - return undefined -} - -const messageContentSelector: SelectedContentSelector = (path, key, value) => { - if (key === 'content') { - return value !== null && typeof value === 'object' ? 'traverse' : 'project' - } - if (MESSAGE_OPAQUE_CONTENT_KEYS.has(key)) return 'project' - if (key === 'arguments') return 'project-json' - if (isCopilotModelTextKey(COPILOT_MESSAGE_DISPLAY_KEYS, key)) return 'project' - if ( - (key === 'name' || key === 'filename' || key === 'fileName') && - ATTACHMENT_PARENT_KEYS.has(nearestPathContainer(path) ?? '') - ) { - return 'project' - } - if ( - key === 'name' && - (path.length === 1 || MESSAGE_HANDLE_PARENT_KEYS.has(nearestPathContainer(path) ?? '')) - ) { - return 'verify' - } - if (key === 'name') return 'project' - if (key === 'context' && nearestPathContainer(path) === 'files') return 'project' - if (MESSAGE_CONTAINER_KEYS.has(key)) return 'traverse' - return 'preserve' -} - -const responseFormatContentSelector: SelectedContentSelector = (path, key, value) => { - if (path.length === 0 && (key === 'description' || key === 'instructions')) return 'project' - if (path.length === 0 && key === 'name') return 'verify' - if (path.length === 0 && key === 'schema') return 'traverse' - if (path.length === 0) return schemaContentAction(path, key, value) - if (path[0] === 'schema') return schemaContentAction(path.slice(1), key, value) - return 'preserve' -} - -const vfsContentSelector: SelectedContentSelector = (_path, key, value) => { - if (isCopilotModelTextKey(COPILOT_VFS_MODEL_TEXT_KEYS, key)) return 'project' - return value !== null && typeof value === 'object' ? 'traverse' : 'preserve' -} - -const userMetadataContentSelector: SelectedContentSelector = (_path, key) => - isCopilotModelTextKey(COPILOT_USER_METADATA_MODEL_TEXT_KEYS, key) ? 'project' : 'preserve' - -const desktopContentSelector: SelectedContentSelector = (_path, key, value) => { - if (isCopilotModelTextKey(COPILOT_DESKTOP_MODEL_TEXT_KEYS, key)) return 'project' - return value !== null && typeof value === 'object' ? 'traverse' : 'preserve' -} - -function projectModelSafeToolPayloads( - value: unknown, - registry: ResolvedSecretTraceRegistry -): unknown[] { - if (!Array.isArray(value)) throw new CopilotModelContentProjectionError() - - const projected: unknown[] = [] - for (const candidate of value) { - if (!isPlainRecord(candidate) || typeof candidate.name !== 'string') continue - - try { - for (const schemaKey of TOOL_SCHEMA_KEYS) { - if (Object.hasOwn(candidate, schemaKey)) { - collectModelVisibleSchemaContent(candidate[schemaKey]) - } - } - projected.push(projectStructuredContent(candidate, registry, toolContentSelector, 'record')) - } catch { - // Tool definitions are independent protocol entities. Reject an unsafe definition without - // turning the entire catalog into one synthetic projection value or failing safe siblings. - } - } - - // Projection completeness is a request-level invariant, even when every candidate was rejected. - projectModelContent([], registry) - return projected -} - -function hasModelSafeRoutingFields( - value: Record, - routingKeys: readonly string[], - registry: ResolvedSecretTraceRegistry -): boolean { - for (const routingKey of routingKeys) { - if (!Object.hasOwn(value, routingKey)) continue - const routingValue = value[routingKey] - if ( - typeof routingValue !== 'string' || - !isResolvedSecretModelContentUnchanged(routingValue, registry) - ) { - return false - } - } - return true -} - -function filterModelSafeContextPayload( - value: unknown, - registry: ResolvedSecretTraceRegistry -): Record | unknown[] | undefined { - if (Array.isArray(value)) { - return value.filter( - (candidate) => - isPlainRecord(candidate) && - hasModelSafeRoutingFields(candidate, COPILOT_CONTEXT_ROUTING_KEYS, registry) - ) - } - if (!isPlainRecord(value)) throw new CopilotModelContentProjectionError() - return hasModelSafeRoutingFields(value, COPILOT_CONTEXT_ROUTING_KEYS, registry) - ? value - : undefined -} - -function filterModelSafeVfsPayload( - value: unknown, - registry: ResolvedSecretTraceRegistry -): Record { - if (!isPlainRecord(value)) throw new CopilotModelContentProjectionError() - const filtered: Record = { ...value } - - for (const [collectionKey, collection] of Object.entries(value)) { - if (collectionKey === 'envVars') { - if (!Array.isArray(collection) || !collection.every((item) => typeof item === 'string')) { - throw new CopilotModelContentProjectionError() - } - filtered[collectionKey] = collection.filter((name) => - isResolvedSecretModelContentUnchanged(name, registry) - ) - continue - } - if (!Array.isArray(collection)) continue - - filtered[collectionKey] = collection.filter((candidate) => { - if (!isPlainRecord(candidate)) return false - return hasModelSafeRoutingFields(candidate, COPILOT_VFS_ROUTING_KEYS, registry) - }) - } - - return filtered -} - -function filterModelSafeUserMetadata( - value: unknown, - registry: ResolvedSecretTraceRegistry -): Record { - if (!isPlainRecord(value)) throw new CopilotModelContentProjectionError() - const filtered = { ...value } - if ( - Object.hasOwn(filtered, 'timezone') && - (typeof filtered.timezone !== 'string' || - !isResolvedSecretModelContentUnchanged(filtered.timezone, registry)) - ) { - return omit(filtered, ['timezone']) - } - return filtered -} - -function filterModelSafeDesktopCapabilities( - value: unknown, - registry: ResolvedSecretTraceRegistry -): Record { - if (!isPlainRecord(value)) throw new CopilotModelContentProjectionError() - const filtered: Record = { ...value } - - if (Object.hasOwn(value, 'terminals')) { - if (!Array.isArray(value.terminals)) throw new CopilotModelContentProjectionError() - filtered.terminals = value.terminals.filter((terminal) => { - if (!isPlainRecord(terminal)) return false - return ( - !Object.hasOwn(terminal, 'cwd') || - (typeof terminal.cwd === 'string' && - isResolvedSecretModelContentUnchanged(terminal.cwd, registry)) - ) - }) - } - - if (Object.hasOwn(value, 'browserSessions')) { - if (!Array.isArray(value.browserSessions)) throw new CopilotModelContentProjectionError() - filtered.browserSessions = value.browserSessions.filter( - (session) => - isPlainRecord(session) && - typeof session.hostname === 'string' && - isResolvedSecretModelContentUnchanged(session.hostname, registry) - ) - } - - return filtered -} - -function projectAttachmentDisplayNames( - payload: Record, - registry: ResolvedSecretTraceRegistry -): Partial> { - const projected: Partial> = {} - for (const key of ['attachments', 'fileAttachments'] as const) { - if (!Object.hasOwn(payload, key)) continue - const attachments = payload[key] - if (!Array.isArray(attachments)) throw new CopilotModelContentProjectionError() - const displayNames = attachments.map((attachment) => { - if (!isPlainRecord(attachment)) throw new CopilotModelContentProjectionError() - return { - ...(Object.hasOwn(attachment, 'name') ? { name: attachment.name } : {}), - ...(Object.hasOwn(attachment, 'filename') ? { filename: attachment.filename } : {}), - } - }) - const projection = projectResolvedSecretModelContent(displayNames, registry) - if ( - !projection.safe || - !Array.isArray(projection.value) || - projection.value.length !== attachments.length - ) { - throw new CopilotModelContentProjectionError() - } - const projectedDisplayNames = projection.value - projected[key] = attachments.map((attachment, index) => { - const displayName = projectedDisplayNames[index] - if (!isPlainRecord(attachment) || !isPlainRecord(displayName)) { - throw new CopilotModelContentProjectionError() - } - const name = displayName.name - const filename = displayName.filename - if (name !== undefined && typeof name !== 'string') { - throw new CopilotModelContentProjectionError() - } - if (filename !== undefined && typeof filename !== 'string') { - throw new CopilotModelContentProjectionError() - } - return { - ...attachment, - ...(name !== undefined ? { name } : {}), - ...(filename !== undefined ? { filename } : {}), - } - }) - } - return projected -} - async function omitUnsafeInitialCopilotAttachments( payload: Record, workspaceId?: string @@ -625,115 +120,11 @@ async function omitUnsafeInitialCopilotAttachments( return projected } -async function projectInitialCopilotPayload( +async function filterInitialCopilotAttachmentsForModel( payload: Record, - registry: ResolvedSecretTraceRegistry, workspaceId?: string ): Promise> { - projectModelContent([], registry) - let projectedPayload = { ...payload } - const simpleContent: Record = {} - for (const key of SIMPLE_MODEL_CONTENT_KEYS) { - if (Object.hasOwn(payload, key)) simpleContent[key] = payload[key] - } - const projectedSimpleContent = projectModelContent(simpleContent, registry) - if (!isPlainRecord(projectedSimpleContent)) throw new CopilotModelContentProjectionError() - for (const key of SIMPLE_MODEL_CONTENT_KEYS) { - if (Object.hasOwn(payload, key) && Object.hasOwn(projectedSimpleContent, key)) { - projectedPayload[key] = projectedSimpleContent[key] - } - } - if (Object.hasOwn(payload, 'userTimezone')) { - if ( - typeof payload.userTimezone === 'string' && - isResolvedSecretModelContentUnchanged(payload.userTimezone, registry) - ) { - projectedPayload.userTimezone = payload.userTimezone - } else { - projectedPayload = omit(projectedPayload, ['userTimezone']) - } - } - - if (Object.hasOwn(payload, 'messages')) { - projectedPayload.messages = projectStructuredContent( - payload.messages, - registry, - messageContentSelector, - 'array' - ) - } - for (const key of ['context', 'contexts'] as const) { - if (Object.hasOwn(payload, key)) { - if (typeof payload[key] === 'string') { - projectedPayload[key] = projectModelContent(payload[key], registry) - continue - } - const safeContexts = filterModelSafeContextPayload(payload[key], registry) - if (safeContexts === undefined) { - projectedPayload = omit(projectedPayload, [key]) - } else { - projectedPayload[key] = projectStructuredContent( - safeContexts, - registry, - contextContentSelector, - 'record-or-array' - ) - } - } - } - for (const key of TOOL_PAYLOAD_KEYS) { - if (Object.hasOwn(payload, key)) { - projectedPayload[key] = projectModelSafeToolPayloads(payload[key], registry) - } - } - if (Object.hasOwn(payload, 'responseFormat')) { - try { - if ( - isPlainRecord(payload.responseFormat) && - Object.hasOwn(payload.responseFormat, 'schema') - ) { - collectModelVisibleSchemaContent(payload.responseFormat.schema) - } - projectedPayload.responseFormat = - typeof payload.responseFormat === 'string' - ? projectModelContent(payload.responseFormat, registry) - : projectStructuredContent( - payload.responseFormat, - registry, - responseFormatContentSelector, - 'record' - ) - } catch { - logger.warn('Omitting a Copilot response format with unsafe model-input provenance') - projectedPayload = omit(projectedPayload, ['responseFormat']) - } - } - if (Object.hasOwn(payload, 'vfs')) { - projectedPayload.vfs = projectStructuredContent( - filterModelSafeVfsPayload(payload.vfs, registry), - registry, - vfsContentSelector, - 'record' - ) - } - if (Object.hasOwn(payload, 'userMetadata')) { - projectedPayload.userMetadata = projectStructuredContent( - filterModelSafeUserMetadata(payload.userMetadata, registry), - registry, - userMetadataContentSelector, - 'record' - ) - } - if (Object.hasOwn(payload, 'desktopCapabilities')) { - projectedPayload.desktopCapabilities = projectStructuredContent( - filterModelSafeDesktopCapabilities(payload.desktopCapabilities, registry), - registry, - desktopContentSelector, - 'record' - ) - } - Object.assign(projectedPayload, projectAttachmentDisplayNames(payload, registry)) - return omitUnsafeInitialCopilotAttachments(projectedPayload, workspaceId) + return omitUnsafeInitialCopilotAttachments(payload, workspaceId) } async function ensureModelEgressRegistry( @@ -919,10 +310,9 @@ export async function runCopilotLifecycle( let onCompleteStarted = false try { - const modelEgressRegistry = await ensureModelEgressRegistry(execContext, lifecycleOptions) - const modelSafeRequestPayload = await projectInitialCopilotPayload( + await ensureModelEgressRegistry(execContext, lifecycleOptions) + const modelSafeRequestPayload = await filterInitialCopilotAttachmentsForModel( requestPayload, - modelEgressRegistry, lifecycleOptions.workspaceId ) await runCheckpointLoop( @@ -1135,8 +525,7 @@ async function waitForToolIds(context: StreamingContext, toolIds: string[]): Pro function collectResultsForToolIds( context: StreamingContext, toolIds: string[], - checkpointId: string, - registry: ResolvedSecretTraceRegistry + checkpointId: string ): Array<{ callId: string; name: string; data: unknown; success: boolean }> { return toolIds.map((toolCallId) => { const tool = context.toolCalls.get(toolCallId) @@ -1146,9 +535,6 @@ function collectResultsForToolIds( ) } const name = tool.name || '' - if (!isResolvedSecretModelContentUnchanged(name, registry)) { - throw new CopilotModelContentProjectionError() - } return { callId: toolCallId, name, @@ -1238,9 +624,7 @@ async function driveOneChildChain( if (isAborted(options, context)) return null await waitForToolIds(context, toolIds) - const registry = execContext.resolvedSecretTraceRegistry - if (!registry) throw new CopilotModelContentProjectionError() - const results = collectResultsForToolIds(context, toolIds, checkpointId, registry) + const results = collectResultsForToolIds(context, toolIds, checkpointId) const leg = makeResumeLegContext(context) await runResumeLegWithRetry( @@ -1651,9 +1035,6 @@ async function runCheckpointLoop( throw new Error(`Cannot resume: missing result for pending tool call ${toolCallId}`) } const name = tool.name || '' - if (!isResolvedSecretModelContentUnchanged(name, execContext.resolvedSecretTraceRegistry)) { - throw new CopilotModelContentProjectionError() - } results.push({ callId: toolCallId, name, diff --git a/apps/sim/lib/copilot/request/lifecycle/start.test.ts b/apps/sim/lib/copilot/request/lifecycle/start.test.ts index c3dcfcb02d8..80f6bc94896 100644 --- a/apps/sim/lib/copilot/request/lifecycle/start.test.ts +++ b/apps/sim/lib/copilot/request/lifecycle/start.test.ts @@ -325,7 +325,7 @@ describe('createSSEStream terminal error handling', () => { expect(lifecycleTraceparent).toMatch(/^00-[0-9a-f]{32}-[0-9a-f]{16}-0[0-9a-f]$/) }) - it('projects title input using the execution-context secret registry', async () => { + it('does not scan manually authored title input against unrelated active secrets', async () => { runCopilotLifecycle.mockResolvedValue({ success: true, content: 'OK', @@ -362,7 +362,7 @@ describe('createSSEStream terminal error handling', () => { await vi.waitFor(() => expect(fetchGo).toHaveBeenCalled()) const [, request] = fetchGo.mock.calls.at(-1) ?? [] expect(JSON.parse(request.body)).toEqual( - expect.objectContaining({ message: 'hello {{TOKEN}}' }) + expect.objectContaining({ message: 'hello secret-value' }) ) }) }) diff --git a/apps/sim/lib/copilot/request/lifecycle/start.ts b/apps/sim/lib/copilot/request/lifecycle/start.ts index fcda495a529..3067e971335 100644 --- a/apps/sim/lib/copilot/request/lifecycle/start.ts +++ b/apps/sim/lib/copilot/request/lifecycle/start.ts @@ -53,8 +53,6 @@ import { TraceCollector } from '@/lib/copilot/request/trace' import { getMothershipBaseURL, getMothershipSourceEnvHeaders } from '@/lib/copilot/server/agent-url' import { env } from '@/lib/core/config/env' import { isCopilotBillingAttributionV1Enabled, isHosted } from '@/lib/core/config/env-flags' -import { projectResolvedSecretModelContent } from '@/executor/utils/resolved-secret-content-projection' -import type { ResolvedSecretTraceRegistry } from '@/executor/utils/resolved-secret-trace-registry' export { SSE_RESPONSE_HEADERS } @@ -251,9 +249,6 @@ export function createSSEStream(params: StreamingOrchestrationParams): ReadableS requestId, publisher, otelContext, - resolvedSecretTraceRegistry: - orchestrateOptions.resolvedSecretTraceRegistry ?? - orchestrateOptions.executionContext?.resolvedSecretTraceRegistry, }) try { @@ -442,7 +437,6 @@ function fireTitleGeneration(params: { requestId: string publisher: StreamWriter otelContext?: Context - resolvedSecretTraceRegistry?: ResolvedSecretTraceRegistry }): void { const { chatId, @@ -457,18 +451,11 @@ function fireTitleGeneration(params: { requestId, publisher, otelContext, - resolvedSecretTraceRegistry, } = params if (!chatId || currentChat?.title || !isNewChat) return - const projectedMessage = projectResolvedSecretModelContent(message, resolvedSecretTraceRegistry) - if (!projectedMessage.safe || typeof projectedMessage.value !== 'string') { - logger.warn(`[${requestId}] Skipping title generation because its input was not safe`) - return - } - requestChatTitle({ - message: projectedMessage.value, + message, model: titleModel, provider: titleProvider, userId, diff --git a/apps/sim/lib/copilot/request/tools/client.ts b/apps/sim/lib/copilot/request/tools/client.ts index b020c32119a..a7aac65cd12 100644 --- a/apps/sim/lib/copilot/request/tools/client.ts +++ b/apps/sim/lib/copilot/request/tools/client.ts @@ -84,7 +84,7 @@ export async function waitForClientToolCompletion({ const completion = await waitForToolCompletion(toolCallId, timeoutMs, abortSignal) if (!completion) return null - const toolRegistry = registry?.forkForToolInput(undefined) + const toolRegistry = registry?.forkForInputPaths([]) const genericMessage = getGenericCompletionMessage(completion.status) const binding = runId ? { toolCallId, runId, userId } : undefined const registryCanImport = toolRegistry !== undefined && !toolRegistry.isPermanentlyIncomplete() @@ -232,7 +232,7 @@ export async function waitForWorkflowToolCompletion({ abortSignal, registry, }: WaitForWorkflowToolCompletionOptions): Promise { - const toolRegistry = registry?.forkForToolInput(undefined) + const toolRegistry = registry?.forkForInputPaths([]) const finishPendingActivation = toolRegistry?.beginPendingActivation() let completion: AsyncTerminalCompletionSnapshot | null = null let trustedExecution: Awaited> = null diff --git a/apps/sim/lib/copilot/request/tools/executor.test.ts b/apps/sim/lib/copilot/request/tools/executor.test.ts index 5078e2377fe..b29a92bff5f 100644 --- a/apps/sim/lib/copilot/request/tools/executor.test.ts +++ b/apps/sim/lib/copilot/request/tools/executor.test.ts @@ -202,7 +202,9 @@ describe('executeToolAndReport provenance isolation', () => { _params: Record, toolContext: ExecutionContext ) => { - toolContext.resolvedSecretTraceRegistry?.recordResolved('TOKEN', 'secret-value') + toolContext.resolvedSecretTraceRegistry?.recordResolved('TOKEN', 'secret-value', { + propagated: true, + }) return { success: true, output: { value: 'secret-value' } } } ) diff --git a/apps/sim/lib/copilot/request/tools/executor.ts b/apps/sim/lib/copilot/request/tools/executor.ts index 2d550c7ff82..2db7570d004 100644 --- a/apps/sim/lib/copilot/request/tools/executor.ts +++ b/apps/sim/lib/copilot/request/tools/executor.ts @@ -277,9 +277,7 @@ export function buildToolExecutionContext( return { ...execContext, toolCallId: toolCall.id, - resolvedSecretTraceRegistry: execContext.resolvedSecretTraceRegistry?.forkForToolInput( - toolCall.params - ), + resolvedSecretTraceRegistry: execContext.resolvedSecretTraceRegistry?.forkForInputPaths([]), ...(toolCall.parentToolCallId ? { parentToolCallId: toolCall.parentToolCallId } : {}), } } diff --git a/apps/sim/lib/copilot/request/tools/files.test.ts b/apps/sim/lib/copilot/request/tools/files.test.ts index f09a7c396d6..c90c158037a 100644 --- a/apps/sim/lib/copilot/request/tools/files.test.ts +++ b/apps/sim/lib/copilot/request/tools/files.test.ts @@ -3,10 +3,15 @@ */ import { beforeEach, describe, expect, it, vi } from 'vitest' -const { mockWriteWorkspaceFileByPath } = vi.hoisted(() => ({ +const { mockEncryptSecret, mockWriteWorkspaceFileByPath } = vi.hoisted(() => ({ + mockEncryptSecret: vi.fn(), mockWriteWorkspaceFileByPath: vi.fn(), })) +vi.mock('@/lib/core/security/encryption', () => ({ + encryptSecret: mockEncryptSecret, +})) + vi.mock('@/lib/copilot/vfs/resource-writer', () => ({ writeWorkspaceFileByPath: mockWriteWorkspaceFileByPath, })) @@ -27,8 +32,8 @@ import { serializeOutputForFile, unwrapFunctionExecuteOutput, } from '@/lib/copilot/request/tools/files' -import { projectToolResultForCopilot } from '@/lib/copilot/request/tools/resolved-secret-result' import type { ExecutionContext } from '@/lib/copilot/request/types' +import { MAX_INLINE_MATERIALIZATION_BYTES } from '@/lib/execution/payloads/limits' import { ResolvedSecretTraceRegistry } from '@/executor/utils/resolved-secret-trace-registry' describe('unwrapFunctionExecuteOutput', () => { @@ -121,6 +126,7 @@ describe('maybeWriteOutputToFile', () => { beforeEach(() => { vi.clearAllMocks() + mockEncryptSecret.mockResolvedValue({ encrypted: 'encrypted-csv-representation', iv: 'iv' }) mockWriteWorkspaceFileByPath.mockResolvedValue({ id: 'file-1', name: 'report.csv', @@ -164,23 +170,104 @@ describe('maybeWriteOutputToFile', () => { expect(result.success).toBe(true) expect(mockWriteWorkspaceFileByPath).toHaveBeenCalledTimes(1) + expect(mockWriteWorkspaceFileByPath).toHaveBeenCalledWith( + expect.objectContaining({ secretProvenance: { status: 'exact', entries: [] } }) + ) }) - it('persists canonical aliases and leaves unrelated low-entropy public values unchanged', async () => { - const parentRegistry = new ResolvedSecretTraceRegistry([ - { - name: 'OUTPUT_SECRET', - plaintext: 'secret-value', - encryptedValue: 'encrypted-output-secret', - }, + it('classifies large structured output from its serialized bytes instead of its object count', async () => { + const registry = new ResolvedSecretTraceRegistry( + [ + { name: 'TOKEN', plaintext: 'secret-value', encryptedValue: 'encrypted-token' }, + { + name: 'TOKEN_ALIAS', + plaintext: 'secret-value', + encryptedValue: 'encrypted-token-alias', + }, + ], + { userId: 'user-1', workspaceId: 'workspace-1' } + ) + registry.recordResolved('TOKEN', 'secret-value') + registry.recordResolved('TOKEN_ALIAS', 'secret-value') + const rows = Array.from({ length: 10_000 }, (_, index) => ({ + id: index, + name: `row-${index}`, + status: 'ready', + enabled: true, + token: index === 9_999 ? 'secret-value' : 'public-value', + })) + + const result = await maybeWriteOutputToFile( + FunctionExecute.id, + { outputs: { files: [{ path: 'files/report.json', mode: 'overwrite' }] } }, + { success: true, output: { result: rows, stdout: '' } }, + buildContext({ resolvedSecretTraceRegistry: registry }) + ) + + expect(result.success).toBe(true) + expect(mockWriteWorkspaceFileByPath).toHaveBeenCalledWith( + expect.objectContaining({ + secretProvenance: { + status: 'exact', + entries: [ + { + name: 'TOKEN', + encryptedValue: 'encrypted-token', + sourceUserId: 'user-1', + sourceWorkspaceId: 'workspace-1', + }, + { + name: 'TOKEN_ALIAS', + encryptedValue: 'encrypted-token-alias', + sourceUserId: 'user-1', + sourceWorkspaceId: 'workspace-1', + }, + ], + }, + }) + ) + }) + + it('writes raw output with unknown provenance when serialized output exceeds the scan budget', async () => { + const registry = new ResolvedSecretTraceRegistry([ + { name: 'TOKEN', plaintext: 'secret-value', encryptedValue: 'encrypted-token' }, + ]) + registry.recordResolved('TOKEN', 'secret-value') + + const result = await maybeWriteOutputToFile( + FunctionExecute.id, + { outputs: { files: [{ path: 'files/report.txt', mode: 'overwrite' }] } }, { - name: 'UNRELATED', - plaintext: 'true', - encryptedValue: 'encrypted-unrelated', + success: true, + output: { result: 'x'.repeat(MAX_INLINE_MATERIALIZATION_BYTES + 1), stdout: '' }, }, - ]) + buildContext({ resolvedSecretTraceRegistry: registry }) + ) + + expect(result.success).toBe(true) + expect(mockWriteWorkspaceFileByPath).toHaveBeenCalledWith( + expect.objectContaining({ secretProvenance: { status: 'unknown' } }) + ) + }) + + it('persists raw bytes with exact provenance and leaves sibling literals unclassified', async () => { + const parentRegistry = new ResolvedSecretTraceRegistry( + [ + { + name: 'OUTPUT_SECRET', + plaintext: 'secret-value', + encryptedValue: 'encrypted-output-secret', + }, + { + name: 'UNRELATED', + plaintext: 'true', + encryptedValue: 'encrypted-unrelated', + }, + ], + { userId: 'user-1', workspaceId: 'workspace-1' } + ) parentRegistry.recordResolved('UNRELATED', 'true') - const toolRegistry = parentRegistry.forkForToolInput({ code: 'return {{OUTPUT_SECRET}}' }) + const toolRegistry = parentRegistry.forkForInputPaths([]) toolRegistry.recordResolved('OUTPUT_SECRET', 'secret-value') const runtimeOutput = { result: { token: 'secret-value', publicLabel: 'true', enabled: true }, @@ -195,26 +282,295 @@ describe('maybeWriteOutputToFile', () => { ) expect(result.success).toBe(true) - const persisted = mockWriteWorkspaceFileByPath.mock.calls[0][0].buffer.toString('utf8') + const write = mockWriteWorkspaceFileByPath.mock.calls[0][0] + const persisted = write.buffer.toString('utf8') expect(JSON.parse(persisted)).toEqual({ - token: '{{OUTPUT_SECRET}}', + token: 'secret-value', publicLabel: 'true', enabled: true, }) + expect(write.secretProvenance).toEqual({ + status: 'exact', + entries: [ + { + name: 'OUTPUT_SECRET', + encryptedValue: 'encrypted-output-secret', + sourceUserId: 'user-1', + sourceWorkspaceId: 'workspace-1', + }, + ], + }) expect(runtimeOutput.result.token).toBe('secret-value') + }) - const laterRead = projectToolResultForCopilot( - { success: true, output: { content: persisted } }, - new ResolvedSecretTraceRegistry() + it('tracks both logical and quote-escaped CSV representations', async () => { + const secret = 'a"b\\c\nline' + const registry = new ResolvedSecretTraceRegistry( + [{ name: 'CSV_SECRET', plaintext: secret, encryptedValue: 'encrypted-csv-secret' }], + { userId: 'user-1', workspaceId: 'workspace-1' } ) - expect(JSON.parse((laterRead.output as { content: string }).content)).toEqual({ - token: '{{OUTPUT_SECRET}}', - publicLabel: 'true', - enabled: true, + registry.recordResolved('CSV_SECRET', secret) + + const result = await maybeWriteOutputToFile( + FunctionExecute.id, + { outputs: { files: [{ path: 'files/report.csv', mode: 'overwrite' }] } }, + { success: true, output: { result: [{ value: secret }], stdout: '' } }, + buildContext({ resolvedSecretTraceRegistry: registry }) + ) + + expect(result.success).toBe(true) + const write = mockWriteWorkspaceFileByPath.mock.calls[0][0] + expect(write.buffer.toString('utf8')).toBe('value\n"a""b\\c\nline"') + expect(write.secretProvenance).toEqual({ + status: 'exact', + entries: [ + { + name: 'CSV_SECRET', + encryptedValue: 'encrypted-csv-representation', + sourceUserId: 'user-1', + sourceWorkspaceId: 'workspace-1', + }, + { + name: 'CSV_SECRET', + encryptedValue: 'encrypted-csv-secret', + sourceUserId: 'user-1', + sourceWorkspaceId: 'workspace-1', + }, + ], }) + expect(mockEncryptSecret).toHaveBeenCalledWith('a""b\\c\nline') }) - it('does not write when exact persistence provenance is unavailable', async () => { + it('deduplicates repeated CSV quote transformations across the table', async () => { + const secret = 'secret"value' + const registry = new ResolvedSecretTraceRegistry( + [{ name: 'CSV_SECRET', plaintext: secret, encryptedValue: 'encrypted-csv-secret' }], + { userId: 'user-1', workspaceId: 'workspace-1' } + ) + registry.recordResolved('CSV_SECRET', secret) + const rows = Array.from({ length: 10_000 }, () => ({ first: secret, second: secret })) + + const result = await maybeWriteOutputToFile( + FunctionExecute.id, + { outputs: { files: [{ path: 'files/report.csv', mode: 'overwrite' }] } }, + { success: true, output: { result: rows, stdout: '' } }, + buildContext({ resolvedSecretTraceRegistry: registry }) + ) + + expect(result.success).toBe(true) + expect(mockEncryptSecret).toHaveBeenCalledTimes(1) + expect(mockEncryptSecret).toHaveBeenCalledWith('secret""value') + expect(mockWriteWorkspaceFileByPath).toHaveBeenCalledTimes(1) + }) + + it('reuses serialized provenance for output files with the same format', async () => { + const secret = 'a"b' + const registry = new ResolvedSecretTraceRegistry( + [{ name: 'CSV_SECRET', plaintext: secret, encryptedValue: 'encrypted-csv-secret' }], + { userId: 'user-1', workspaceId: 'workspace-1' } + ) + registry.recordResolved('CSV_SECRET', secret) + + const result = await maybeWriteOutputToFile( + FunctionExecute.id, + { + outputs: { + files: [ + { path: 'files/one.csv', mode: 'overwrite' }, + { path: 'files/two.csv', mode: 'overwrite' }, + ], + }, + }, + { success: true, output: { result: [{ value: secret }], stdout: '' } }, + buildContext({ resolvedSecretTraceRegistry: registry }) + ) + + expect(result.success).toBe(true) + expect(mockEncryptSecret).toHaveBeenCalledTimes(1) + expect(mockWriteWorkspaceFileByPath).toHaveBeenCalledTimes(2) + }) + + it('preserves existing multi-file outputs beyond twenty declarations', async () => { + const files = Array.from({ length: 21 }, (_, index) => ({ + path: `files/report-${index}.txt`, + mode: 'overwrite' as const, + })) + + const result = await maybeWriteOutputToFile( + FunctionExecute.id, + { outputs: { files } }, + { success: true, output: { result: 'content', stdout: '' } }, + buildContext() + ) + + expect(result.success).toBe(true) + expect(mockWriteWorkspaceFileByPath).toHaveBeenCalledTimes(21) + }) + + it('tracks a persisted legacy runtime alias', async () => { + const registry = new ResolvedSecretTraceRegistry( + [{ name: 'API_KEY', plaintext: 'secret-value', encryptedValue: 'encrypted-api-key' }], + { userId: 'user-1', workspaceId: 'workspace-1' } + ) + registry.recordResolved('API_KEY', 'secret-value') + + const result = await maybeWriteOutputToFile( + FunctionExecute.id, + { outputs: { files: [{ path: 'files/report.txt', mode: 'overwrite' }] } }, + { success: true, output: { result: '__var_API_KEY', stdout: '' } }, + buildContext({ resolvedSecretTraceRegistry: registry }) + ) + + expect(result.success).toBe(true) + expect(mockWriteWorkspaceFileByPath).toHaveBeenCalledWith( + expect.objectContaining({ + buffer: Buffer.from('__var_API_KEY'), + secretProvenance: { + status: 'exact', + entries: [ + { + name: 'API_KEY', + encryptedValue: 'encrypted-api-key', + sourceUserId: 'user-1', + sourceWorkspaceId: 'workspace-1', + }, + ], + }, + }) + ) + }) + + it('preserves anonymous provenance without inventing a secret name', async () => { + const registry = { + exportCommittedProvenanceForValue: vi.fn().mockReturnValue({ + version: 1, + complete: true, + entries: [{ encryptedValue: 'encrypted-anonymous' }], + scope: { userId: 'user-1', workspaceId: 'workspace-1' }, + }), + } as unknown as ResolvedSecretTraceRegistry + + const result = await maybeWriteOutputToFile( + FunctionExecute.id, + { outputs: { files: [{ path: 'files/report.txt', mode: 'overwrite' }] } }, + { success: true, output: { result: 'anonymous-secret', stdout: '' } }, + buildContext({ resolvedSecretTraceRegistry: registry }) + ) + + expect(result.success).toBe(true) + expect(mockWriteWorkspaceFileByPath).toHaveBeenCalledWith( + expect.objectContaining({ + secretProvenance: { + status: 'exact', + entries: [ + { + encryptedValue: 'encrypted-anonymous', + sourceUserId: 'user-1', + sourceWorkspaceId: 'workspace-1', + }, + ], + }, + }) + ) + }) + + it('preserves the secret source when a different actor writes within the same workspace', async () => { + const registry = new ResolvedSecretTraceRegistry( + [ + { + name: 'OUTPUT_SECRET', + plaintext: 'secret-value', + encryptedValue: 'encrypted-output-secret', + }, + ], + { userId: 'workflow-owner', workspaceId: 'workspace-1' } + ) + registry.recordResolved('OUTPUT_SECRET', 'secret-value') + + const result = await maybeWriteOutputToFile( + FunctionExecute.id, + { outputs: { files: [{ path: 'files/report.txt', mode: 'overwrite' }] } }, + { success: true, output: { result: 'secret-value', stdout: '' } }, + buildContext({ userId: 'billing-actor', resolvedSecretTraceRegistry: registry }) + ) + + expect(result.success).toBe(true) + expect(mockWriteWorkspaceFileByPath).toHaveBeenCalledWith( + expect.objectContaining({ + userId: 'billing-actor', + secretProvenance: { + status: 'exact', + entries: [ + { + name: 'OUTPUT_SECRET', + encryptedValue: 'encrypted-output-secret', + sourceUserId: 'workflow-owner', + sourceWorkspaceId: 'workspace-1', + }, + ], + }, + }) + ) + }) + + it('writes raw output with unknown provenance when the source scope differs', async () => { + const registry = new ResolvedSecretTraceRegistry( + [ + { + name: 'OUTPUT_SECRET', + plaintext: 'secret-value', + encryptedValue: 'encrypted-output-secret', + }, + ], + { userId: 'workflow-owner', workspaceId: 'workspace-2' } + ) + registry.recordResolved('OUTPUT_SECRET', 'secret-value') + + const result = await maybeWriteOutputToFile( + FunctionExecute.id, + { outputs: { files: [{ path: 'files/report.txt', mode: 'overwrite' }] } }, + { success: true, output: { result: 'secret-value', stdout: '' } }, + buildContext({ userId: 'billing-actor', resolvedSecretTraceRegistry: registry }) + ) + + expect(result.success).toBe(true) + expect(mockWriteWorkspaceFileByPath).toHaveBeenCalledWith( + expect.objectContaining({ secretProvenance: { status: 'unknown' } }) + ) + }) + + it('prepares every file provenance and marks unavailable lineage unknown', async () => { + const exportCommittedProvenanceForValue = vi + .fn() + .mockReturnValueOnce({ version: 1, complete: true, entries: [] }) + .mockReturnValueOnce({ version: 1, complete: false, entries: [] }) + const registry = { + exportCommittedProvenanceForValue, + } as unknown as ResolvedSecretTraceRegistry + + const result = await maybeWriteOutputToFile( + FunctionExecute.id, + { + outputs: { + files: [ + { path: 'files/report.json', mode: 'overwrite' }, + { path: 'files/report.txt', mode: 'overwrite' }, + ], + }, + }, + { success: true, output: { result: { token: 'value' }, stdout: '' } }, + buildContext({ resolvedSecretTraceRegistry: registry }) + ) + + expect(result.success).toBe(true) + expect(exportCommittedProvenanceForValue).toHaveBeenCalledTimes(2) + expect(mockWriteWorkspaceFileByPath).toHaveBeenCalledTimes(2) + expect(mockWriteWorkspaceFileByPath.mock.calls[1]?.[0]).toEqual( + expect.objectContaining({ secretProvenance: { status: 'unknown' } }) + ) + }) + + it('preserves legacy writes without a registry and marks their provenance unknown', async () => { const result = await maybeWriteOutputToFile( FunctionExecute.id, { outputs: { files: [{ path: 'files/report.json', mode: 'overwrite' }] } }, @@ -222,11 +578,10 @@ describe('maybeWriteOutputToFile', () => { buildContext({ resolvedSecretTraceRegistry: undefined }) ) - expect(result).toEqual({ - success: false, - error: 'Tool output could not be persisted safely because secret provenance was unavailable.', - }) - expect(mockWriteWorkspaceFileByPath).not.toHaveBeenCalled() + expect(result.success).toBe(true) + expect(mockWriteWorkspaceFileByPath).toHaveBeenCalledWith( + expect.objectContaining({ secretProvenance: { status: 'unknown' } }) + ) }) it('fails loudly instead of silently skipping declared outputs when workspace context is missing', async () => { diff --git a/apps/sim/lib/copilot/request/tools/files.ts b/apps/sim/lib/copilot/request/tools/files.ts index 43ffdcc9c27..7f4b9ca55ec 100644 --- a/apps/sim/lib/copilot/request/tools/files.ts +++ b/apps/sim/lib/copilot/request/tools/files.ts @@ -7,15 +7,24 @@ import { TraceEvent } from '@/lib/copilot/generated/trace-events-v1' import { TraceSpan } from '@/lib/copilot/generated/trace-spans-v1' import { withCopilotSpan } from '@/lib/copilot/request/otel' import { denyOutputWriteWithoutWritePermission } from '@/lib/copilot/request/tools/permissions' -import { - projectToolErrorMessageForCopilot, - projectToolOutputForPersistence, -} from '@/lib/copilot/request/tools/resolved-secret-result' +import { projectToolErrorMessageForCopilot } from '@/lib/copilot/request/tools/resolved-secret-result' import type { ExecutionContext, ToolCallResult } from '@/lib/copilot/request/types' import { decodeVfsPathSegments } from '@/lib/copilot/vfs/path-utils' import { writeWorkspaceFileByPath } from '@/lib/copilot/vfs/resource-writer' +import { + createWorkspaceFileSecretProvenanceFromRegistry, + type WorkspaceFileSecretProvenance, + type WorkspaceFileSecretProvenanceRepresentation, +} from '@/lib/uploads/contexts/workspace/workspace-file-secret-provenance' +import type { ResolvedSecretMatcher } from '@/executor/utils/resolved-secret-matcher' +import { + createResolvedSecretMatcher, + scanResolvedSecretString, +} from '@/executor/utils/resolved-secret-matcher' +import type { ResolvedSecretTraceRegistry } from '@/executor/utils/resolved-secret-trace-registry' const logger = createLogger('CopilotToolResultFiles') +const MAX_OUTPUT_FILE_PROVENANCE_REPRESENTATIONS = 10_000 export const OUTPUT_PATH_TOOLS: Set = new Set([FunctionExecute.id, UserTable.id]) @@ -99,21 +108,7 @@ export function escapeCsvValue(value: unknown): string { } export function convertRowsToCsv(rows: Record[]): string { - if (rows.length === 0) return '' - - const headerSet = new Set() - for (const row of rows) { - for (const key of Object.keys(row)) { - headerSet.add(key) - } - } - const headers = [...headerSet] - - const lines = [headers.map(escapeCsvValue).join(',')] - for (const row of rows) { - lines.push(headers.map((h) => escapeCsvValue(row[h])).join(',')) - } - return lines.join('\n') + return convertRowsToCsvWithProvenance(rows).content } export function normalizeOutputWorkspaceFileName(outputPath: string): string { @@ -131,19 +126,149 @@ export function resolveOutputFormat(fileName: string, explicit?: string): Output return EXT_TO_FORMAT[ext] ?? 'json' } -export function serializeOutputForFile(output: unknown, format: OutputFormat): string { +interface SerializedOutputFile { + content: string + provenanceValue: unknown + provenanceRepresentations?: readonly WorkspaceFileSecretProvenanceRepresentation[] + provenanceRepresentationsComplete?: boolean +} + +function convertRowsToCsvWithProvenance( + rows: Record[], + registry?: ResolvedSecretTraceRegistry +): { + content: string + representations: readonly WorkspaceFileSecretProvenanceRepresentation[] + representationSourceValues: readonly string[] + representationsComplete: boolean +} { + if (rows.length === 0) { + return { + content: '', + representations: [], + representationSourceValues: [], + representationsComplete: true, + } + } + + const headerSet = new Set() + for (const row of rows) { + for (const key of Object.keys(row)) { + headerSet.add(key) + } + } + const headers = [...headerSet] + const representations = new Map< + string, + { + representation: WorkspaceFileSecretProvenanceRepresentation + sourceValue: string + } + >() + let representationsComplete = true + let csvQuoteTransformMatcher: ResolvedSecretMatcher | undefined + if (registry) { + try { + const scanLiterals = new Set() + for (const { plaintext } of registry.getActiveMatches()) { + const jsonEncoded = JSON.stringify(plaintext).slice(1, -1) + if (plaintext.includes('"')) scanLiterals.add(plaintext) + if (jsonEncoded.includes('"')) scanLiterals.add(jsonEncoded) + } + csvQuoteTransformMatcher = createResolvedSecretMatcher( + [...scanLiterals].map((plaintext) => ({ plaintext, replacement: '' })) + ) + } catch { + representationsComplete = false + } + } + const serializeCell = (sourceValue: unknown): string => { + const persistedValue = escapeCsvValue(sourceValue) + const serializedSource = + sourceValue === null || sourceValue === undefined + ? '' + : typeof sourceValue === 'object' + ? JSON.stringify(sourceValue) + : String(sourceValue) + if ( + registry && + csvQuoteTransformMatcher && + representationsComplete && + serializedSource.includes('"') + ) { + try { + scanResolvedSecretString(serializedSource, csvQuoteTransformMatcher, (scanLiteral) => { + if (!representationsComplete) return + const transformedLiteral = scanLiteral.replace(/"/g, '""') + const sourceProvenance = registry.exportCommittedProvenanceForValue(scanLiteral) + if (!sourceProvenance.complete) { + representationsComplete = false + return + } + if (sourceProvenance.entries.length === 0) return + const representationKey = `${transformedLiteral}\u0000${sourceProvenance.entries + .map((entry) => `${entry.name ?? ''}\u0000${entry.encryptedValue}`) + .join('\u0001')}` + if (representations.has(representationKey)) return + if (representations.size >= MAX_OUTPUT_FILE_PROVENANCE_REPRESENTATIONS) { + representationsComplete = false + return + } + representations.set(representationKey, { + representation: { sourceProvenance, persistedValue: transformedLiteral }, + sourceValue: scanLiteral, + }) + }) + } catch { + representationsComplete = false + } + } + return persistedValue + } + + const lines = [headers.map(serializeCell).join(',')] + for (const row of rows) { + lines.push(headers.map((header) => serializeCell(row[header])).join(',')) + } + return { + content: lines.join('\n'), + representations: [...representations.values()].map(({ representation }) => representation), + representationSourceValues: [...representations.values()].map(({ sourceValue }) => sourceValue), + representationsComplete, + } +} + +function prepareOutputForFile( + output: unknown, + format: OutputFormat, + registry?: ResolvedSecretTraceRegistry +): SerializedOutputFile { const unwrapped = unwrapFunctionExecuteOutput(output) - if (typeof unwrapped === 'string') return unwrapped + if (typeof unwrapped === 'string') { + return { content: unwrapped, provenanceValue: unwrapped } + } if (format === 'csv') { const rows = extractTabularData(unwrapped) if (rows && rows.length > 0) { - return convertRowsToCsv(rows) + const { content, representations, representationSourceValues, representationsComplete } = + convertRowsToCsvWithProvenance(rows, registry) + return { + content, + provenanceValue: [content, ...representationSourceValues], + provenanceRepresentations: representations, + provenanceRepresentationsComplete: representationsComplete, + } } } - return JSON.stringify(unwrapped, null, 2) + const content = JSON.stringify(unwrapped, null, 2) + return { content, provenanceValue: content } +} + +export function serializeOutputForFile(output: unknown, format: OutputFormat): string { + return prepareOutputForFile(output, format).content } export interface OutputFileDeclaration { @@ -213,7 +338,6 @@ export async function maybeWriteOutputToFile( const outputFiles = getOutputFileDeclarations(params).filter((file) => !file.sandboxPath) if (outputFiles.length === 0) return result - // The tool declared workspace file outputs; passing the successful result // through without writing them would be a silent no-op the model reads as // "file written", so fail loudly instead — but keep the computed output so @@ -230,6 +354,7 @@ export async function maybeWriteOutputToFile( output: result.output, } } + const { userId, workspaceId } = context const outputObject = result.output && typeof result.output === 'object' && !Array.isArray(result.output) @@ -252,13 +377,7 @@ export async function maybeWriteOutputToFile( const denied = denyOutputWriteWithoutWritePermission(context) if (denied) return denied - const persistedOutput = projectToolOutputForPersistence( - unwrapFunctionExecuteOutput(result.output), - context.resolvedSecretTraceRegistry - ) - if (!persistedOutput.safe) { - return { success: false, error: persistedOutput.error } - } + const registry = context.resolvedSecretTraceRegistry // Only span the actual write path (where we upload to storage). Fast // no-op returns above don't need a span — they'd just pad the trace @@ -267,27 +386,61 @@ export async function maybeWriteOutputToFile( TraceSpan.CopilotToolsWriteOutputFile, { [TraceAttr.ToolName]: toolName, - [TraceAttr.WorkspaceId]: context.workspaceId, + [TraceAttr.WorkspaceId]: workspaceId, }, async (span) => { try { - const writtenFiles = [] + const preparedByFormat = new Map< + OutputFormat, + Promise<{ + buffer: Buffer + secretProvenance: WorkspaceFileSecretProvenance + }> + >() + const preparedFiles = [] for (const outputFile of outputFiles) { const fileName = normalizeOutputWorkspaceFileName( outputFile.formatPath ?? outputFile.path ) const format = resolveOutputFormat(fileName, outputFile.format) - const content = serializeOutputForFile(persistedOutput.value, format) const contentType = outputFile.mimeType || FORMAT_TO_CONTENT_TYPE[format] - const buffer = Buffer.from(content, 'utf-8') + let prepared = preparedByFormat.get(format) + if (!prepared) { + prepared = (async () => { + const { + content, + provenanceValue, + provenanceRepresentations, + provenanceRepresentationsComplete, + } = prepareOutputForFile(result.output, format, registry) + const decision = await createWorkspaceFileSecretProvenanceFromRegistry( + registry, + content, + { userId, workspaceId }, + provenanceValue, + provenanceRepresentations, + provenanceRepresentationsComplete + ) + return { + buffer: Buffer.from(content, 'utf-8'), + secretProvenance: decision.safe ? decision.provenance : { status: 'unknown' }, + } + })() + preparedByFormat.set(format, prepared) + } + const { buffer, secretProvenance } = await prepared + preparedFiles.push({ outputFile, format, contentType, buffer, secretProvenance }) + } + const writtenFiles = [] + for (const { outputFile, format, contentType, buffer, secretProvenance } of preparedFiles) { if (context.abortSignal?.aborted) { throw new Error('Request aborted before tool mutation could be applied') } const written = await writeWorkspaceFileByPath({ - workspaceId: context.workspaceId!, - userId: context.userId!, + workspaceId, + userId, target: { path: outputFile.path, mode: outputFile.mode ?? 'create', @@ -295,6 +448,7 @@ export async function maybeWriteOutputToFile( }, buffer, inferredMimeType: contentType, + secretProvenance, }) writtenFiles.push({ ...written, diff --git a/apps/sim/lib/copilot/request/tools/resolved-secret-result.test.ts b/apps/sim/lib/copilot/request/tools/resolved-secret-result.test.ts index bd58ff50ce3..8b49d87fe65 100644 --- a/apps/sim/lib/copilot/request/tools/resolved-secret-result.test.ts +++ b/apps/sim/lib/copilot/request/tools/resolved-secret-result.test.ts @@ -24,7 +24,7 @@ describe('projectToolResultForCopilot', () => { 'projects active exact and embedded secrets for %s without mutating runtime output', (toolName) => { const registry = createRegistry() - registry.recordResolved('SECRET', 'secret-value') + registry.recordResolved('SECRET', 'secret-value', { propagated: true }) const runtimeResult = { success: true, output: { @@ -51,7 +51,7 @@ describe('projectToolResultForCopilot', () => { const registry = new ResolvedSecretTraceRegistry([ { name: 'Test', plaintext: 'Test', encryptedValue: 'ciphertext' }, ]) - registry.recordResolved('Test', 'Test') + registry.recordResolved('Test', 'Test', { propagated: true }) const runtimeResult = { success: true, output: { @@ -81,7 +81,7 @@ describe('projectToolResultForCopilot', () => { it('projects both output and error from a failed Function execution', () => { const registry = createRegistry() - registry.recordResolved('SECRET', 'secret-value') + registry.recordResolved('SECRET', 'secret-value', { propagated: true }) expect( projectToolResultForCopilot( @@ -99,9 +99,9 @@ describe('projectToolResultForCopilot', () => { }) }) - it('projects secret-bearing object keys and omits content when replacement collides', () => { + it('projects the model-only copy without mutating raw structural object keys', () => { const registry = createRegistry() - registry.recordResolved('SECRET', 'secret-value') + registry.recordResolved('SECRET', 'secret-value', { propagated: true }) expect( projectToolResultForCopilot( @@ -116,6 +116,13 @@ describe('projectToolResultForCopilot', () => { output: { 'prefix-{{SECRET}}': 'safe' }, }) + const raw = { + success: true, + output: { 'prefix-secret-value': 'safe' }, + } + projectToolResultForCopilot(raw, registry) + expect(raw.output).toEqual({ 'prefix-secret-value': 'safe' }) + expect( projectToolResultForCopilot( { @@ -133,9 +140,9 @@ describe('projectToolResultForCopilot', () => { { name: 'BRACE', plaintext: '{', encryptedValue: 'encrypted-brace' }, { name: 'JOINED', plaintext: 'ac', encryptedValue: 'encrypted-ac' }, ]) - registry.recordResolved('MIDDLE', 'B') - registry.recordResolved('BRACE', '{') - registry.recordResolved('JOINED', 'ac') + registry.recordResolved('MIDDLE', 'B', { propagated: true }) + registry.recordResolved('BRACE', '{', { propagated: true }) + registry.recordResolved('JOINED', 'ac', { propagated: true }) expect(projectToolResultForCopilot({ success: true, output: 'aBc' }, registry)).toEqual({ success: true, @@ -147,7 +154,7 @@ describe('projectToolResultForCopilot', () => { const registry = new ResolvedSecretTraceRegistry([ { name: 'F_SECRET', plaintext: 'F', encryptedValue: 'encrypted-f' }, ]) - registry.recordResolved('F_SECRET', 'F') + registry.recordResolved('F_SECRET', 'F', { propagated: true }) const projected = projectToolResultForCopilot( { @@ -165,9 +172,21 @@ describe('projectToolResultForCopilot', () => { }) }) + it('emits the fixed missing-error message without projecting it as runtime content', () => { + const registry = new ResolvedSecretTraceRegistry([ + { name: 'T_SECRET', plaintext: 'T', encryptedValue: 'encrypted-t' }, + ]) + registry.recordResolved('T_SECRET', 'T', { propagated: true }) + + expect(projectToolResultForCopilot({ success: false }, registry)).toEqual({ + success: false, + error: TOOL_RESULT_UNAVAILABLE_ERROR, + }) + }) + it('does not project transformed values', () => { const registry = createRegistry() - registry.recordResolved('SECRET', 'secret-value') + registry.recordResolved('SECRET', 'secret-value', { propagated: true }) const encoded = Buffer.from('secret-value').toString('base64') expect( @@ -181,9 +200,9 @@ describe('projectToolResultForCopilot', () => { { name: 'BOOLEAN', plaintext: 'true', encryptedValue: 'boolean-ciphertext' }, { name: 'NULL', plaintext: 'null', encryptedValue: 'null-ciphertext' }, ]) - registry.recordResolved('NUMBER', '123') - registry.recordResolved('BOOLEAN', 'true') - registry.recordResolved('NULL', 'null') + registry.recordResolved('NUMBER', '123', { propagated: true }) + registry.recordResolved('BOOLEAN', 'true', { propagated: true }) + registry.recordResolved('NULL', 'null', { propagated: true }) expect( projectToolResultForCopilot( @@ -301,9 +320,9 @@ describe('projectToolResultForCopilot', () => { ).toEqual({ success: false, error: TOOL_RESULT_UNAVAILABLE_ERROR }) }) - it('projects Copilot-visible resource metadata without changing the runtime result', () => { + it('leaves resource metadata outside plaintext result projection', () => { const registry = createRegistry() - registry.recordResolved('SECRET', 'secret-value') + registry.recordResolved('SECRET', 'secret-value', { propagated: true }) const result = { success: true, resources: [ @@ -322,7 +341,7 @@ describe('projectToolResultForCopilot', () => { { type: 'file', id: 'file-1', - title: '{{SECRET}}.txt', + title: 'secret-value.txt', path: '/workspace/report.txt', }, ], @@ -343,9 +362,9 @@ describe('projectToolResultForCopilot', () => { title: 'report.txt', path: '/workspace/secret-value/report.txt', }, - ])('omits resources whose routing controls contain a secret', (resource) => { + ])('leaves resource routing controls outside plaintext result projection', (resource) => { const registry = createRegistry() - registry.recordResolved('SECRET', 'secret-value') + registry.recordResolved('SECRET', 'secret-value', { propagated: true }) const projected = projectToolResultForCopilot( { success: true, @@ -355,18 +374,17 @@ describe('projectToolResultForCopilot', () => { registry ) - expect(projected).toEqual({ success: true, output: {}, resources: [] }) - expect(JSON.stringify(projected)).not.toContain('secret-value') + expect(projected).toEqual({ success: true, output: {}, resources: [resource] }) }) - it('projects every tool result once provenance is active', () => { + it('does not project a tool result from merely active input provenance', () => { const registry = createRegistry() registry.recordResolved('SECRET', 'secret-value') const result = { success: true, output: 'secret-value' } expect(projectToolResultForCopilot(result, registry)).toEqual({ success: true, - output: '{{SECRET}}', + output: 'secret-value', }) expect(result).toEqual({ success: true, output: 'secret-value' }) }) diff --git a/apps/sim/lib/copilot/request/tools/resolved-secret-result.ts b/apps/sim/lib/copilot/request/tools/resolved-secret-result.ts index 65750abb89d..fdd4584fd1a 100644 --- a/apps/sim/lib/copilot/request/tools/resolved-secret-result.ts +++ b/apps/sim/lib/copilot/request/tools/resolved-secret-result.ts @@ -1,113 +1,23 @@ -import { isPlainRecord } from '@sim/utils/object' -import type { MothershipResource } from '@/lib/copilot/resources/types' import type { ToolExecutionResult } from '@/lib/copilot/tool-executor/types' -import { - isResolvedSecretModelContentUnchanged, - projectResolvedSecretModelContent, - projectResolvedSecretModelControlMessage, - projectResolvedSecretModelJsonContent, -} from '@/executor/utils/resolved-secret-content-projection' +import { projectResolvedSecretModelJsonContent } from '@/executor/utils/resolved-secret-content-projection' import type { ResolvedSecretTraceRegistry } from '@/executor/utils/resolved-secret-trace-registry' export const TOOL_RESULT_UNAVAILABLE_ERROR = 'Tool execution settled, but its result could not be returned safely. Do not retry a mutation automatically.' -export const TOOL_OUTPUT_PERSISTENCE_UNAVAILABLE_ERROR = - 'Tool output could not be persisted safely because secret provenance was unavailable.' function structuralResult(result: ToolExecutionResult): ToolExecutionResult { return { success: result.success === true } } -function resourceContent( - resources: MothershipResource[] -): Array<{ type: string; id: string; title: string; path?: string }> { - return resources.map((resource) => ({ - type: resource.type, - id: resource.id, - title: resource.title, - ...(resource.path !== undefined ? { path: resource.path } : {}), - })) -} - -function modelSafeResources( - resources: MothershipResource[], - registry: ResolvedSecretTraceRegistry | undefined -): MothershipResource[] { - return resources.filter((resource) => - isResolvedSecretModelContentUnchanged([resource.type, resource.id, resource.path], registry) - ) -} - -function restoreProjectedResources( - resources: MothershipResource[], - projectedContent: unknown -): MothershipResource[] | undefined { - if (!Array.isArray(projectedContent) || projectedContent.length !== resources.length) { - return undefined - } - - const projectedResources: MothershipResource[] = [] - for (let index = 0; index < resources.length; index += 1) { - const resource = resources[index] - const content = projectedContent[index] - if ( - !isPlainRecord(content) || - typeof content.type !== 'string' || - typeof content.id !== 'string' || - typeof content.title !== 'string' || - (content.path !== undefined && typeof content.path !== 'string') - ) { - return undefined - } - if (content.type !== resource.type || content.id !== resource.id) continue - - projectedResources.push({ - type: resource.type, - id: resource.id, - title: content.title, - ...(content.path !== undefined ? { path: content.path } : {}), - }) - } - - return projectedResources -} - -function omittedResult( - result: ToolExecutionResult, - registry: ResolvedSecretTraceRegistry | undefined -): ToolExecutionResult { +function omittedResult(result: ToolExecutionResult): ToolExecutionResult { if (result.success) return { success: true } - - const error = - projectResolvedSecretModelControlMessage(TOOL_RESULT_UNAVAILABLE_ERROR, registry) ?? - TOOL_RESULT_UNAVAILABLE_ERROR - return { success: false, error } + return { success: false, error: TOOL_RESULT_UNAVAILABLE_ERROR } } export type CopilotToolResultProjection = | { safe: true; result: ToolExecutionResult } | { safe: false; result: ToolExecutionResult } -export type CopilotPersistedOutputProjection = - | { safe: true; value: unknown } - | { safe: false; error: string } - -/** - * Projects only the exact value a Copilot tool is about to persist. The isolated per-tool registry - * makes this causal: values activated by this tool become canonical `{{NAME}}` aliases, while a - * public low-entropy value cannot be rewritten merely because an earlier sibling activated the - * same bytes. Persisting the alias makes later reads safe without a second provenance store. - */ -export function projectToolOutputForPersistence( - value: unknown, - registry: ResolvedSecretTraceRegistry | undefined -): CopilotPersistedOutputProjection { - const projection = projectResolvedSecretModelContent(value, registry) - return projection.safe - ? { safe: true, value: projection.value } - : { safe: false, error: TOOL_OUTPUT_PERSISTENCE_UNAVAILABLE_ERROR } -} - /** * Projects terminal tool content and reports whether the complete content was safe to cross. * Callers that isolate provenance per tool call may merge that child registry only when `safe` @@ -119,15 +29,14 @@ export function inspectToolResultForCopilot( registry: ResolvedSecretTraceRegistry | undefined ): CopilotToolResultProjection { try { + const resultRegistry = registry?.forkForPropagatedEntries() const content: Record = {} - const resources = - result.resources !== undefined ? modelSafeResources(result.resources, registry) : undefined + const resources = result.resources if (Object.hasOwn(result, 'output')) content.output = result.output if (Object.hasOwn(result, 'error')) content.error = result.error - if (resources !== undefined) content.resources = resourceContent(resources) - const projection = projectResolvedSecretModelJsonContent(content, registry) + const projection = projectResolvedSecretModelJsonContent(content, resultRegistry) if (!projection.safe || !projection.value || typeof projection.value !== 'object') { - return { safe: false, result: omittedResult(result, registry) } + return { safe: false, result: omittedResult(result) } } const projectedContent = projection.value as Record @@ -135,26 +44,19 @@ export function inspectToolResultForCopilot( if (Object.hasOwn(projectedContent, 'output')) projected.output = projectedContent.output if (Object.hasOwn(projectedContent, 'error')) { if (typeof projectedContent.error !== 'string') { - return { safe: false, result: omittedResult(result, registry) } + return { safe: false, result: omittedResult(result) } } projected.error = projectedContent.error } if (resources !== undefined) { - const projectedResources = restoreProjectedResources(resources, projectedContent.resources) - if (!projectedResources) { - return { safe: false, result: omittedResult(result, registry) } - } - projected.resources = projectedResources + projected.resources = resources } if (!projected.success && !projected.error) { - projected.error = projectResolvedSecretModelControlMessage( - TOOL_RESULT_UNAVAILABLE_ERROR, - registry - ) + projected.error = TOOL_RESULT_UNAVAILABLE_ERROR } return { safe: true, result: projected } } catch { - return { safe: false, result: omittedResult(result, registry) } + return { safe: false, result: omittedResult(result) } } } diff --git a/apps/sim/lib/copilot/request/tools/tables.test.ts b/apps/sim/lib/copilot/request/tools/tables.test.ts index 55ad05c06e7..f75dbdde3f6 100644 --- a/apps/sim/lib/copilot/request/tools/tables.test.ts +++ b/apps/sim/lib/copilot/request/tools/tables.test.ts @@ -2,7 +2,7 @@ * @vitest-environment node */ -import { loggerMock } from '@sim/testing' +import { encryptionMock, encryptionMockFns, loggerMock } from '@sim/testing' import { beforeEach, describe, expect, it, vi } from 'vitest' import type { TableDefinition } from '@/lib/table' @@ -20,6 +20,8 @@ vi.mock('@/lib/table/rows/service', () => ({ replaceTableRows: mockReplaceTableRows, })) +vi.mock('@/lib/core/security/encryption', () => encryptionMock) + vi.mock('@/lib/copilot/request/otel', () => ({ withCopilotSpan: ( _name: string, @@ -147,22 +149,25 @@ describe('maybeWriteOutputToTable', () => { expect(table.id).toBe('tbl_1') }) - it('projects activated secrets before persistence without rewriting sibling literals', async () => { - const parentRegistry = new ResolvedSecretTraceRegistry([ - { - name: 'OUTPUT_SECRET', - plaintext: 'secret-value', - encryptedValue: 'encrypted-output-secret', - }, - { - name: 'UNRELATED', - plaintext: 'true', - encryptedValue: 'encrypted-unrelated', - }, - ]) + it('persists raw values with per-cell provenance without rewriting sibling literals', async () => { + const parentRegistry = new ResolvedSecretTraceRegistry( + [ + { + name: 'OUTPUT_SECRET', + plaintext: 'secret-value', + encryptedValue: 'encrypted-output-secret', + }, + { + name: 'UNRELATED', + plaintext: 'true', + encryptedValue: 'encrypted-unrelated', + }, + ], + { userId: 'user-1', workspaceId: 'workspace-1' } + ) parentRegistry.recordResolved('UNRELATED', 'true') - const toolRegistry = parentRegistry.forkForToolInput({ code: 'return {{OUTPUT_SECRET}}' }) - toolRegistry.recordResolved('OUTPUT_SECRET', 'secret-value') + const toolRegistry = parentRegistry.forkForInputPaths([]) + toolRegistry.recordResolved('OUTPUT_SECRET', 'secret-value', { propagated: true }) const runtimeRows = [{ name: 'secret-value', age: '123', status: 'true' }] const result = await maybeWriteOutputToTable( @@ -173,9 +178,35 @@ describe('maybeWriteOutputToTable', () => { ) expect(result.success).toBe(true) - const persistedRows = mockReplaceTableRows.mock.calls[0][0].rows + const persistedWrite = mockReplaceTableRows.mock.calls[0][0] + const persistedRows = persistedWrite.rows expect(persistedRows).toEqual([ - { col_name: '{{OUTPUT_SECRET}}', col_age: '123', col_status: 'true' }, + { col_name: 'secret-value', col_age: '123', col_status: 'true' }, + ]) + expect(persistedWrite.secretProvenance).toEqual([ + { + complete: true, + columns: { + col_name: { + version: 1, + complete: true, + entries: [{ name: 'OUTPUT_SECRET', encryptedValue: 'encrypted-output-secret' }], + scope: { userId: 'user-1', workspaceId: 'workspace-1' }, + }, + col_age: { + version: 1, + complete: true, + entries: [], + scope: { userId: 'user-1', workspaceId: 'workspace-1' }, + }, + col_status: { + version: 1, + complete: true, + entries: [], + scope: { userId: 'user-1', workspaceId: 'workspace-1' }, + }, + }, + }, ]) expect(runtimeRows).toEqual([{ name: 'secret-value', age: '123', status: 'true' }]) @@ -189,14 +220,164 @@ describe('maybeWriteOutputToTable', () => { }, }) + encryptionMockFns.mockDecryptSecret.mockResolvedValue({ decrypted: 'secret-value' }) + const readRegistry = new ResolvedSecretTraceRegistry([], { + userId: 'user-1', + workspaceId: 'workspace-1', + }) + expect( + await readRegistry.importCrossingProvenance( + persistedWrite.secretProvenance[0].columns.col_name, + persistedRows, + { trusted: true } + ) + ).toBe(true) const laterRead = projectToolResultForCopilot( { success: true, output: { data: { rows: persistedRows } } }, - new ResolvedSecretTraceRegistry() + readRegistry + ) + expect(laterRead.output).toEqual({ + data: { + rows: [{ col_name: '{{OUTPUT_SECRET}}', col_age: '123', col_status: 'true' }], + }, + }) + }) + + it('never rewrites stored public text when an active secret has a common value', async () => { + const registry = new ResolvedSecretTraceRegistry( + [{ name: 'SHORT_SECRET', plaintext: 'x', encryptedValue: 'encrypted-short-secret' }], + { userId: 'user-1', workspaceId: 'workspace-1' } + ) + registry.recordResolved('SHORT_SECRET', 'x') + const rows = [{ name: 'Box eSign' }, { name: 'Brex' }, { name: 'hex' }] + + const result = await maybeWriteOutputToTable( + FunctionExecute.id, + { outputTable: 'tbl_1' }, + { success: true, output: { result: rows } }, + buildContext({ resolvedSecretTraceRegistry: registry }) + ) + + expect(result.success).toBe(true) + expect(mockReplaceTableRows.mock.calls[0][0].rows).toEqual([ + { col_name: 'Box eSign' }, + { col_name: 'Brex' }, + { col_name: 'hex' }, + ]) + }) + + it('binds provenance to the values produced by table coercion', async () => { + const registry = new ResolvedSecretTraceRegistry( + [ + { name: 'NUMBER_SECRET', plaintext: '123', encryptedValue: 'encrypted-number' }, + { name: 'INVALID_SECRET', plaintext: 'not-a-number', encryptedValue: 'encrypted-invalid' }, + ], + { userId: 'user-1', workspaceId: 'workspace-1' } + ) + registry.recordResolved('NUMBER_SECRET', '123') + registry.recordResolved('INVALID_SECRET', 'not-a-number') + + const result = await maybeWriteOutputToTable( + FunctionExecute.id, + { outputTable: 'tbl_1' }, + { + success: true, + output: { result: [{ age: '123' }, { age: 'not-a-number' }] }, + }, + buildContext({ resolvedSecretTraceRegistry: registry }) + ) + + expect(result.success).toBe(true) + expect(mockReplaceTableRows.mock.calls[0][0].secretProvenance).toEqual([ + { + complete: true, + columns: { + col_age: { + version: 1, + complete: true, + entries: [{ name: 'NUMBER_SECRET', encryptedValue: 'encrypted-number' }], + scope: { userId: 'user-1', workspaceId: 'workspace-1' }, + }, + }, + }, + { + complete: true, + columns: { + col_age: { + version: 1, + complete: true, + entries: [], + scope: { userId: 'user-1', workspaceId: 'workspace-1' }, + }, + }, + }, + ]) + }) + + it('accepts same-workspace provenance from a different actor and preserves its source', async () => { + const registry = new ResolvedSecretTraceRegistry( + [{ name: 'OUTPUT_SECRET', plaintext: 'secret-value', encryptedValue: 'encrypted-secret' }], + { userId: 'workflow-owner', workspaceId: 'workspace-1' } + ) + registry.recordResolved('OUTPUT_SECRET', 'secret-value') + + const result = await maybeWriteOutputToTable( + FunctionExecute.id, + { outputTable: 'tbl_1' }, + { success: true, output: { result: [{ name: 'secret-value' }] } }, + buildContext({ userId: 'billing-actor', resolvedSecretTraceRegistry: registry }) + ) + + expect(result.success).toBe(true) + expect( + mockReplaceTableRows.mock.calls[0][0].secretProvenance[0].columns.col_name.scope + ).toEqual({ userId: 'workflow-owner', workspaceId: 'workspace-1' }) + }) + + it('persists raw rows with unknown provenance when the source workspace differs', async () => { + const registry = new ResolvedSecretTraceRegistry( + [{ name: 'OUTPUT_SECRET', plaintext: 'secret-value', encryptedValue: 'encrypted-secret' }], + { userId: 'user-1', workspaceId: 'workspace-2' } + ) + registry.recordResolved('OUTPUT_SECRET', 'secret-value') + + const result = await maybeWriteOutputToTable( + FunctionExecute.id, + { outputTable: 'tbl_1' }, + { success: true, output: { result: [{ name: 'secret-value' }] } }, + buildContext({ resolvedSecretTraceRegistry: registry }) + ) + + expect(result.success).toBe(true) + expect(mockReplaceTableRows).toHaveBeenCalledWith( + expect.objectContaining({ secretProvenance: [{ complete: false, columns: {} }] }), + expect.anything(), + expect.any(String) ) - expect(laterRead.output).toEqual({ data: { rows: persistedRows } }) }) - it('does not write when table persistence provenance is incomplete', async () => { + it('persists raw rows with unknown provenance when the source scope is unavailable', async () => { + const registry = new ResolvedSecretTraceRegistry([ + { name: 'OUTPUT_SECRET', plaintext: 'secret-value', encryptedValue: 'encrypted-secret' }, + ]) + registry.recordResolved('OUTPUT_SECRET', 'secret-value') + + const result = await maybeWriteOutputToTable( + FunctionExecute.id, + { outputTable: 'tbl_1' }, + { success: true, output: { result: [{ name: 'secret-value' }] } }, + buildContext({ resolvedSecretTraceRegistry: registry }) + ) + + expect(result.success).toBe(true) + expect(mockReplaceTableRows).toHaveBeenCalledWith( + expect.objectContaining({ secretProvenance: [{ complete: false, columns: {} }] }), + expect.anything(), + expect.any(String) + ) + }) + + it('persists raw rows with unknown provenance when lineage is incomplete', async () => { const registry = new ResolvedSecretTraceRegistry() registry.markIncomplete() @@ -207,14 +388,15 @@ describe('maybeWriteOutputToTable', () => { buildContext({ resolvedSecretTraceRegistry: registry }) ) - expect(result).toEqual({ - success: false, - error: 'Tool output could not be persisted safely because secret provenance was unavailable.', - }) - expect(mockReplaceTableRows).not.toHaveBeenCalled() + expect(result.success).toBe(true) + expect(mockReplaceTableRows).toHaveBeenCalledWith( + expect.objectContaining({ secretProvenance: [{ complete: false, columns: {} }] }), + expect.anything(), + expect.any(String) + ) }) - it('preserves legacy table writes when execution provenance is unavailable', async () => { + it('preserves legacy table writes without certifying unavailable provenance', async () => { const result = await maybeWriteOutputToTable( FunctionExecute.id, { outputTable: 'tbl_1' }, @@ -224,7 +406,10 @@ describe('maybeWriteOutputToTable', () => { expect(result.success).toBe(true) expect(mockReplaceTableRows).toHaveBeenCalledWith( - expect.objectContaining({ rows: [{ col_name: 'unknown' }] }), + expect.objectContaining({ + rows: [{ col_name: 'unknown' }], + secretProvenance: [{ complete: false, columns: {} }], + }), expect.anything(), expect.any(String) ) @@ -271,10 +456,11 @@ describe('maybeWriteOutputToTable', () => { }) it('keeps raw errors for terminal projection but projects application logs and OTel events', async () => { - const registry = new ResolvedSecretTraceRegistry([ - { name: 'SECRET', plaintext: 'secret-value', encryptedValue: 'encrypted-secret-value' }, - ]) - registry.recordResolved('SECRET', 'secret-value') + const registry = new ResolvedSecretTraceRegistry( + [{ name: 'SECRET', plaintext: 'secret-value', encryptedValue: 'encrypted-secret-value' }], + { userId: 'user-1', workspaceId: 'workspace-1' } + ) + registry.recordResolved('SECRET', 'secret-value', { propagated: true }) mockReplaceTableRows.mockRejectedValue(new Error('Duplicate value "secret-value"')) const result = await maybeWriteOutputToTable( @@ -343,11 +529,14 @@ describe('maybeWriteReadCsvToTable', () => { ]) }) - it('projects active secret literals into string-compatible CSV columns', async () => { - const registry = new ResolvedSecretTraceRegistry([ - { name: 'NUMBER', plaintext: '123', encryptedValue: 'encrypted-number' }, - { name: 'BOOLEAN', plaintext: 'true', encryptedValue: 'encrypted-boolean' }, - ]) + it('persists raw CSV cells with per-cell secret provenance', async () => { + const registry = new ResolvedSecretTraceRegistry( + [ + { name: 'NUMBER', plaintext: '123', encryptedValue: 'encrypted-number' }, + { name: 'BOOLEAN', plaintext: 'true', encryptedValue: 'encrypted-boolean' }, + ], + { userId: 'user-1', workspaceId: 'workspace-1' } + ) registry.recordResolved('NUMBER', '123') registry.recordResolved('BOOLEAN', 'true') @@ -363,8 +552,27 @@ describe('maybeWriteReadCsvToTable', () => { expect.objectContaining({ rows: [ { - col_name: '{{NUMBER}}', - col_status: '{{BOOLEAN}}', + col_name: '123', + col_status: 'true', + }, + ], + secretProvenance: [ + { + complete: true, + columns: { + col_name: { + version: 1, + complete: true, + entries: [{ name: 'NUMBER', encryptedValue: 'encrypted-number' }], + scope: { userId: 'user-1', workspaceId: 'workspace-1' }, + }, + col_status: { + version: 1, + complete: true, + entries: [{ name: 'BOOLEAN', encryptedValue: 'encrypted-boolean' }], + scope: { userId: 'user-1', workspaceId: 'workspace-1' }, + }, + }, }, ], }), @@ -373,32 +581,104 @@ describe('maybeWriteReadCsvToTable', () => { ) }) - it('rejects active secret literals in number and boolean columns before mutation', async () => { - const registry = new ResolvedSecretTraceRegistry([ - { name: 'NUMBER', plaintext: '123', encryptedValue: 'encrypted-number' }, - { name: 'BOOLEAN', plaintext: 'true', encryptedValue: 'encrypted-boolean' }, - ]) + it('retains original provenance after a quote-escaped CSV round trip', async () => { + encryptionMockFns.mockDecryptSecret.mockImplementation(async (encryptedValue: string) => ({ + decrypted: encryptedValue === 'encrypted-original' ? 'a"b' : '"a""b"', + })) + const registry = new ResolvedSecretTraceRegistry([], { + userId: 'user-1', + workspaceId: 'workspace-1', + }) + await expect( + registry.importProvenance( + { + version: 1, + complete: true, + entries: [ + { name: 'CSV_SECRET', encryptedValue: 'encrypted-original' }, + { name: 'CSV_SECRET', encryptedValue: 'encrypted-representation' }, + ], + scope: { userId: 'user-1', workspaceId: 'workspace-1' }, + }, + { trusted: true } + ) + ).resolves.toBe(true) + + const result = await maybeWriteReadCsvToTable( + ReadTool.id, + { outputTable: 'tbl_1', path: 'files/people.csv' }, + { success: true, output: { content: 'name\n"a""b"' } }, + buildContext({ resolvedSecretTraceRegistry: registry }) + ) + + expect(result.success).toBe(true) + expect(mockReplaceTableRows).toHaveBeenCalledWith( + expect.objectContaining({ + rows: [{ col_name: 'a"b' }], + secretProvenance: [ + { + complete: true, + columns: { + col_name: { + version: 1, + complete: true, + entries: [{ name: 'CSV_SECRET', encryptedValue: 'encrypted-original' }], + scope: { userId: 'user-1', workspaceId: 'workspace-1' }, + }, + }, + }, + ], + }), + expect.anything(), + expect.any(String) + ) + }) + + it('preserves numeric and boolean cells while recording their provenance', async () => { + const registry = new ResolvedSecretTraceRegistry( + [ + { name: 'NUMBER', plaintext: '123', encryptedValue: 'encrypted-number' }, + { name: 'BOOLEAN', plaintext: 'true', encryptedValue: 'encrypted-boolean' }, + ], + { userId: 'user-1', workspaceId: 'workspace-1' } + ) registry.recordResolved('NUMBER', '123') registry.recordResolved('BOOLEAN', 'true') const result = await maybeWriteReadCsvToTable( ReadTool.id, - { outputTable: 'tbl_1', path: 'files/people.csv' }, - { success: true, output: { content: 'name,age,active\nAlice,123,true' } }, + { outputTable: 'tbl_1', path: 'files/people.json' }, + { + success: true, + output: { content: '[{"name":"Alice","age":123,"active":true}]' }, + }, buildContext({ resolvedSecretTraceRegistry: registry }) ) - expect(result).toEqual({ - success: false, - error: - 'Tool output could not be persisted safely because a resolved secret is incompatible with the target column type.', - }) - expect(mockReplaceTableRows).not.toHaveBeenCalled() - expect(JSON.stringify(result)).not.toContain('123') - expect(JSON.stringify(result)).not.toContain('true') + expect(result.success).toBe(true) + expect(mockReplaceTableRows).toHaveBeenCalledWith( + expect.objectContaining({ + rows: [{ col_name: 'Alice', col_age: 123, col_active: true }], + secretProvenance: [ + expect.objectContaining({ + complete: true, + columns: expect.objectContaining({ + col_age: expect.objectContaining({ + entries: [{ name: 'NUMBER', encryptedValue: 'encrypted-number' }], + }), + col_active: expect.objectContaining({ + entries: [{ name: 'BOOLEAN', encryptedValue: 'encrypted-boolean' }], + }), + }), + }), + ], + }), + expect.anything(), + expect.any(String) + ) }) - it('does not import CSV rows when persistence provenance is incomplete', async () => { + it('imports raw CSV rows with unknown provenance when lineage is incomplete', async () => { const registry = new ResolvedSecretTraceRegistry() registry.markIncomplete() @@ -409,14 +689,15 @@ describe('maybeWriteReadCsvToTable', () => { buildContext({ resolvedSecretTraceRegistry: registry }) ) - expect(result).toEqual({ - success: false, - error: 'Tool output could not be persisted safely because secret provenance was unavailable.', - }) - expect(mockReplaceTableRows).not.toHaveBeenCalled() + expect(result.success).toBe(true) + expect(mockReplaceTableRows).toHaveBeenCalledWith( + expect.objectContaining({ secretProvenance: [{ complete: false, columns: {} }] }), + expect.anything(), + expect.any(String) + ) }) - it('preserves legacy CSV imports when execution provenance is unavailable', async () => { + it('preserves legacy CSV imports without certifying unavailable provenance', async () => { const result = await maybeWriteReadCsvToTable( ReadTool.id, { outputTable: 'tbl_1', path: 'files/people.csv' }, @@ -428,6 +709,7 @@ describe('maybeWriteReadCsvToTable', () => { expect(mockReplaceTableRows).toHaveBeenCalledWith( expect.objectContaining({ rows: [{ col_name: 'legacy-value', col_age: '123', col_active: 'true' }], + secretProvenance: [{ complete: false, columns: {} }], }), expect.anything(), expect.any(String) @@ -462,10 +744,11 @@ describe('maybeWriteReadCsvToTable', () => { }) it('projects active secret literals in CSV-import log and OTel errors', async () => { - const registry = new ResolvedSecretTraceRegistry([ - { name: 'SECRET', plaintext: 'secret-value', encryptedValue: 'encrypted-secret-value' }, - ]) - registry.recordResolved('SECRET', 'secret-value') + const registry = new ResolvedSecretTraceRegistry( + [{ name: 'SECRET', plaintext: 'secret-value', encryptedValue: 'encrypted-secret-value' }], + { userId: 'user-1', workspaceId: 'workspace-1' } + ) + registry.recordResolved('SECRET', 'secret-value', { propagated: true }) mockReplaceTableRows.mockRejectedValue(new Error('Duplicate value "secret-value"')) const result = await maybeWriteReadCsvToTable( diff --git a/apps/sim/lib/copilot/request/tools/tables.ts b/apps/sim/lib/copilot/request/tools/tables.ts index b8158308986..0ea82190ac2 100644 --- a/apps/sim/lib/copilot/request/tools/tables.ts +++ b/apps/sim/lib/copilot/request/tools/tables.ts @@ -1,4 +1,3 @@ -import { isDeepStrictEqual } from 'node:util' import { createLogger } from '@sim/logger' import { toError } from '@sim/utils/errors' import { generateId } from '@sim/utils/id' @@ -11,40 +10,22 @@ import { TraceEvent } from '@/lib/copilot/generated/trace-events-v1' import { TraceSpan } from '@/lib/copilot/generated/trace-spans-v1' import { withCopilotSpan } from '@/lib/copilot/request/otel' import { denyOutputWriteWithoutWritePermission } from '@/lib/copilot/request/tools/permissions' -import { - projectToolErrorMessageForCopilot, - projectToolOutputForPersistence, -} from '@/lib/copilot/request/tools/resolved-secret-result' +import { projectToolErrorMessageForCopilot } from '@/lib/copilot/request/tools/resolved-secret-result' import type { ExecutionContext, ToolCallResult } from '@/lib/copilot/request/types' +import { isPrivateSecretProvenanceScopeCompatible } from '@/lib/execution/durable-secret-provenance' import type { RowData, TableDefinition } from '@/lib/table' import { buildIdByName, rowDataNameToId } from '@/lib/table/column-keys' -import { columnTypeOf } from '@/lib/table/column-types' -import { createExactEmptyTableRowSecretProvenance } from '@/lib/table/rows/secret-provenance' +import { + createTableRowSecretProvenanceFromRegistry, + createUnknownTableRowSecretProvenance, +} from '@/lib/table/rows/secret-provenance' import { replaceTableRows } from '@/lib/table/rows/service' import { getTableById } from '@/lib/table/service' +import { coerceRowValues } from '@/lib/table/validation' const logger = createLogger('CopilotToolResultTables') const MAX_OUTPUT_TABLE_ROWS = 10_000 -const TABLE_SECRET_PROJECTION_UNSUPPORTED_ERROR = - 'Tool output could not be persisted safely because a resolved secret is incompatible with the target column type.' - -function hasUnsupportedProjectedCell( - table: TableDefinition, - sourceRows: Array>, - projectedRows: Array> -): boolean { - const columnsByName = new Map(table.schema.columns.map((column) => [column.name, column])) - for (let rowIndex = 0; rowIndex < projectedRows.length; rowIndex += 1) { - for (const [name, projectedValue] of Object.entries(projectedRows[rowIndex])) { - const column = columnsByName.get(name) - if (!column || isDeepStrictEqual(sourceRows[rowIndex]?.[name], projectedValue)) continue - const type = columnTypeOf(column).id - if (type !== 'string' && type !== 'json') return true - } - } - return false -} /** * Replaces a table's rows with wire rows keyed by column name. Translates the @@ -57,37 +38,43 @@ async function replaceTableRowsFromWire( rows: Array>, context: ExecutionContext ): Promise<{ error?: string }> { - const persistenceProjection = context.resolvedSecretTraceRegistry - ? projectToolOutputForPersistence(rows, context.resolvedSecretTraceRegistry) - : { safe: true as const, value: rows } - if (!persistenceProjection.safe) return { error: persistenceProjection.error } - if ( - !Array.isArray(persistenceProjection.value) || - !persistenceProjection.value.every(isPlainRecord) - ) { + if (!rows.every(isPlainRecord)) { return { error: 'Table rows could not be persisted safely' } } - if (hasUnsupportedProjectedCell(table, rows, persistenceProjection.value)) { - return { error: TABLE_SECRET_PROJECTION_UNSUPPORTED_ERROR } - } const idByName = buildIdByName(table.schema) - const idKeyedRows = persistenceProjection.value.map((row) => - rowDataNameToId(row as RowData, idByName) - ) + const idKeyedRows = rows.map((row) => rowDataNameToId(row as RowData, idByName)) const emptyIndex = idKeyedRows.findIndex((row) => Object.keys(row).length === 0) if (emptyIndex !== -1) { return { error: `Row ${emptyIndex + 1} has no keys matching columns on table "${table.name}" (columns: ${table.schema.columns.map((c) => c.name).join(', ')})`, } } + const registry = context.resolvedSecretTraceRegistry + const persistedRows = idKeyedRows.map((row) => { + const persistedRow = { ...row } + coerceRowValues(persistedRow, table.schema) + return persistedRow + }) + const destinationScope = { userId: context.userId, workspaceId: table.workspaceId } + const secretProvenance = persistedRows.map((row) => { + if (!registry) return createUnknownTableRowSecretProvenance() + const provenance = createTableRowSecretProvenanceFromRegistry(row, registry) + if (!provenance.complete) return createUnknownTableRowSecretProvenance() + const compatible = Object.values(provenance.columns).every( + (columnProvenance) => + columnProvenance.entries.length === 0 || + isPrivateSecretProvenanceScopeCompatible(columnProvenance.scope, destinationScope) + ) + return compatible ? provenance : createUnknownTableRowSecretProvenance() + }) await replaceTableRows( { tableId: table.id, rows: idKeyedRows, workspaceId: table.workspaceId, userId: context.userId, - secretProvenance: idKeyedRows.map(createExactEmptyTableRowSecretProvenance), + secretProvenance, }, table, generateId().slice(0, 8) diff --git a/apps/sim/lib/copilot/tool-executor/executor.test.ts b/apps/sim/lib/copilot/tool-executor/executor.test.ts index 30b5d8d4f17..ad3c423f61c 100644 --- a/apps/sim/lib/copilot/tool-executor/executor.test.ts +++ b/apps/sim/lib/copilot/tool-executor/executor.test.ts @@ -94,7 +94,7 @@ describe('copilot tool executor fallback', () => { const registry = new ResolvedSecretTraceRegistry([ { name: 'API_KEY', plaintext: secret, encryptedValue: 'encrypted-secret' }, ]) - registry.recordResolved('API_KEY', secret) + registry.recordResolved('API_KEY', secret, { propagated: true }) isKnownTool.mockReturnValue(true) isSimExecuted.mockReturnValue(true) isClientExecuted.mockReturnValue(false) diff --git a/apps/sim/lib/copilot/tools/handlers/deployment/custom-block.test.ts b/apps/sim/lib/copilot/tools/handlers/deployment/custom-block.test.ts index 54817fffbc3..b36022239ad 100644 --- a/apps/sim/lib/copilot/tools/handlers/deployment/custom-block.test.ts +++ b/apps/sim/lib/copilot/tools/handlers/deployment/custom-block.test.ts @@ -329,11 +329,14 @@ describe('executeDeployCustomBlock', () => { it('ingests a workspace-file icon into public icon storage', async () => { listWorkspaceFilesMock.mockResolvedValue([ { + id: 'file-1', + workspaceId: 'ws-1', name: 'icon.png', folderPath: null, type: 'image/png', size: 1024, key: 'workspace/ws-1/123-abc-icon.png', + storageContext: 'workspace', }, ]) fetchWorkspaceFileBufferMock.mockResolvedValue(Buffer.from('png-bytes')) diff --git a/apps/sim/lib/copilot/tools/handlers/deployment/custom-block.ts b/apps/sim/lib/copilot/tools/handlers/deployment/custom-block.ts index 8b2182b6abe..6812670da64 100644 --- a/apps/sim/lib/copilot/tools/handlers/deployment/custom-block.ts +++ b/apps/sim/lib/copilot/tools/handlers/deployment/custom-block.ts @@ -68,7 +68,6 @@ async function resolveIconUrl( if (record.size > MAX_ICON_BYTES) { throw new CustomBlockValidationError('Icon file must be 5MB or smaller') } - const buffer = await fetchWorkspaceFileBuffer(record) const safeFileName = record.name.replace(/[^a-zA-Z0-9.-]/g, '_') const uploaded = await uploadFile({ diff --git a/apps/sim/lib/copilot/tools/handlers/function-execute.test.ts b/apps/sim/lib/copilot/tools/handlers/function-execute.test.ts index 044142f0c96..1905ba77f44 100644 --- a/apps/sim/lib/copilot/tools/handlers/function-execute.test.ts +++ b/apps/sim/lib/copilot/tools/handlers/function-execute.test.ts @@ -629,6 +629,64 @@ describe('executeFunctionExecute table mounts', () => { expect(file.type).toBeUndefined() }) + it('flag ON + unknown snapshot provenance still mounts and taints model egress', async () => { + mockIsFeatureEnabled.mockImplementation(snapshotCacheOn) + mockIsTableSnapshotSafeForModelMount.mockResolvedValue(false) + mockGetOrCreateTableSnapshot.mockResolvedValue({ + key: 'table-snapshots/ws_1/tbl_1/v5.csv', + size: 9, + version: 5, + }) + mockExecuteTool.mockResolvedValue({ success: true, output: { result: 'raw output' } }) + const parentRegistry = new ResolvedSecretTraceRegistry([], { + userId: 'u1', + workspaceId: 'ws_1', + }) + + const result = await executeFunctionExecute( + { inputTables: ['tbl_1'] }, + { ...context, resolvedSecretTraceRegistry: parentRegistry } + ) + + expect(mockGeneratePresignedDownloadUrl).toHaveBeenCalled() + expect(mockExecuteTool.mock.calls[0]?.[1]?.[PRIVATE_SECRET_PROVENANCE_FIELD]).toEqual({ + version: 1, + complete: false, + selections: [], + }) + expect(result).toEqual({ success: true, output: { result: 'raw output' } }) + expect(parentRegistry.isComplete()).toBe(false) + expect(projectToolResultForCopilot(result, parentRegistry)).toEqual({ success: true }) + }) + + it('flag OFF + unknown row provenance still mounts and taints model egress', async () => { + mockLoadTableRowSecretProvenance.mockResolvedValue({ + version: 1, + complete: false, + entries: [], + }) + mockExecuteTool.mockResolvedValue({ success: true, output: { result: 'raw output' } }) + const parentRegistry = new ResolvedSecretTraceRegistry([], { + userId: 'u1', + workspaceId: 'ws_1', + }) + + const result = await executeFunctionExecute( + { inputTables: ['tbl_1'] }, + { ...context, resolvedSecretTraceRegistry: parentRegistry } + ) + + expect(mountedFiles()[0].content).toBe('name\nAda') + expect(mockExecuteTool.mock.calls[0]?.[1]?.[PRIVATE_SECRET_PROVENANCE_FIELD]).toEqual({ + version: 1, + complete: false, + selections: [], + }) + expect(result).toEqual({ success: true, output: { result: 'raw output' } }) + expect(parentRegistry.isComplete()).toBe(false) + expect(projectToolResultForCopilot(result, parentRegistry)).toEqual({ success: true }) + }) + it('flag ON but small table stays on the inline path', async () => { mockIsFeatureEnabled.mockImplementation(snapshotCacheOn) mockGetTableById.mockResolvedValue({ ...table, rowCount: 10 }) @@ -747,19 +805,32 @@ describe('executeFunctionExecute file mounts', () => { expect(file.type).toBeUndefined() }) - it('rejects unavailable file provenance before presigning, fetching, or executing', async () => { + it('mounts unavailable file provenance and taints only the model-facing result', async () => { mockImportWorkspaceFileSecretProvenanceForRuntime.mockResolvedValue(false) + mockExecuteTool.mockResolvedValue({ success: true, output: { result: 'raw output' } }) + const parentRegistry = new ResolvedSecretTraceRegistry([], { + userId: 'u1', + workspaceId: 'ws_1', + }) - await expect( - executeFunctionExecute({ inputFiles: ['files/data.csv'] }, context as never) - ).rejects.toThrow(/secret provenance is unavailable/) + const result = await executeFunctionExecute( + { inputFiles: ['files/data.csv'] }, + { ...context, resolvedSecretTraceRegistry: parentRegistry } + ) - expect(mockGeneratePresignedDownloadUrl).not.toHaveBeenCalled() + expect(mockGeneratePresignedDownloadUrl).toHaveBeenCalled() expect(mockFetchWorkspaceFileBuffer).not.toHaveBeenCalled() - expect(mockExecuteTool).not.toHaveBeenCalled() + expect(mockExecuteTool.mock.calls[0]?.[1]?.[PRIVATE_SECRET_PROVENANCE_FIELD]).toEqual({ + version: 1, + complete: false, + selections: [], + }) + expect(result).toEqual({ success: true, output: { result: 'raw output' } }) + expect(parentRegistry.isComplete()).toBe(false) + expect(projectToolResultForCopilot(result, parentRegistry)).toEqual({ success: true }) }) - it('omits the envelope when mounts it cannot attest to are already on the params', async () => { + it('preserves existing ordinary mounts while sending resolver-owned mount provenance', async () => { mockExecuteTool.mockResolvedValue({ success: true, output: { result: 'ok' } }) await executeFunctionExecute( @@ -772,7 +843,16 @@ describe('executeFunctionExecute file mounts', () => { const call = mockExecuteTool.mock.calls[0]?.[1] expect(call?._sandboxFiles?.length).toBeGreaterThan(1) - expect(call?.[PRIVATE_SECRET_PROVENANCE_FIELD]).toBeUndefined() + expect(call?.[PRIVATE_SECRET_PROVENANCE_FIELD]).toEqual({ + version: 1, + complete: true, + selections: [ + { + key: MOUNTED_WORKSPACE_FILES_PROVENANCE_KEY, + provenance: expect.objectContaining({ version: 1, complete: true, entries: [] }), + }, + ], + }) }) it('projects only mounted-file secrets that cross the settled Function result', async () => { @@ -960,7 +1040,7 @@ describe('executeFunctionExecute file mounts', () => { }) }) - it('rejects unavailable directory-descendant provenance before presigning or fetching', async () => { + it('mounts a directory descendant with unavailable provenance as incomplete', async () => { mockListWorkspaceFileFolders.mockResolvedValue([{ path: 'Reports' }]) mockListWorkspaceFiles.mockResolvedValue([ { @@ -972,13 +1052,16 @@ describe('executeFunctionExecute file mounts', () => { ]) mockImportWorkspaceFileSecretProvenanceForRuntime.mockResolvedValue(false) - await expect( - executeFunctionExecute({ inputs: { directories: ['files/Reports'] } }, context as never) - ).rejects.toThrow(/secret provenance is unavailable/) + await executeFunctionExecute({ inputs: { directories: ['files/Reports'] } }, context as never) - expect(mockGeneratePresignedDownloadUrl).not.toHaveBeenCalled() + expect(mockGeneratePresignedDownloadUrl).toHaveBeenCalled() expect(mockFetchWorkspaceFileBuffer).not.toHaveBeenCalled() - expect(mockExecuteTool).not.toHaveBeenCalled() + expect(mockExecuteTool).toHaveBeenCalled() + expect(mockExecuteTool.mock.calls[0]?.[1]?.[PRIVATE_SECRET_PROVENANCE_FIELD]).toEqual({ + version: 1, + complete: false, + selections: [], + }) }) it('local storage: buffers directory descendants via inline content', async () => { diff --git a/apps/sim/lib/copilot/tools/handlers/function-execute.ts b/apps/sim/lib/copilot/tools/handlers/function-execute.ts index 94e774e4996..da324d58906 100644 --- a/apps/sim/lib/copilot/tools/handlers/function-execute.ts +++ b/apps/sim/lib/copilot/tools/handlers/function-execute.ts @@ -103,20 +103,25 @@ async function importMountedWorkspaceFileProvenance(args: { mountPath: string registry?: ResolvedSecretTraceRegistry }): Promise { - const imported = await importWorkspaceFileSecretProvenanceForRuntime({ - workspaceId: args.workspaceId, - identity: { - fileId: args.record.id, - key: args.record.key, - context: args.record.storageContext ?? 'workspace', - }, - registry: args.registry, - }) - if (!imported) { + if (!args.registry) { throw new Error( `Input file "${args.mountPath}" cannot be mounted because its secret provenance is unavailable.` ) } + try { + const imported = await importWorkspaceFileSecretProvenanceForRuntime({ + workspaceId: args.workspaceId, + identity: { + fileId: args.record.id, + key: args.record.key, + context: args.record.storageContext ?? 'workspace', + }, + registry: args.registry, + }) + if (!imported) args.registry.markIncomplete() + } catch { + args.registry.markIncomplete() + } } /** @@ -448,16 +453,21 @@ export async function resolveInputFiles( // Large/hot tables mount by reference from a version-keyed CSV snapshot in object storage. if (snapshotCacheEnabled && table.rowCount >= SNAPSHOT_MIN_ROWS) { const snapshot = await getOrCreateTableSnapshot(table, 'copilot-fn-exec') - const safeForModelMount = await isTableSnapshotSafeForModelMount({ - tableId: table.id, - workspaceId, - rowsVersion: snapshot.version, - }) - if (!safeForModelMount) { + if (!resolvedSecretTraceRegistry) { throw new Error( - `Input table "${tableId}" cannot be mounted because its secret provenance is not safe for an opaque sandbox file.` + `Input table "${tableId}" cannot be mounted because its secret provenance is unavailable.` ) } + try { + const safeForModelMount = await isTableSnapshotSafeForModelMount({ + tableId: table.id, + workspaceId, + rowsVersion: snapshot.version, + }) + if (!safeForModelMount) resolvedSecretTraceRegistry.markIncomplete() + } catch { + resolvedSecretTraceRegistry.markIncomplete() + } if (hasCloudStorage()) { // Mount by reference: the sandbox fetches the snapshot straight from storage via a @@ -506,15 +516,25 @@ export async function resolveInputFiles( { limit: TABLE_LIMITS.DEFAULT_QUERY_LIMIT }, 'copilot-fn-exec' ) - const provenance = await loadTableRowSecretProvenance(rows.rows, { - userId: provenanceUserId ?? 'opaque-model-mount', - workspaceId, - }) - if (!provenance.complete || provenance.entries.length > 0) { + if (!resolvedSecretTraceRegistry) { throw new Error( - `Input table "${tableId}" cannot be mounted because its secret provenance is not safe for an opaque sandbox file.` + `Input table "${tableId}" cannot be mounted because its secret provenance is unavailable.` ) } + try { + const provenance = await loadTableRowSecretProvenance(rows.rows, { + userId: provenanceUserId ?? 'opaque-model-mount', + workspaceId, + }) + if ( + !provenance.complete || + !(await resolvedSecretTraceRegistry.importProvenance(provenance, { trusted: true })) + ) { + resolvedSecretTraceRegistry.markIncomplete() + } + } catch { + resolvedSecretTraceRegistry.markIncomplete() + } const columns = table.schema.columns const csvLines = [toCsvRow(columns.map((column) => neutralizeCsvFormula(column.name)))] @@ -636,30 +656,19 @@ export async function executeFunctionExecute( secretActorUserId ?? context.userId, mountedRegistry ) - // Every mount ships its provenance envelope, tables included. The route classifies an - // output file from that envelope, so a mount without one is unclassifiable there — it - // cannot tell "nothing secret was mounted" from "nobody said". Emitting on the same - // condition that produces the mount keeps the two from drifting apart. if (resolved.length > 0) { const existing = (enrichedParams._sandboxFiles as SandboxFile[]) || [] enrichedParams._sandboxFiles = [...existing, ...resolved] - // The envelope attests to the WHOLE mounted set or it is not emitted at all. Mounts - // that arrived already on the params came from outside this resolver, so - // `mountedRegistry` knows nothing about them; an envelope covering only `resolved` - // would read at the route as a complete attestation over every mounted byte. With no - // envelope the route fails closed instead, which is the honest answer. - if (existing.length === 0) { - const provenance = mountedRegistry.exportProvenance() - const bundle: PrivateSecretProvenanceBundleV1 = { - version: 1, - complete: provenance.complete, - selections: provenance.complete - ? [{ key: MOUNTED_WORKSPACE_FILES_PROVENANCE_KEY, provenance }] - : [], - } - enrichedParams[PRIVATE_SECRET_PROVENANCE_FIELD] = bundle + const provenance = mountedRegistry.exportProvenance() + const bundle: PrivateSecretProvenanceBundleV1 = { + version: 1, + complete: provenance.complete, + selections: provenance.complete + ? [{ key: MOUNTED_WORKSPACE_FILES_PROVENANCE_KEY, provenance }] + : [], } + enrichedParams[PRIVATE_SECRET_PROVENANCE_FIELD] = bundle } } } diff --git a/apps/sim/lib/copilot/tools/handlers/upload-file-reader.test.ts b/apps/sim/lib/copilot/tools/handlers/upload-file-reader.test.ts index 2aa574c6a69..3e511d1be0c 100644 --- a/apps/sim/lib/copilot/tools/handlers/upload-file-reader.test.ts +++ b/apps/sim/lib/copilot/tools/handlers/upload-file-reader.test.ts @@ -11,6 +11,7 @@ const { mockReadFileRecord, mockFetchBuffer } = vi.hoisted(() => ({ })) vi.mock('@/lib/copilot/vfs/file-reader', () => ({ + isReadableFileType: (contentType: string) => contentType.startsWith('text/'), readFileRecord: mockReadFileRecord, MAX_TEXT_READ_BYTES: 5 * 1024 * 1024, })) diff --git a/apps/sim/lib/copilot/tools/handlers/upload-file-reader.ts b/apps/sim/lib/copilot/tools/handlers/upload-file-reader.ts index fd891ac1121..064b40adb16 100644 --- a/apps/sim/lib/copilot/tools/handlers/upload-file-reader.ts +++ b/apps/sim/lib/copilot/tools/handlers/upload-file-reader.ts @@ -5,6 +5,7 @@ import { toError } from '@sim/utils/errors' import { and, asc, desc, eq, isNull, or } from 'drizzle-orm' import { type FileReadResult, + isReadableFileType, MAX_TEXT_READ_BYTES, readFileRecord, } from '@/lib/copilot/vfs/file-reader' @@ -206,6 +207,7 @@ export async function readChatUploadWithProvenance( return { value: result, file: { fileId: record.id, key: record.key, context: 'mothership' }, + view: isReadableFileType(record.type) ? 'complete' : 'derived', } } catch (err) { logger.warn('Failed to read chat upload', { diff --git a/apps/sim/lib/copilot/tools/handlers/vfs.test.ts b/apps/sim/lib/copilot/tools/handlers/vfs.test.ts index d314b0bfde0..5edb62a789c 100644 --- a/apps/sim/lib/copilot/tools/handlers/vfs.test.ts +++ b/apps/sim/lib/copilot/tools/handlers/vfs.test.ts @@ -9,8 +9,8 @@ const { getOrMaterializeVFS } = vi.hoisted(() => ({ getOrMaterializeVFS: vi.fn(), })) -const { importWorkspaceFileSecretProvenanceForValue } = vi.hoisted(() => ({ - importWorkspaceFileSecretProvenanceForValue: vi.fn().mockResolvedValue(true), +const { importWorkspaceFileSecretProvenanceForModelView } = vi.hoisted(() => ({ + importWorkspaceFileSecretProvenanceForModelView: vi.fn().mockResolvedValue(true), })) const { @@ -40,7 +40,7 @@ vi.mock('@/lib/copilot/vfs', () => ({ getOrMaterializeVFS, })) vi.mock('@/lib/uploads/contexts/workspace/workspace-file-secret-provenance', () => ({ - importWorkspaceFileSecretProvenanceForValue, + importWorkspaceFileSecretProvenanceForModelView, })) vi.mock('./upload-file-reader', () => ({ readChatUpload, @@ -81,7 +81,7 @@ const GREP_CTX_CHAT = { ...GREP_CTX, chatId: 'chat-1' } describe('vfs handlers oversize policy', () => { beforeEach(() => { vi.clearAllMocks() - importWorkspaceFileSecretProvenanceForValue.mockResolvedValue(true) + importWorkspaceFileSecretProvenanceForModelView.mockResolvedValue(true) }) it('fails oversized grep results with narrowing guidance', async () => { @@ -270,7 +270,7 @@ describe('vfs handlers oversize policy', () => { expect(vfs.read).not.toHaveBeenCalled() }) - it('filters durable provenance against only the final windowed read result', async () => { + it('marks a windowed read as a derived provenance view', async () => { const vfs = makeVfs() vfs.readFileContentWithProvenance.mockResolvedValue({ value: { content: 'hidden-secret\nvisible line', totalLines: 2 }, @@ -284,10 +284,10 @@ describe('vfs handlers oversize policy', () => { ) expect(result.success).toBe(true) - expect(importWorkspaceFileSecretProvenanceForValue).toHaveBeenCalledWith( + expect(importWorkspaceFileSecretProvenanceForModelView).toHaveBeenCalledWith( expect.objectContaining({ identity: { fileId: 'file-1', key: 'workspace/key-1', context: 'workspace' }, - value: { content: 'visible line', totalLines: 2 }, + view: 'derived', }) ) }) @@ -336,6 +336,76 @@ describe('vfs handlers oversize policy', () => { expect(result.output).toEqual(imageResult) }) + it('checks every compiled-document contributor at the opaque model boundary', async () => { + const vfs = makeVfs() + const contentUpdatedAt = new Date('2026-08-06T00:00:00.000Z') + vfs.readFileContentWithProvenance.mockResolvedValue({ + value: { + content: 'Compiled file: report.pdf', + totalLines: 1, + attachment: { + type: 'file', + name: 'report.pdf', + source: { type: 'base64', media_type: 'application/pdf', data: 'AAAA' }, + }, + }, + file: { fileId: 'source-1', key: 'workspace/source-1', context: 'workspace' }, + contributingFiles: [ + { + fileId: 'image-1', + key: 'workspace/image-1', + context: 'workspace', + contentUpdatedAt, + }, + ], + }) + getOrMaterializeVFS.mockResolvedValue(vfs) + importWorkspaceFileSecretProvenanceForModelView + .mockResolvedValueOnce(true) + .mockResolvedValueOnce(false) + + const result = await executeVfsRead({ path: 'files/report.pdf/compiled' }, GREP_CTX) + + expect(result.success).toBe(false) + expect(result.error).toContain('cannot be shared safely') + expect(importWorkspaceFileSecretProvenanceForModelView).toHaveBeenNthCalledWith( + 1, + expect.objectContaining({ + identity: { fileId: 'source-1', key: 'workspace/source-1', context: 'workspace' }, + view: 'opaque', + }) + ) + expect(importWorkspaceFileSecretProvenanceForModelView).toHaveBeenNthCalledWith( + 2, + expect.objectContaining({ + identity: { + fileId: 'image-1', + key: 'workspace/image-1', + context: 'workspace', + contentUpdatedAt, + }, + view: 'opaque', + }) + ) + }) + + it('uses the source-declared view for an unwindowed read', async () => { + const vfs = makeVfs() + vfs.readFileContentWithProvenance.mockResolvedValue({ + value: { content: 'complete content', totalLines: 1 }, + file: { fileId: 'file-1', key: 'workspace/key-1', context: 'workspace' }, + view: 'complete', + }) + getOrMaterializeVFS.mockResolvedValue(vfs) + + const result = await executeVfsRead({ path: 'files/report.txt/content' }, GREP_CTX) + + expect(result.success).toBe(true) + expect(importWorkspaceFileSecretProvenanceForModelView).toHaveBeenCalledWith( + expect.objectContaining({ view: 'complete' }) + ) + }) + it('rejects only the file read when durable provenance cannot be verified', async () => { const vfs = makeVfs() vfs.readFileContentWithProvenance.mockResolvedValue({ @@ -343,7 +413,7 @@ describe('vfs handlers oversize policy', () => { file: { fileId: 'file-1', key: 'workspace/key-1', context: 'workspace' }, }) getOrMaterializeVFS.mockResolvedValue(vfs) - importWorkspaceFileSecretProvenanceForValue.mockResolvedValueOnce(false) + importWorkspaceFileSecretProvenanceForModelView.mockResolvedValueOnce(false) const result = await executeVfsRead({ path: 'files/report.txt/content' }, GREP_CTX) @@ -355,7 +425,7 @@ describe('vfs handlers oversize policy', () => { describe('vfs grep workspace-file routing', () => { beforeEach(() => { vi.clearAllMocks() - importWorkspaceFileSecretProvenanceForValue.mockResolvedValue(true) + importWorkspaceFileSecretProvenanceForModelView.mockResolvedValue(true) }) it('routes a single workspace file leaf to grepFile (content search)', async () => { @@ -430,7 +500,7 @@ describe('vfs grep workspace-file routing', () => { expect(result.error).toContain('single workspace file') }) - it('filters durable provenance against the final grep projection', async () => { + it('marks content grep as a derived provenance view', async () => { const vfs = makeVfs() vfs.grepFileWithProvenance.mockResolvedValue({ value: [{ path: 'files/report.csv', line: 2, content: 'visible hit' }], @@ -444,20 +514,42 @@ describe('vfs grep workspace-file routing', () => { ) expect(result.success).toBe(true) - expect(importWorkspaceFileSecretProvenanceForValue).toHaveBeenCalledWith( + expect(importWorkspaceFileSecretProvenanceForModelView).toHaveBeenCalledWith( expect.objectContaining({ - value: { - matches: [{ path: 'files/report.csv', line: 2, content: 'visible hit' }], - }, + view: 'derived', }) ) }) + + it('treats count grep as derived from file content', async () => { + importWorkspaceFileSecretProvenanceForModelView.mockResolvedValueOnce(false) + const vfs = makeVfs() + vfs.grepFileWithProvenance.mockResolvedValue({ + value: [{ path: 'files/report.csv', count: 1 }], + file: { fileId: 'file-1', key: 'workspace/key-1', context: 'workspace' }, + }) + getOrMaterializeVFS.mockResolvedValue(vfs) + + const result = await executeVfsGrep( + { pattern: 'visible', path: 'files/report.csv', output_mode: 'count' }, + GREP_CTX + ) + + expect(result).toEqual({ + success: false, + error: + 'This file result cannot be shared safely because its secret provenance is unavailable.', + }) + expect(importWorkspaceFileSecretProvenanceForModelView).toHaveBeenCalledWith( + expect.objectContaining({ view: 'derived' }) + ) + }) }) describe('vfs uploads are opt-in (like recently-deleted/)', () => { beforeEach(() => { vi.clearAllMocks() - importWorkspaceFileSecretProvenanceForValue.mockResolvedValue(true) + importWorkspaceFileSecretProvenanceForModelView.mockResolvedValue(true) }) it('does not search uploads for an unscoped grep', async () => { diff --git a/apps/sim/lib/copilot/tools/handlers/vfs.ts b/apps/sim/lib/copilot/tools/handlers/vfs.ts index f5ba4c4aa15..b70cd2d9a23 100644 --- a/apps/sim/lib/copilot/tools/handlers/vfs.ts +++ b/apps/sim/lib/copilot/tools/handlers/vfs.ts @@ -9,7 +9,7 @@ import type { GrepCountEntry, GrepMatch } from '@/lib/copilot/vfs/operations' import { WorkspaceFileGrepError } from '@/lib/copilot/vfs/operations' import { encodeVfsSegment } from '@/lib/copilot/vfs/path-utils' import { - importWorkspaceFileSecretProvenanceForValue, + importWorkspaceFileSecretProvenanceForModelView, type WorkspaceFileSecretProvenanceIdentity, } from '@/lib/uploads/contexts/workspace/workspace-file-secret-provenance' import { withBlockVisibility } from '@/blocks/visibility/server-context' @@ -97,16 +97,32 @@ function hasModelAttachment(result: unknown): boolean { async function canReturnWorkspaceFileValue( file: WorkspaceFileSecretProvenanceIdentity | undefined, value: unknown, - context: ExecutionContext + context: ExecutionContext, + view: 'complete' | 'derived', + contributingFiles: readonly WorkspaceFileSecretProvenanceIdentity[] = [] ): Promise { - if (!file || !context.workspaceId) return true - return importWorkspaceFileSecretProvenanceForValue({ - workspaceId: context.workspaceId, - identity: file, - value, - registry: context.resolvedSecretTraceRegistry, - opaqueAttachment: hasModelAttachment(value), - }) + if (!context.workspaceId) return true + const files = new Map() + for (const identity of file ? [file, ...contributingFiles] : contributingFiles) { + files.set(`${identity.context}:${identity.fileId}:${identity.key}`, identity) + } + if (files.size === 0) return true + + const provenanceView = hasModelAttachment(value) ? 'opaque' : view + for (const identity of files.values()) { + if ( + !(await importWorkspaceFileSecretProvenanceForModelView({ + workspaceId: context.workspaceId, + identity, + registry: context.resolvedSecretTraceRegistry, + view: provenanceView, + value, + })) + ) { + return false + } + } + return true } export async function executeVfsGrep( @@ -187,7 +203,7 @@ export async function executeVfsGrep( ? Object.keys(result).length : 0 const output = { [key]: result } - if (!(await canReturnWorkspaceFileValue(provenanceFile, output, context))) { + if (!(await canReturnWorkspaceFileValue(provenanceFile, output, context, 'derived'))) { return { success: false, error: @@ -341,7 +357,19 @@ export async function executeVfsRead( } } const windowedUpload = applyWindow(uploadResult) - if (!(await canReturnWorkspaceFileValue(uploadEnvelope?.file, windowedUpload, context))) { + const provenanceView = + offset === undefined && limit === undefined + ? (uploadEnvelope?.view ?? 'derived') + : 'derived' + if ( + !(await canReturnWorkspaceFileValue( + uploadEnvelope?.file, + windowedUpload, + context, + provenanceView, + uploadEnvelope?.contributingFiles + )) + ) { return { success: false, error: @@ -396,7 +424,17 @@ export async function executeVfsRead( } } const windowedFileContent = applyWindow(fileContent) - if (!(await canReturnWorkspaceFileValue(fileEnvelope?.file, windowedFileContent, context))) { + const provenanceView = + offset === undefined && limit === undefined ? (fileEnvelope?.view ?? 'derived') : 'derived' + if ( + !(await canReturnWorkspaceFileValue( + fileEnvelope?.file, + windowedFileContent, + context, + provenanceView, + fileEnvelope?.contributingFiles + )) + ) { return { success: false, error: diff --git a/apps/sim/lib/copilot/tools/server/docs/search-documentation.test.ts b/apps/sim/lib/copilot/tools/server/docs/search-documentation.test.ts index f61622fafff..14693f75913 100644 --- a/apps/sim/lib/copilot/tools/server/docs/search-documentation.test.ts +++ b/apps/sim/lib/copilot/tools/server/docs/search-documentation.test.ts @@ -24,7 +24,7 @@ describe('documentation search model boundary', () => { mockGenerateSearchEmbedding.mockResolvedValue({ embedding: [], isBYOK: false }) }) - it('projects the query immediately before embedding without logging plaintext', async () => { + it('preserves a query that merely collides with ambient secret plaintext', async () => { const registry = new ResolvedSecretTraceRegistry([ { name: 'DOCS_QUERY', @@ -39,7 +39,7 @@ describe('documentation search model boundary', () => { { userId: 'user-1', resolvedSecretTraceRegistry: registry } ) - expect(mockGenerateSearchEmbedding).toHaveBeenCalledWith('{{DOCS_QUERY}}') + expect(mockGenerateSearchEmbedding).toHaveBeenCalledWith('private documentation query') expect(result).toEqual({ results: [], query: 'private documentation query', diff --git a/apps/sim/lib/copilot/tools/server/docs/search-documentation.ts b/apps/sim/lib/copilot/tools/server/docs/search-documentation.ts index 9226e27b369..ad14c3937a6 100644 --- a/apps/sim/lib/copilot/tools/server/docs/search-documentation.ts +++ b/apps/sim/lib/copilot/tools/server/docs/search-documentation.ts @@ -3,8 +3,7 @@ import { docsEmbeddings } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { sql } from 'drizzle-orm' import { SearchDocumentation } from '@/lib/copilot/generated/tool-catalog-v1' -import type { BaseServerTool, ServerToolContext } from '@/lib/copilot/tools/server/base-tool' -import { projectServerToolModelInput } from '@/lib/copilot/tools/server/model-input' +import type { BaseServerTool } from '@/lib/copilot/tools/server/base-tool' import { generateSearchEmbedding } from '@/lib/knowledge/embeddings' interface DocsSearchParams { @@ -17,7 +16,7 @@ const DEFAULT_DOCS_SIMILARITY_THRESHOLD = 0.3 export const searchDocumentationServerTool: BaseServerTool = { name: SearchDocumentation.id, - async execute(params: DocsSearchParams, context?: ServerToolContext): Promise { + async execute(params: DocsSearchParams): Promise { const logger = createLogger('SearchDocumentationServerTool') const { query, topK = 10, threshold } = params if (!query || typeof query !== 'string') throw new Error('query is required') @@ -26,7 +25,7 @@ export const searchDocumentationServerTool: BaseServerTool ({ + executeInSandboxMock: vi.fn(), + executeShellInSandboxMock: vi.fn(), + fetchWorkspaceFileBufferMock: vi.fn(), + getWorkspaceFileMock: vi.fn(), + loadCompiledDocMock: vi.fn(), + storeCompiledDocMock: vi.fn(), +})) vi.mock('@/lib/execution/remote-sandbox', () => ({ - executeInSandbox: vi.fn(), - executeShellInSandbox: vi.fn(), + executeInSandbox: executeInSandboxMock, + executeShellInSandbox: executeShellInSandboxMock, })) vi.mock('@/lib/execution/languages', () => ({ CodeLanguage: { javascript: 'javascript', python: 'python' }, })) vi.mock('@/lib/uploads/contexts/workspace/workspace-file-manager', () => ({ - getWorkspaceFile: vi.fn(), - fetchWorkspaceFileBuffer: vi.fn(), + getWorkspaceFile: getWorkspaceFileMock, + fetchWorkspaceFileBuffer: fetchWorkspaceFileBufferMock, })) vi.mock('./doc-compiled-store', () => ({ - loadCompiledDoc: vi.fn(), - storeCompiledDoc: vi.fn(), + loadCompiledDoc: loadCompiledDocMock, + storeCompiledDoc: storeCompiledDocMock, })) -import { collectReferencedFileIds } from './doc-compile' +import { collectReferencedFileIds, compileDoc } from './doc-compile' const ID = '550e8400-e29b-41d4-a716-446655440000' +function referencedFileSource(count: number): string { + return Array.from({ length: count }, (_, index) => `getFileBase64('file-${index}')`).join('\n') +} + +afterAll(resetEnvFlagsMock) + describe('collectReferencedFileIds', () => { + beforeEach(() => { + vi.clearAllMocks() + setEnvFlags({ isDocSandboxEnabled: true }) + }) + it('captures the id from getFileBase64(...) with single or double quotes', () => { expect(collectReferencedFileIds(`await getFileBase64('${ID}')`)).toEqual(new Set([ID])) expect(collectReferencedFileIds(`getFileBase64("abc_def-1")`)).toEqual(new Set(['abc_def-1'])) @@ -72,4 +100,136 @@ describe('collectReferencedFileIds', () => { it('returns an empty set when there are no image references', () => { expect(collectReferencedFileIds(`slide.addText('hello', { x: 1, y: 1 })`)).toEqual(new Set()) }) + + it('retains all references at the remote staging limit', () => { + const ids = collectReferencedFileIds(referencedFileSource(20)) + + expect(ids.size).toBe(20) + expect(ids.has('file-19')).toBe(true) + }) + + it('stops collecting at the one-over-limit sentinel', () => { + const ids = collectReferencedFileIds(referencedFileSource(100)) + + expect(ids.size).toBe(21) + expect(ids.has('file-20')).toBe(true) + expect(ids.has('file-21')).toBe(false) + }) + + it('rejects remote compilation at 21 references before resolving file metadata', async () => { + await expect( + compileDoc({ + source: referencedFileSource(21), + fileName: 'report.pdf', + workspaceId: 'workspace-1', + }) + ).rejects.toThrow('More than 20 referenced input files; maximum is 20') + expect(getWorkspaceFileMock).not.toHaveBeenCalled() + expect(executeInSandboxMock).not.toHaveBeenCalled() + }) + + it('compiles referenced bytes and returns their canonical contributor identity', async () => { + const contentUpdatedAt = new Date('2026-08-05T00:00:00.000Z') + const record = { + id: ID, + workspaceId: 'workspace-1', + name: 'reference.png', + key: `workspace/workspace-1/${ID}-reference.png`, + path: '/api/files/serve/reference.png', + size: 10, + type: 'image/png', + uploadedBy: 'user-1', + uploadedAt: contentUpdatedAt, + updatedAt: contentUpdatedAt, + contentUpdatedAt, + storageContext: 'workspace' as const, + } + getWorkspaceFileMock.mockResolvedValue(record) + loadCompiledDocMock.mockResolvedValue(null) + fetchWorkspaceFileBufferMock.mockResolvedValue(Buffer.from('image')) + executeInSandboxMock.mockResolvedValue({ + error: null, + exportedFileContent: Buffer.from('%PDF-built').toString('base64'), + }) + + await expect( + compileDoc({ + source: `image = await getFileBase64('${ID}')`, + fileName: 'report.pdf', + workspaceId: 'workspace-1', + }) + ).resolves.toEqual({ + buffer: Buffer.from('%PDF-built'), + contentType: 'application/pdf', + contributingFiles: [ + { + fileId: ID, + key: record.key, + context: 'workspace', + contentUpdatedAt, + }, + ], + }) + expect(fetchWorkspaceFileBufferMock).toHaveBeenCalledWith(record, expect.any(Object)) + expect(executeInSandboxMock).toHaveBeenCalledOnce() + expect(storeCompiledDocMock).toHaveBeenCalledOnce() + }) + + it('binds cached artifacts to the current referenced-file content version', async () => { + const contentUpdatedAt = new Date('2026-08-05T01:00:00.000Z') + getWorkspaceFileMock.mockResolvedValue({ + id: ID, + workspaceId: 'workspace-1', + name: 'reference.png', + key: `workspace/workspace-1/${ID}-reference.png`, + path: '/api/files/serve/reference.png', + size: 10, + type: 'image/png', + uploadedBy: 'user-1', + uploadedAt: new Date('2026-08-05T00:00:00.000Z'), + updatedAt: contentUpdatedAt, + contentUpdatedAt, + storageContext: 'workspace', + }) + loadCompiledDocMock.mockResolvedValue(Buffer.from('%PDF-cached')) + + await expect( + compileDoc({ + source: `image = await getFileBase64('${ID}')`, + fileName: 'report.pdf', + workspaceId: 'workspace-1', + }) + ).resolves.toEqual({ + buffer: Buffer.from('%PDF-cached'), + contentType: 'application/pdf', + contributingFiles: [ + { + fileId: ID, + key: `workspace/workspace-1/${ID}-reference.png`, + context: 'workspace', + contentUpdatedAt, + }, + ], + }) + + expect(loadCompiledDocMock).toHaveBeenCalledWith( + 'workspace-1', + `image = await getFileBase64('${ID}')`, + 'pdf', + JSON.stringify({ + version: 1, + inputs: [ + { + fileId: ID, + key: `workspace/workspace-1/${ID}-reference.png`, + context: 'workspace', + contentVersion: contentUpdatedAt.toISOString(), + size: 10, + }, + ], + }) + ) + expect(fetchWorkspaceFileBufferMock).not.toHaveBeenCalled() + expect(executeInSandboxMock).not.toHaveBeenCalled() + }) }) diff --git a/apps/sim/lib/copilot/tools/server/files/doc-compile.ts b/apps/sim/lib/copilot/tools/server/files/doc-compile.ts index 4b26ebe4c7d..52d334bc8f8 100644 --- a/apps/sim/lib/copilot/tools/server/files/doc-compile.ts +++ b/apps/sim/lib/copilot/tools/server/files/doc-compile.ts @@ -13,6 +13,7 @@ import { fetchWorkspaceFileBuffer, getWorkspaceFile, } from '@/lib/uploads/contexts/workspace/workspace-file-manager' +import type { WorkspaceFileSecretProvenanceIdentity } from '@/lib/uploads/contexts/workspace/workspace-file-secret-provenance' import { getContentType } from '@/app/api/files/utils' import type { SandboxTaskId } from '@/sandbox-tasks/registry' import { loadCompiledDoc, storeCompiledDoc } from './doc-compiled-store' @@ -123,32 +124,71 @@ const MAX_STAGED_INPUTS = 20 const MAX_STAGED_FILE_BYTES = 25 * 1024 * 1024 const MAX_STAGED_TOTAL_BYTES = 50 * 1024 * 1024 +interface ResolvedReferencedImage { + fileId: string + record: NonNullable>> +} + +interface ReferencedImageResolution { + images: ResolvedReferencedImage[] + referenceCount: number + artifactIdentity?: string +} + +export interface CompiledDocResult { + buffer: Buffer + contentType: string + contributingFiles?: readonly WorkspaceFileSecretProvenanceIdentity[] +} + +function referencedImageIdentities( + resolution: ReferencedImageResolution +): WorkspaceFileSecretProvenanceIdentity[] { + return resolution.images.map(({ record }) => ({ + fileId: record.id, + key: record.key, + context: record.storageContext ?? 'workspace', + contentUpdatedAt: record.contentUpdatedAt ?? record.updatedAt, + })) +} + /** * Collects the workspace file ids a doc source references — from the injected * image-helper call sites and the legacy `/home/user/inputs/` path. Matching * is scoped to the helper calls (not bare id-like strings in slide text), and the * caller skips any id that does not resolve to a real file, so over-matching is - * harmless. + * harmless. Retention stops at one over the remote staging limit: callers need + * only distinguish no references, an admissible set, and an oversized set. */ export function collectReferencedFileIds(source: string): Set { const ids = new Set() for (const re of [INPUT_PATH_RE, FILE_HELPER_RE]) { for (const match of source.matchAll(re)) { - if (match[1]) ids.add(match[1]) + if (match[1]) { + ids.add(match[1]) + if (ids.size > MAX_STAGED_INPUTS) return ids + } } } return ids } -async function stageReferencedImages(source: string, workspaceId: string): Promise { - const ids = collectReferencedFileIds(source) +async function resolveReferencedImages( + source: string, + workspaceId: string, + ids = collectReferencedFileIds(source) +): Promise { if (ids.size > MAX_STAGED_INPUTS) { throw new Error( - `Too many referenced input files (${ids.size}); max ${MAX_STAGED_INPUTS}. Reference fewer files.` + `More than ${MAX_STAGED_INPUTS} referenced input files; maximum is ${MAX_STAGED_INPUTS}. Reference fewer files.` ) } - const files: SandboxFile[] = [] - let totalBytes = 0 + if (ids.size === 0) { + return { images: [], referenceCount: 0 } + } + + const images: ResolvedReferencedImage[] = [] + const identity: Array> = [] for (const fileId of ids) { let record: Awaited> try { @@ -159,9 +199,43 @@ async function stageReferencedImages(source: string, workspaceId: string): Promi fileId, error: getErrorMessage(err), }) + identity.push({ fileId, state: 'unavailable' }) + continue + } + if (!record) { + identity.push({ fileId, state: 'missing' }) continue } - if (!record) continue + identity.push({ + fileId, + key: record.key, + context: record.storageContext ?? 'workspace', + contentVersion: (record.contentUpdatedAt ?? record.updatedAt).toISOString(), + size: record.size, + }) + images.push({ fileId, record }) + } + + return { + images, + referenceCount: ids.size, + artifactIdentity: JSON.stringify({ version: 1, inputs: identity }), + } +} + +async function stageReferencedImages( + resolution: ReferencedImageResolution, + workspaceId: string +): Promise { + if (resolution.referenceCount > MAX_STAGED_INPUTS) { + throw new Error( + `More than ${MAX_STAGED_INPUTS} referenced input files; maximum is ${MAX_STAGED_INPUTS}. Reference fewer files.` + ) + } + + const files: SandboxFile[] = [] + let totalBytes = 0 + for (const { fileId, record } of resolution.images) { if (typeof record.size === 'number' && record.size > MAX_STAGED_FILE_BYTES) { logger.warn('Skipping oversized referenced image for doc compile', { workspaceId, @@ -177,7 +251,7 @@ async function stageReferencedImages(source: string, workspaceId: string): Promi } let buffer: Buffer try { - buffer = await fetchWorkspaceFileBuffer(record) + buffer = await fetchWorkspaceFileBuffer(record, { maxBytes: MAX_STAGED_FILE_BYTES }) } catch (err) { logger.warn('Failed to stage referenced image for doc compile', { workspaceId, @@ -241,6 +315,8 @@ interface CompileArgs { source: string fileName: string workspaceId: string + ownerKey?: string + signal?: AbortSignal } /** @@ -251,9 +327,10 @@ interface CompileArgs { */ async function compileDocViaE2BPython( { source, workspaceId }: CompileArgs, - fmt: E2BDocFormat + fmt: E2BDocFormat, + referencedImages: ReferencedImageResolution ): Promise { - const sandboxFiles = await stageReferencedImages(source, workspaceId) + const sandboxFiles = await stageReferencedImages(referencedImages, workspaceId) const outputSandboxPath = `/home/user/output.${fmt.ext}` // openpyxl writes formula strings but no cached values, so a web viewer (SheetJS) @@ -332,9 +409,10 @@ fs.writeFileSync('/home/user/output.docx', __buf); */ async function compileDocViaE2BNode( { source, fileName, workspaceId }: CompileArgs, - ext: 'pptx' | 'docx' + ext: 'pptx' | 'docx', + referencedImages: ReferencedImageResolution ): Promise { - const sandboxFiles = await stageReferencedImages(source, workspaceId) + const sandboxFiles = await stageReferencedImages(referencedImages, workspaceId) const outputSandboxPath = `/home/user/output.${ext}` const preamble = ext === 'pptx' ? PPTX_NODE_PREAMBLE : DOCX_NODE_PREAMBLE const finalize = ext === 'pptx' ? PPTX_NODE_FINALIZE : DOCX_NODE_FINALIZE @@ -385,59 +463,167 @@ ${finalize} ) } +async function buildCompiledDoc( + args: CompileArgs, + fmt: E2BDocFormat, + referencedImages: ReferencedImageResolution +): Promise { + const { source, fileName, workspaceId } = args + const buffer = + fmt.engine === 'node' + ? await compileDocViaE2BNode( + { source, fileName, workspaceId }, + fmt.ext as 'pptx' | 'docx', + referencedImages + ) + : await compileDocViaE2BPython({ source, fileName, workspaceId }, fmt, referencedImages) + await storeCompiledDoc( + workspaceId, + source, + fmt.ext, + fmt.contentType, + buffer, + referencedImages.artifactIdentity + ) + const contributingFiles = referencedImageIdentities(referencedImages) + return { + buffer, + contentType: fmt.contentType, + ...(contributingFiles.length > 0 ? { contributingFiles } : {}), + } +} + +interface CompilableFormat { + magic: Buffer + taskId: SandboxTaskId + contentType: string +} + +const ZIP_MAGIC = Buffer.from([0x50, 0x4b, 0x03, 0x04]) +const PDF_MAGIC = Buffer.from([0x25, 0x50, 0x44, 0x46, 0x2d]) + +const COMPILABLE_FORMATS: Record = { + '.pptx': { magic: ZIP_MAGIC, taskId: 'pptx-generate', contentType: PPTX_MIME }, + '.docx': { magic: ZIP_MAGIC, taskId: 'docx-generate', contentType: DOCX_MIME }, + '.pdf': { magic: PDF_MAGIC, taskId: 'pdf-generate', contentType: PDF_MIME }, +} + +async function compileDocInLegacySandbox( + args: CompileArgs, + fmt: E2BDocFormat +): Promise { + const format = COMPILABLE_FORMATS[`.${fmt.ext}`] + if (!format) { + throw new DocCompileUserError('Document is still being generated') + } + + const cacheKey = sha256Hex(`.${fmt.ext}${args.source}${args.workspaceId}`) + const cached = compiledDocCache.get(cacheKey) + if (cached) { + return { + buffer: cached.buffer, + contentType: fmt.contentType, + ...(cached.contributingFiles && cached.contributingFiles.length > 0 + ? { contributingFiles: cached.contributingFiles } + : {}), + } + } + + const contributingFiles = new Map() + const buffer = await runSandboxTask( + format.taskId, + { code: args.source, workspaceId: args.workspaceId }, + { + ownerKey: args.ownerKey, + signal: args.signal, + onWorkspaceFileAccess: (identity) => { + contributingFiles.set(`${identity.context}:${identity.fileId}:${identity.key}`, identity) + }, + } + ) + compiledCacheSet(cacheKey, buffer, [...contributingFiles.values()]) + return { + buffer, + contentType: fmt.contentType, + ...(contributingFiles.size > 0 ? { contributingFiles: [...contributingFiles.values()] } : {}), + } +} + /** - * Returns the compiled binary for a doc, building it once (via the right engine — - * Node for pptx/docx, Python for pdf/xlsx) if the source-hash artifact is not - * already in S3. Used by read paths (serve, render, compiled-check) so E2B runs - * at most once per distinct source. + * Returns the compiled binary for a document. The remote backend reuses and publishes a + * dependency-bound artifact after statically staging its inputs. The isolated-VM backend preserves + * its live-broker semantics and keeps only a small process-local cache; it cannot publish a durable + * artifact until broker calls can report the exact file versions they accessed. */ -export async function compileDoc( - args: CompileArgs -): Promise<{ buffer: Buffer; contentType: string }> { +export async function compileDoc(args: CompileArgs): Promise { const { source, fileName, workspaceId } = args const fmt = await getE2BDocFormat(fileName) if (!fmt) throw new Error(`Unsupported document format: ${fileName}`) + if (!isDocSandboxEnabled) return compileDocInLegacySandbox(args, fmt) - const existing = await loadCompiledDoc(workspaceId, source, fmt.ext) - if (existing) return { buffer: existing, contentType: fmt.contentType } + const referencedFileIds = collectReferencedFileIds(source) + const referencedImages = await resolveReferencedImages(source, workspaceId, referencedFileIds) - const buffer = - fmt.engine === 'node' - ? await compileDocViaE2BNode({ source, fileName, workspaceId }, fmt.ext as 'pptx' | 'docx') - : await compileDocViaE2BPython({ source, fileName, workspaceId }, fmt) - await storeCompiledDoc(workspaceId, source, fmt.ext, fmt.contentType, buffer) - return { buffer, contentType: fmt.contentType } + const existing = await loadCompiledDoc( + workspaceId, + source, + fmt.ext, + referencedImages.artifactIdentity + ) + if (existing) { + const contributingFiles = referencedImageIdentities(referencedImages) + return { + buffer: existing, + contentType: fmt.contentType, + ...(contributingFiles.length > 0 ? { contributingFiles } : {}), + } + } + return buildCompiledDoc(args, fmt, referencedImages) } /** - * Loads a compiled doc artifact by extension when present, without compiling. - * Used by the serve route, which has the source + ext but no file record — a hit - * means the file is a generated doc whose binary is already built. + * Loads a dependency-bound compiled artifact. Public shares may also read a pre-cutover + * source-keyed artifact. That fallback preserves already-public documents even when their + * original inputs no longer resolve; it only reads an existing binary and never executes source. */ export async function loadCompiledDocByExt( workspaceId: string, source: string, - ext: string + ext: string, + options: { allowLegacyReferencedArtifact?: boolean } = {} ): Promise<{ buffer: Buffer; contentType: string } | null> { const fmt = await getE2BDocFormat(`x.${ext}`) if (!fmt) return null - const buffer = await loadCompiledDoc(workspaceId, source, fmt.ext) - return buffer ? { buffer, contentType: fmt.contentType } : null + const referencedFileIds = collectReferencedFileIds(source) + if (referencedFileIds.size > MAX_STAGED_INPUTS) { + if (!options.allowLegacyReferencedArtifact) return null + const legacyBuffer = await loadCompiledDoc(workspaceId, source, fmt.ext) + return legacyBuffer ? { buffer: legacyBuffer, contentType: fmt.contentType } : null + } + const referencedImages = await resolveReferencedImages(source, workspaceId, referencedFileIds) + const buffer = await loadCompiledDoc( + workspaceId, + source, + fmt.ext, + referencedImages.artifactIdentity + ) + if (buffer) return { buffer, contentType: fmt.contentType } + if (referencedImages.artifactIdentity && options.allowLegacyReferencedArtifact) { + const legacyBuffer = await loadCompiledDoc(workspaceId, source, fmt.ext) + if (legacyBuffer) return { buffer: legacyBuffer, contentType: fmt.contentType } + } + return null } -const ZIP_MAGIC = Buffer.from([0x50, 0x4b, 0x03, 0x04]) -const PDF_MAGIC = Buffer.from([0x25, 0x50, 0x44, 0x46, 0x2d]) // %PDF- - function bufferStartsWith(buffer: Buffer, magic: Buffer): boolean { return buffer.length >= magic.length && buffer.subarray(0, magic.length).equals(magic) } /** - * How a read-only consumer (e.g. the public share route) should serve a stored doc - * WITHOUT compiling: + * How a read-only consumer (e.g. the public share route) should serve a stored doc: * - `passthrough` — serve the raw stored bytes as-is (a non-doc file, or an uploaded * binary that already carries its format magic). - * - `artifact` — serve this prebuilt content-addressed compiled binary. + * - `artifact` — serve a dependency-bound compiled binary, or an eligible pre-cutover public artifact. * - `unavailable` — a generated doc stored as source whose compiled artifact does * not exist yet; the raw bytes are source, so serving them under the file's binary * content type would be corrupt. The caller should signal "not ready" instead. @@ -456,30 +642,40 @@ export async function resolveServableDoc( if (!fmt) return { kind: 'passthrough' } const magic = fmt.ext === 'pdf' ? PDF_MAGIC : ZIP_MAGIC if (bufferStartsWith(storedBytes, magic)) return { kind: 'passthrough' } - const artifact = await loadCompiledDocByExt(workspaceId, storedBytes.toString('utf-8'), fmt.ext) - return artifact ? { kind: 'artifact', ...artifact } : { kind: 'unavailable' } -} - -interface CompilableFormat { - magic: Buffer - taskId: SandboxTaskId - contentType: string + try { + const artifact = await loadCompiledDocByExt( + workspaceId, + storedBytes.toString('utf-8'), + fmt.ext, + { allowLegacyReferencedArtifact: true } + ) + return artifact ? { kind: 'artifact', ...artifact } : { kind: 'unavailable' } + } catch (error) { + if (error instanceof DocCompileUserError) return { kind: 'unavailable' } + throw error + } } -const COMPILABLE_FORMATS: Record = { - '.pptx': { magic: ZIP_MAGIC, taskId: 'pptx-generate', contentType: PPTX_MIME }, - '.docx': { magic: ZIP_MAGIC, taskId: 'docx-generate', contentType: DOCX_MIME }, - '.pdf': { magic: PDF_MAGIC, taskId: 'pdf-generate', contentType: PDF_MIME }, +const MAX_COMPILED_DOC_CACHE = 10 +interface CompiledDocCacheEntry { + buffer: Buffer + contributingFiles?: readonly WorkspaceFileSecretProvenanceIdentity[] } -const MAX_COMPILED_DOC_CACHE = 10 -const compiledDocCache = new Map() +const compiledDocCache = new Map() -function compiledCacheSet(key: string, buffer: Buffer): void { +function compiledCacheSet( + key: string, + buffer: Buffer, + contributingFiles: readonly WorkspaceFileSecretProvenanceIdentity[] = [] +): void { if (compiledDocCache.size >= MAX_COMPILED_DOC_CACHE) { compiledDocCache.delete(compiledDocCache.keys().next().value as string) } - compiledDocCache.set(key, buffer) + compiledDocCache.set(key, { + buffer, + ...(contributingFiles.length > 0 ? { contributingFiles } : {}), + }) } /** @@ -512,7 +708,7 @@ export async function resolveServableDocBytes(args: { workspaceId: string | undefined ownerKey?: string signal?: AbortSignal -}): Promise<{ buffer: Buffer; contentType: string }> { +}): Promise { const { rawBuffer, fileName, workspaceId, ownerKey, signal } = args const ext = fileName.slice(fileName.lastIndexOf('.')).toLowerCase() const extNoDot = ext.replace(/^\./, '') @@ -532,13 +728,12 @@ export async function resolveServableDocBytes(args: { const source = rawBuffer.toString('utf-8') if (workspaceId) { - const stored = await loadCompiledDocByExt(workspaceId, source, extNoDot) - if (stored) { - return { buffer: stored.buffer, contentType: stored.contentType } - } - if (isDocSandboxEnabled && (await getE2BDocFormat(fileName))) { - throw new DocCompileUserError('Document is still being generated') + if (!isDocSandboxEnabled || collectReferencedFileIds(source).size > 0) { + return compileDoc({ source, fileName, workspaceId, ownerKey, signal }) } + const stored = await loadCompiledDocByExt(workspaceId, source, extNoDot) + if (stored) return stored + throw new DocCompileUserError('Document is still being generated') } // Reaches here only for xlsx, which has no isolated-vm fallback. Returning these @@ -548,7 +743,13 @@ export async function resolveServableDocBytes(args: { const cacheKey = sha256Hex(`${ext}${source}${workspaceId ?? ''}`) const cached = compiledDocCache.get(cacheKey) if (cached) { - return { buffer: cached, contentType: format.contentType } + return { + buffer: cached.buffer, + contentType: format.contentType, + ...(cached.contributingFiles && cached.contributingFiles.length > 0 + ? { contributingFiles: cached.contributingFiles } + : {}), + } } const compiled = await runSandboxTask( diff --git a/apps/sim/lib/copilot/tools/server/files/doc-compiled-store.ts b/apps/sim/lib/copilot/tools/server/files/doc-compiled-store.ts index b0c8242bde5..b25921868a5 100644 --- a/apps/sim/lib/copilot/tools/server/files/doc-compiled-store.ts +++ b/apps/sim/lib/copilot/tools/server/files/doc-compiled-store.ts @@ -10,15 +10,23 @@ const logger = createLogger('CopilotDocCompiledStore') * * The Python doc path keeps the SOURCE as the primary file (the agent reads and * edits it exactly like the JS path). The compiled binary is stored as its own - * S3 object, content-addressed by (workspaceId, sha256(source), ext) — the hash - * is in the key, so when the source changes the key changes. Every read path + * S3 object, content-addressed by (workspaceId, sha256(source + referenced-input identity), ext) — + * the hash is in the key, so source or referenced-file content changes invalidate the artifact. Every read path * (serve, preview, /compiled) loads the artifact for the current source hash and * recompiles only when it is absent. No fileId in the key means any site with * the source (e.g. the serve route) can find it. S3 is cheap; stale artifacts * are inert. */ -function compiledArtifactKey(workspaceId: string, source: string, ext: string): string { - const hash = createHash('sha256').update(source, 'utf-8').digest('hex') +function compiledArtifactKey( + workspaceId: string, + source: string, + ext: string, + referencedInputIdentity?: string +): string { + const cacheInput = referencedInputIdentity + ? JSON.stringify({ version: 1, source, referencedInputIdentity }) + : source + const hash = createHash('sha256').update(cacheInput, 'utf-8').digest('hex') return `copilot-doc-compiled/${workspaceId}/${hash}.${ext}` } @@ -26,9 +34,10 @@ function compiledArtifactKey(workspaceId: string, source: string, ext: string): export async function loadCompiledDoc( workspaceId: string, source: string, - ext: string + ext: string, + referencedInputIdentity?: string ): Promise { - const key = compiledArtifactKey(workspaceId, source, ext) + const key = compiledArtifactKey(workspaceId, source, ext, referencedInputIdentity) try { return await downloadFile({ key, context: 'copilot' }) } catch { @@ -49,9 +58,10 @@ export async function storeCompiledDoc( source: string, ext: string, contentType: string, - binary: Buffer + binary: Buffer, + referencedInputIdentity?: string ): Promise { - const key = compiledArtifactKey(workspaceId, source, ext) + const key = compiledArtifactKey(workspaceId, source, ext, referencedInputIdentity) try { await uploadFile({ file: binary, diff --git a/apps/sim/lib/copilot/tools/server/files/doc-servable.test.ts b/apps/sim/lib/copilot/tools/server/files/doc-servable.test.ts index 17d4ec964af..b428fa0c73a 100644 --- a/apps/sim/lib/copilot/tools/server/files/doc-servable.test.ts +++ b/apps/sim/lib/copilot/tools/server/files/doc-servable.test.ts @@ -4,13 +4,24 @@ import { resetEnvFlagsMock, setEnvFlags } from '@sim/testing' import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest' -const { mockLoadCompiledDoc, mockRunSandboxTask } = vi.hoisted(() => ({ +const { + mockExecuteInSandbox, + mockFetchWorkspaceFileBuffer, + mockGetWorkspaceFile, + mockLoadCompiledDoc, + mockRunSandboxTask, + mockStoreCompiledDoc, +} = vi.hoisted(() => ({ + mockExecuteInSandbox: vi.fn(), + mockFetchWorkspaceFileBuffer: vi.fn(), + mockGetWorkspaceFile: vi.fn(), mockLoadCompiledDoc: vi.fn(), mockRunSandboxTask: vi.fn(), + mockStoreCompiledDoc: vi.fn(), })) vi.mock('@/lib/execution/remote-sandbox', () => ({ - executeInSandbox: vi.fn(), + executeInSandbox: mockExecuteInSandbox, executeShellInSandbox: vi.fn(), })) vi.mock('@/lib/execution/languages', () => ({ @@ -20,12 +31,12 @@ vi.mock('@/lib/execution/sandbox/run-task', () => ({ runSandboxTask: mockRunSandboxTask, })) vi.mock('@/lib/uploads/contexts/workspace/workspace-file-manager', () => ({ - getWorkspaceFile: vi.fn(), - fetchWorkspaceFileBuffer: vi.fn(), + getWorkspaceFile: mockGetWorkspaceFile, + fetchWorkspaceFileBuffer: mockFetchWorkspaceFileBuffer, })) vi.mock('./doc-compiled-store', () => ({ loadCompiledDoc: mockLoadCompiledDoc, - storeCompiledDoc: vi.fn(), + storeCompiledDoc: mockStoreCompiledDoc, })) vi.mock('@/app/api/files/utils', () => ({ getContentType: (name: string) => @@ -36,7 +47,12 @@ vi.mock('@/app/api/files/utils', () => ({ : 'application/octet-stream', })) -import { DocCompileUserError, resolveServableDocBytes } from './doc-compile' +import { + compileDoc, + DocCompileUserError, + resolveServableDoc, + resolveServableDocBytes, +} from './doc-compile' const WORKSPACE_ID = '550e8400-e29b-41d4-a716-446655440000' const PDF_MAGIC = Buffer.from('%PDF-1.7\n...binary...') @@ -44,6 +60,13 @@ const PDF_SOURCE = Buffer.from('from reportlab.pdfgen import canvas\n# generates const ZIP_MAGIC = Buffer.from([0x50, 0x4b, 0x03, 0x04, 0x00, 0x01]) const XLSX_SOURCE = Buffer.from('from openpyxl import Workbook\n# generates an xlsx', 'utf-8') +function referencedFileSource(count: number): Buffer { + return Buffer.from( + Array.from({ length: count }, (_, index) => `getFileBase64('file-${index}')`).join('\n'), + 'utf-8' + ) +} + afterAll(resetEnvFlagsMock) describe('resolveServableDocBytes', () => { @@ -67,8 +90,10 @@ describe('resolveServableDocBytes', () => { expect(mockLoadCompiledDoc).toHaveBeenCalledWith( WORKSPACE_ID, PDF_SOURCE.toString('utf-8'), - 'pdf' + 'pdf', + undefined ) + expect(mockLoadCompiledDoc).toHaveBeenCalledTimes(1) }) it('passes through a real binary PDF (carries the %PDF magic) without an artifact lookup', async () => { @@ -83,6 +108,217 @@ describe('resolveServableDocBytes', () => { expect(mockLoadCompiledDoc).not.toHaveBeenCalled() }) + it('checks a missing no-reference public artifact only once', async () => { + mockLoadCompiledDoc.mockResolvedValue(null) + + await expect(resolveServableDoc(WORKSPACE_ID, PDF_SOURCE, 'report.pdf')).resolves.toEqual({ + kind: 'unavailable', + }) + expect(mockLoadCompiledDoc).toHaveBeenCalledTimes(1) + expect(mockLoadCompiledDoc).toHaveBeenCalledWith( + WORKSPACE_ID, + PDF_SOURCE.toString('utf-8'), + 'pdf', + undefined + ) + }) + + it('lazily rebuilds a legacy referenced-file document under its dependency-bound key', async () => { + const source = Buffer.from(`image = await getFileBase64('reference-1')`, 'utf-8') + const contentUpdatedAt = new Date('2026-08-05T01:00:00.000Z') + mockGetWorkspaceFile.mockResolvedValue({ + id: 'reference-1', + workspaceId: WORKSPACE_ID, + name: 'reference.png', + key: `workspace/${WORKSPACE_ID}/reference.png`, + path: '/api/files/serve/reference.png', + size: 10, + type: 'image/png', + uploadedBy: 'user-1', + uploadedAt: contentUpdatedAt, + updatedAt: contentUpdatedAt, + contentUpdatedAt, + storageContext: 'workspace', + }) + mockLoadCompiledDoc.mockResolvedValue(null) + mockFetchWorkspaceFileBuffer.mockResolvedValue(Buffer.from('image-bytes')) + mockExecuteInSandbox.mockResolvedValue({ + exportedFileContent: Buffer.from('%PDF-rebuilt').toString('base64'), + }) + + const result = await resolveServableDocBytes({ + rawBuffer: source, + fileName: 'report.pdf', + workspaceId: WORKSPACE_ID, + }) + + expect(result).toEqual({ + buffer: Buffer.from('%PDF-rebuilt'), + contentType: 'application/pdf', + contributingFiles: [ + { + fileId: 'reference-1', + key: `workspace/${WORKSPACE_ID}/reference.png`, + context: 'workspace', + contentUpdatedAt, + }, + ], + }) + expect(mockFetchWorkspaceFileBuffer).toHaveBeenCalledWith( + expect.objectContaining({ id: 'reference-1' }), + { maxBytes: 25 * 1024 * 1024 } + ) + expect(mockExecuteInSandbox).toHaveBeenCalledTimes(1) + expect(mockStoreCompiledDoc).toHaveBeenCalledWith( + WORKSPACE_ID, + source.toString('utf-8'), + 'pdf', + 'application/pdf', + Buffer.from('%PDF-rebuilt'), + expect.stringContaining('reference-1') + ) + }) + + it('keeps an existing public referenced document available through its legacy artifact', async () => { + const source = Buffer.from(`image = await getFileBase64('reference-1')`, 'utf-8') + const contentUpdatedAt = new Date('2026-08-05T01:00:00.000Z') + mockGetWorkspaceFile.mockResolvedValue({ + id: 'reference-1', + workspaceId: WORKSPACE_ID, + name: 'reference.png', + key: `workspace/${WORKSPACE_ID}/reference.png`, + path: '/api/files/serve/reference.png', + size: 10, + type: 'image/png', + uploadedBy: 'user-1', + uploadedAt: contentUpdatedAt, + updatedAt: contentUpdatedAt, + contentUpdatedAt, + storageContext: 'workspace', + }) + const legacyArtifact = Buffer.from('%PDF-legacy') + mockLoadCompiledDoc.mockResolvedValueOnce(null).mockResolvedValueOnce(legacyArtifact) + + await expect(resolveServableDoc(WORKSPACE_ID, source, 'report.pdf')).resolves.toEqual({ + kind: 'artifact', + buffer: legacyArtifact, + contentType: 'application/pdf', + }) + expect(mockLoadCompiledDoc).toHaveBeenNthCalledWith( + 1, + WORKSPACE_ID, + source.toString('utf-8'), + 'pdf', + expect.stringContaining('reference-1') + ) + expect(mockLoadCompiledDoc).toHaveBeenNthCalledWith( + 2, + WORKSPACE_ID, + source.toString('utf-8'), + 'pdf' + ) + expect(mockExecuteInSandbox).not.toHaveBeenCalled() + expect(mockStoreCompiledDoc).not.toHaveBeenCalled() + }) + + it('does not apply model-only provenance policy while serving a public legacy artifact', async () => { + const source = Buffer.from(`image = await getFileBase64('reference-1')`, 'utf-8') + const contentUpdatedAt = new Date('2026-08-05T01:00:00.000Z') + mockGetWorkspaceFile.mockResolvedValue({ + id: 'reference-1', + workspaceId: WORKSPACE_ID, + name: 'reference.png', + key: `workspace/${WORKSPACE_ID}/reference.png`, + path: '/api/files/serve/reference.png', + size: 10, + type: 'image/png', + uploadedBy: 'user-1', + uploadedAt: contentUpdatedAt, + updatedAt: contentUpdatedAt, + contentUpdatedAt, + storageContext: 'workspace', + }) + const legacyArtifact = Buffer.from('%PDF-legacy') + mockLoadCompiledDoc.mockResolvedValueOnce(null).mockResolvedValueOnce(legacyArtifact) + + await expect(resolveServableDoc(WORKSPACE_ID, source, 'report.pdf')).resolves.toEqual({ + kind: 'artifact', + buffer: legacyArtifact, + contentType: 'application/pdf', + }) + expect(mockLoadCompiledDoc).toHaveBeenCalledTimes(2) + expect(mockExecuteInSandbox).not.toHaveBeenCalled() + }) + + it('keeps a legacy artifact servable when a referenced file is missing', async () => { + const source = Buffer.from(`image = await getFileBase64('reference-1')`, 'utf-8') + mockGetWorkspaceFile.mockResolvedValue(null) + const legacyArtifact = Buffer.from('%PDF-legacy') + mockLoadCompiledDoc.mockResolvedValueOnce(null).mockResolvedValueOnce(legacyArtifact) + + await expect(resolveServableDoc(WORKSPACE_ID, source, 'report.pdf')).resolves.toEqual({ + kind: 'artifact', + buffer: legacyArtifact, + contentType: 'application/pdf', + }) + expect(mockLoadCompiledDoc).toHaveBeenNthCalledWith( + 1, + WORKSPACE_ID, + source.toString('utf-8'), + 'pdf', + expect.stringContaining('missing') + ) + expect(mockLoadCompiledDoc).toHaveBeenNthCalledWith( + 2, + WORKSPACE_ID, + source.toString('utf-8'), + 'pdf' + ) + expect(mockExecuteInSandbox).not.toHaveBeenCalled() + }) + + it('keeps a legacy artifact servable when a referenced-file lookup fails', async () => { + const source = Buffer.from(`image = await getFileBase64('reference-1')`, 'utf-8') + mockGetWorkspaceFile.mockRejectedValue(new Error('database unavailable')) + const legacyArtifact = Buffer.from('%PDF-legacy') + mockLoadCompiledDoc.mockResolvedValueOnce(null).mockResolvedValueOnce(legacyArtifact) + + await expect(resolveServableDoc(WORKSPACE_ID, source, 'report.pdf')).resolves.toEqual({ + kind: 'artifact', + buffer: legacyArtifact, + contentType: 'application/pdf', + }) + expect(mockLoadCompiledDoc).toHaveBeenNthCalledWith( + 1, + WORKSPACE_ID, + source.toString('utf-8'), + 'pdf', + expect.stringContaining('unavailable') + ) + expect(mockLoadCompiledDoc).toHaveBeenNthCalledWith( + 2, + WORKSPACE_ID, + source.toString('utf-8'), + 'pdf' + ) + expect(mockExecuteInSandbox).not.toHaveBeenCalled() + }) + + it('serves a legacy artifact with more than 20 references without resolving files', async () => { + const source = referencedFileSource(21) + const legacyArtifact = Buffer.from('%PDF-legacy') + mockLoadCompiledDoc.mockResolvedValue(legacyArtifact) + + await expect(resolveServableDoc(WORKSPACE_ID, source, 'report.pdf')).resolves.toEqual({ + kind: 'artifact', + buffer: legacyArtifact, + contentType: 'application/pdf', + }) + expect(mockGetWorkspaceFile).not.toHaveBeenCalled() + expect(mockLoadCompiledDoc).toHaveBeenCalledWith(WORKSPACE_ID, source.toString('utf-8'), 'pdf') + expect(mockExecuteInSandbox).not.toHaveBeenCalled() + }) + it('throws DocCompileUserError when a generated doc artifact is not ready (E2B regime)', async () => { mockLoadCompiledDoc.mockResolvedValue(null) setEnvFlags({ isDocSandboxEnabled: true }) @@ -119,6 +355,120 @@ describe('resolveServableDocBytes', () => { ) }) + it('does not couple isolated-vm compile success to durable artifact storage', async () => { + mockLoadCompiledDoc.mockResolvedValue(null) + setEnvFlags({ isDocSandboxEnabled: false }) + const compiled = Buffer.from('%PDF-isolated-vm-binary') + mockRunSandboxTask.mockResolvedValue(compiled) + + await expect( + resolveServableDocBytes({ + rawBuffer: PDF_SOURCE, + fileName: 'report.pdf', + workspaceId: WORKSPACE_ID, + }) + ).resolves.toEqual({ buffer: compiled, contentType: 'application/pdf' }) + expect(mockLoadCompiledDoc).not.toHaveBeenCalled() + expect(mockStoreCompiledDoc).not.toHaveBeenCalled() + }) + + it('preserves contributor identities when a servable document hits the local compile cache', async () => { + const source = 'const cacheContributor = true' + const compiled = Buffer.from('%PDF-cached-with-contributor') + const contentUpdatedAt = new Date('2026-08-06T01:00:00.000Z') + const contributor = { + fileId: 'reference-cache-1', + key: 'workspace/workspace-1/reference-cache-1.png', + context: 'workspace' as const, + contentUpdatedAt, + } + setEnvFlags({ isDocSandboxEnabled: false }) + mockRunSandboxTask.mockImplementationOnce((...args: unknown[]) => { + const options = args[2] as { + onWorkspaceFileAccess?: (identity: typeof contributor) => void + } + options.onWorkspaceFileAccess?.(contributor) + return Promise.resolve(compiled) + }) + + await expect(compileDoc({ source, fileName: 'report.pdf', workspaceId: '' })).resolves.toEqual({ + buffer: compiled, + contentType: 'application/pdf', + contributingFiles: [contributor], + }) + + await expect( + resolveServableDocBytes({ + rawBuffer: Buffer.from(source), + fileName: 'report.pdf', + workspaceId: undefined, + }) + ).resolves.toEqual({ + buffer: compiled, + contentType: 'application/pdf', + contributingFiles: [contributor], + }) + expect(mockRunSandboxTask).toHaveBeenCalledTimes(1) + }) + + it('keeps the isolated-vm fallback for referenced documents when E2B is disabled', async () => { + const source = Buffer.from(`const image = getFileBase64('reference-1')`, 'utf-8') + const contentUpdatedAt = new Date('2026-08-05T01:00:00.000Z') + setEnvFlags({ isDocSandboxEnabled: false }) + mockGetWorkspaceFile.mockResolvedValue({ + id: 'reference-1', + workspaceId: WORKSPACE_ID, + name: 'reference.png', + key: `workspace/${WORKSPACE_ID}/reference.png`, + path: '/api/files/serve/reference.png', + size: 10, + type: 'image/png', + uploadedBy: 'user-1', + uploadedAt: contentUpdatedAt, + updatedAt: contentUpdatedAt, + contentUpdatedAt, + storageContext: 'workspace', + }) + mockLoadCompiledDoc.mockResolvedValue(null) + const compiled = Buffer.from('%PDF-isolated-vm-referenced') + mockRunSandboxTask.mockResolvedValue(compiled) + + const result = await resolveServableDocBytes({ + rawBuffer: source, + fileName: 'report.pdf', + workspaceId: WORKSPACE_ID, + }) + + expect(result).toEqual({ buffer: compiled, contentType: 'application/pdf' }) + expect(mockExecuteInSandbox).not.toHaveBeenCalled() + expect(mockRunSandboxTask).toHaveBeenCalledWith( + 'pdf-generate', + { code: source.toString('utf-8'), workspaceId: WORKSPACE_ID }, + expect.objectContaining({}) + ) + expect(mockGetWorkspaceFile).not.toHaveBeenCalled() + expect(mockLoadCompiledDoc).not.toHaveBeenCalled() + expect(mockStoreCompiledDoc).not.toHaveBeenCalled() + }) + + it('does not apply the remote 20-reference staging limit to isolated-vm documents', async () => { + const source = referencedFileSource(21) + setEnvFlags({ isDocSandboxEnabled: false }) + const compiled = Buffer.from('%PDF-isolated-vm-many-references') + mockRunSandboxTask.mockResolvedValue(compiled) + + await expect( + resolveServableDocBytes({ + rawBuffer: source, + fileName: 'report.pdf', + workspaceId: WORKSPACE_ID, + }) + ).resolves.toEqual({ buffer: compiled, contentType: 'application/pdf' }) + expect(mockRunSandboxTask).toHaveBeenCalledTimes(1) + expect(mockGetWorkspaceFile).not.toHaveBeenCalled() + expect(mockStoreCompiledDoc).not.toHaveBeenCalled() + }) + it('passes non-doc files through untouched with their extension content type', async () => { const text = Buffer.from('hello world', 'utf-8') const result = await resolveServableDocBytes({ diff --git a/apps/sim/lib/copilot/tools/server/files/workspace-file.ts b/apps/sim/lib/copilot/tools/server/files/workspace-file.ts index 2215e414f39..8b6c0aaf4a5 100644 --- a/apps/sim/lib/copilot/tools/server/files/workspace-file.ts +++ b/apps/sim/lib/copilot/tools/server/files/workspace-file.ts @@ -188,9 +188,9 @@ export type CompileForWriteResult = * Shared write-time doc handling for create + edit_content: validates and builds * the document (E2B doc sandbox when enabled — Node pptx/docx, Python pdf/xlsx — * else isolated-vm JS) and returns the source MIME to store, or a user-facing - * failure message. Non-doc files resolve to `fallbackMime`. Compilation happens - * here exactly once per write; the artifact is content-addressed so a read can - * later just load it. + * failure message. Non-doc files resolve to `fallbackMime`. The remote backend publishes a + * content-addressed artifact; the isolated-VM backend compiles through its live file broker and + * retains its historical compile-and-return behavior. */ export async function compileDocForWrite(args: { source: string @@ -213,10 +213,8 @@ export async function compileDocForWrite(args: { } if (e2bFmt) { - // compileDoc is load-or-build, so an identical re-write reuses the cached - // binary instead of re-running E2B. try { - await compileDoc({ source, fileName, workspaceId }) + await compileDoc({ source, fileName, workspaceId, ownerKey, signal }) } catch (err) { if (err instanceof DocCompileUserError) { return { diff --git a/apps/sim/lib/copilot/tools/server/image/generate-image.ts b/apps/sim/lib/copilot/tools/server/image/generate-image.ts index ea1eb645abe..9e06870efca 100644 --- a/apps/sim/lib/copilot/tools/server/image/generate-image.ts +++ b/apps/sim/lib/copilot/tools/server/image/generate-image.ts @@ -9,7 +9,6 @@ import { } from '@/lib/copilot/tools/server/base-tool' import { assertOpaqueWorkspaceFileModelSafe, - projectServerToolModelInput, ServerToolModelInputError, } from '@/lib/copilot/tools/server/model-input' import { writeWorkspaceFileByPath } from '@/lib/copilot/vfs/resource-writer' @@ -78,7 +77,7 @@ export const generateImageServerTool: BaseServerTool ({ checkAttributedUsageLimits: vi.fn(), serializeBillingAttributionHeader: mockSerializeBillingAttributionHeader, })) +vi.mock('@/lib/core/security/encryption', () => ({ + decryptSecret: encryptionMockFns.mockDecryptSecret, +})) vi.mock('@/lib/copilot/generated/tool-catalog-v1', () => ({ KnowledgeBase: { id: 'knowledge_base' }, })) @@ -134,6 +138,7 @@ describe('knowledge base connector Copilot operations', () => { beforeEach(() => { vi.clearAllMocks() + encryptionMockFns.mockDecryptSecret.mockReset() resetDbChainMock() vi.stubGlobal('fetch', mockFetch) queueTableRows(knowledgeConnector, [{ knowledgeBaseId: 'knowledge-base-1' }]) @@ -207,6 +212,7 @@ describe('knowledge base connector Copilot operations', () => { describe('knowledge base query model boundary', () => { beforeEach(() => { vi.clearAllMocks() + encryptionMockFns.mockDecryptSecret.mockReset() resetDbChainMock() vi.mocked(checkKnowledgeBaseAccess).mockResolvedValue({ hasAccess: true }) vi.mocked(getKnowledgeBaseById).mockResolvedValue({ @@ -228,7 +234,7 @@ describe('knowledge base query model boundary', () => { }) }) - it('projects the query at embedding, search, and usage boundaries', async () => { + it('preserves a query that merely collides with ambient secret plaintext', async () => { const registry = new ResolvedSecretTraceRegistry([ { name: 'KB_QUERY', @@ -257,15 +263,15 @@ describe('knowledge base query model boundary', () => { expect(result.success).toBe(true) expect(result.data?.query).toBe('private knowledge query') expect(generateSearchEmbedding).toHaveBeenCalledWith( - '{{KB_QUERY}}', + 'private knowledge query', 'text-embedding-3-small', 'workspace-paid' ) expect(executeKnowledgeSearch).toHaveBeenCalledWith( - expect.objectContaining({ query: '{{KB_QUERY}}' }) + expect.objectContaining({ query: 'private knowledge query' }) ) expect(recordSearchEmbeddingUsage).toHaveBeenCalledWith( - expect.objectContaining({ query: '{{KB_QUERY}}' }) + expect.objectContaining({ query: 'private knowledge query' }) ) expect(mockImportKnowledgeSearchResultSecretProvenance).toHaveBeenCalledWith({ registry, @@ -295,9 +301,19 @@ describe('knowledge base query model boundary', () => { }, ] vi.mocked(executeKnowledgeSearch).mockResolvedValue(results) + encryptionMockFns.mockDecryptSecret.mockResolvedValue({ decrypted: 'stored-secret-value' }) mockImportKnowledgeSearchResultSecretProvenance.mockImplementationOnce( async ({ registry: resultRegistry }) => { - expect(resultRegistry.recordResolved('STORED_TOKEN', 'stored-secret-value')).toBe(true) + expect( + await resultRegistry.importProvenance( + { + version: 1, + complete: true, + entries: [{ name: 'STORED_TOKEN', encryptedValue: 'encrypted-stored-secret' }], + }, + { trusted: true } + ) + ).toBe(true) return { imported: true, documentMetadata: {} } } ) diff --git a/apps/sim/lib/copilot/tools/server/knowledge/knowledge-base.ts b/apps/sim/lib/copilot/tools/server/knowledge/knowledge-base.ts index 0ae1b2d4cef..d88ce57e8fd 100644 --- a/apps/sim/lib/copilot/tools/server/knowledge/knowledge-base.ts +++ b/apps/sim/lib/copilot/tools/server/knowledge/knowledge-base.ts @@ -21,7 +21,6 @@ import { type BaseServerTool, type ServerToolContext, } from '@/lib/copilot/tools/server/base-tool' -import { projectServerToolModelInput } from '@/lib/copilot/tools/server/model-input' import { getInternalApiBaseUrl } from '@/lib/core/utils/urls' import { createSingleDocument, @@ -264,7 +263,7 @@ export const knowledgeBaseServerTool: BaseServerTool ({ + createWorkspaceFileSecretProvenanceFromRegistryMock: vi.fn(), + fetchWorkspaceFileBufferMock: vi.fn(), + getBoundWorkspaceFileSecretProvenanceMock: vi.fn(), + mergeWorkspaceFileSecretProvenanceMock: vi.fn(), + resolveWorkspaceFileReferenceMock: vi.fn(), + runFfmpegOperationMock: vi.fn(), + writeWorkspaceFileByPathMock: vi.fn(), +})) + +vi.mock('@/lib/copilot/generated/tool-catalog-v1', () => ({ + Ffmpeg: { id: 'ffmpeg' }, +})) + +vi.mock('@/lib/copilot/vfs/resource-writer', () => ({ + writeWorkspaceFileByPath: writeWorkspaceFileByPathMock, +})) + +vi.mock('@/lib/media/ffmpeg', () => ({ + runFfmpegOperation: runFfmpegOperationMock, +})) + +vi.mock('@/lib/uploads/contexts/workspace/workspace-file-manager', () => ({ + fetchWorkspaceFileBuffer: fetchWorkspaceFileBufferMock, + resolveWorkspaceFileReference: resolveWorkspaceFileReferenceMock, +})) + +vi.mock('@/lib/uploads/contexts/workspace/workspace-file-secret-provenance', () => ({ + createWorkspaceFileSecretProvenanceFromRegistry: + createWorkspaceFileSecretProvenanceFromRegistryMock, + getBoundWorkspaceFileSecretProvenance: getBoundWorkspaceFileSecretProvenanceMock, + mergeWorkspaceFileSecretProvenance: mergeWorkspaceFileSecretProvenanceMock, +})) + +import { ffmpegServerTool } from '@/lib/copilot/tools/server/media/ffmpeg' + +const EXACT_EMPTY = { status: 'exact' as const, entries: [] } +const TRACKED = { + status: 'exact' as const, + entries: [ + { + name: 'MEDIA_SECRET', + encryptedValue: 'encrypted-media-secret', + sourceUserId: 'user-1', + sourceWorkspaceId: 'workspace-1', + }, + ], +} +const TEXT_PROVENANCE = { + status: 'exact' as const, + entries: [ + { + name: 'CAPTION', + encryptedValue: 'encrypted-caption', + sourceUserId: 'user-1', + sourceWorkspaceId: 'workspace-1', + }, + ], +} + +const file = { + id: 'file-1', + workspaceId: 'workspace-1', + name: 'input.mp4', + key: 'workspace/workspace-1/input.mp4', + path: '/api/files/serve/input.mp4', + size: 10, + type: 'video/mp4', + uploadedBy: 'user-1', + uploadedAt: new Date('2026-08-05T00:00:00.000Z'), + updatedAt: new Date('2026-08-05T00:00:00.000Z'), + storageContext: 'workspace' as const, +} + +function mergeProvenance( + ...values: WorkspaceFileSecretProvenance[] +): WorkspaceFileSecretProvenance { + if (values.some((value) => value.status === 'unknown')) return { status: 'unknown' } + return { + status: 'exact', + entries: values.flatMap((value) => (value.status === 'exact' ? value.entries : [])), + } +} + +describe('ffmpeg server tool secret provenance', () => { + const registry = new ResolvedSecretTraceRegistry([], { + userId: 'user-1', + workspaceId: 'workspace-1', + }) + const context = { + userId: 'user-1', + workspaceId: 'workspace-1', + resolvedSecretTraceRegistry: registry, + } + + beforeEach(() => { + vi.clearAllMocks() + resolveWorkspaceFileReferenceMock.mockResolvedValue(file) + fetchWorkspaceFileBufferMock.mockResolvedValue(Buffer.from('media')) + getBoundWorkspaceFileSecretProvenanceMock.mockResolvedValue(EXACT_EMPTY) + mergeWorkspaceFileSecretProvenanceMock.mockImplementation(mergeProvenance) + createWorkspaceFileSecretProvenanceFromRegistryMock.mockResolvedValue({ + safe: true, + provenance: EXACT_EMPTY, + }) + runFfmpegOperationMock.mockResolvedValue({ + buffer: Buffer.from('output'), + ext: 'mp4', + contentType: 'video/mp4', + }) + writeWorkspaceFileByPathMock.mockResolvedValue({ + id: 'output-1', + name: 'converted.mp4', + vfsPath: 'files/converted.mp4', + downloadUrl: '/api/files/serve/converted.mp4', + mode: 'create', + }) + }) + + it('preserves exact input provenance on transformed output', async () => { + getBoundWorkspaceFileSecretProvenanceMock.mockResolvedValue(TRACKED) + + const result = await ffmpegServerTool.execute( + { + operation: 'convert', + inputs: { files: [{ path: 'files/input.mp4' }] }, + outputs: { files: [{ path: 'files/converted.mp4' }] }, + }, + context + ) + + expect(result.success).toBe(true) + expect(writeWorkspaceFileByPathMock).toHaveBeenCalledWith( + expect.objectContaining({ secretProvenance: TRACKED }) + ) + }) + + it('preserves unknown input provenance without blocking the trusted local transform', async () => { + getBoundWorkspaceFileSecretProvenanceMock.mockResolvedValue({ status: 'unknown' }) + + const result = await ffmpegServerTool.execute( + { operation: 'convert', inputs: { files: [{ path: 'files/input.mp4' }] } }, + context + ) + + expect(result.success).toBe(true) + expect(fetchWorkspaceFileBufferMock).toHaveBeenCalledWith(file) + expect(runFfmpegOperationMock).toHaveBeenCalled() + expect(writeWorkspaceFileByPathMock).toHaveBeenCalledWith( + expect.objectContaining({ secretProvenance: { status: 'unknown' } }) + ) + }) + + it('adds resolved caption provenance to add_text outputs', async () => { + createWorkspaceFileSecretProvenanceFromRegistryMock.mockResolvedValue({ + safe: true, + provenance: TEXT_PROVENANCE, + }) + + const result = await ffmpegServerTool.execute( + { + operation: 'add_text', + inputs: { files: [{ path: 'files/input.mp4' }] }, + text: 'private caption', + }, + context + ) + + expect(result.success).toBe(true) + expect(createWorkspaceFileSecretProvenanceFromRegistryMock).toHaveBeenCalledWith( + registry, + 'private caption', + { userId: 'user-1', workspaceId: 'workspace-1' } + ) + expect(writeWorkspaceFileByPathMock).toHaveBeenCalledWith( + expect.objectContaining({ secretProvenance: TEXT_PROVENANCE }) + ) + }) + + it('does not taint operations that ignore the text parameter', async () => { + const result = await ffmpegServerTool.execute( + { + operation: 'convert', + inputs: { files: [{ path: 'files/input.mp4' }] }, + text: 'ignored caption', + }, + context + ) + + expect(result.success).toBe(true) + expect(createWorkspaceFileSecretProvenanceFromRegistryMock).not.toHaveBeenCalled() + expect(writeWorkspaceFileByPathMock).toHaveBeenCalledWith( + expect.objectContaining({ secretProvenance: EXACT_EMPTY }) + ) + }) + + it('probes tracked inputs without writing an unclassified output file', async () => { + getBoundWorkspaceFileSecretProvenanceMock.mockResolvedValue(TRACKED) + runFfmpegOperationMock.mockResolvedValue({ + probe: { + durationSeconds: 1, + format: 'mov,mp4', + hasAudio: true, + hasVideo: true, + }, + }) + + const result = await ffmpegServerTool.execute( + { operation: 'probe', inputs: { files: [{ path: 'files/input.mp4' }] } }, + context + ) + + expect(result.success).toBe(true) + expect(writeWorkspaceFileByPathMock).not.toHaveBeenCalled() + }) + + it('does not expose parser errors from tracked or unknown media bytes', async () => { + getBoundWorkspaceFileSecretProvenanceMock.mockResolvedValue(TRACKED) + runFfmpegOperationMock.mockRejectedValue(new Error('decoder echoed private bytes')) + + const result = await ffmpegServerTool.execute( + { operation: 'convert', inputs: { files: [{ path: 'files/input.mp4' }] } }, + context + ) + + expect(result).toEqual({ + success: false, + message: 'ffmpeg convert failed: The media operation failed safely', + }) + }) + + it('does not expose storage errors after learning that an input has tracked provenance', async () => { + getBoundWorkspaceFileSecretProvenanceMock.mockResolvedValue(TRACKED) + fetchWorkspaceFileBufferMock.mockRejectedValue(new Error('storage echoed private bytes')) + + const result = await ffmpegServerTool.execute( + { operation: 'convert', inputs: { files: [{ path: 'files/input.mp4' }] } }, + context + ) + + expect(result).toEqual({ + success: false, + message: 'ffmpeg convert failed: The media operation failed safely', + }) + expect(runFfmpegOperationMock).not.toHaveBeenCalled() + }) + + it('projects dynamic parser errors before returning them', async () => { + const secret = 'decoder-secret' + const errorRegistry = new ResolvedSecretTraceRegistry([ + { name: 'DECODER_SECRET', plaintext: secret, encryptedValue: 'encrypted-decoder-secret' }, + ]) + errorRegistry.recordResolved('DECODER_SECRET', secret) + runFfmpegOperationMock.mockRejectedValue(new Error(`decoder failed near ${secret}`)) + + const result = await ffmpegServerTool.execute( + { operation: 'convert', inputs: { files: [{ path: 'files/input.mp4' }] } }, + { ...context, resolvedSecretTraceRegistry: errorRegistry } + ) + + expect(result).toEqual({ + success: false, + message: 'ffmpeg convert failed: decoder failed near {{DECODER_SECRET}}', + }) + }) + + it('uses the fixed safe message directly when no dynamic error text exists', async () => { + const errorRegistry = new ResolvedSecretTraceRegistry([ + { name: 'T_SECRET', plaintext: 'T', encryptedValue: 'encrypted-t' }, + ]) + errorRegistry.recordResolved('T_SECRET', 'T') + runFfmpegOperationMock.mockRejectedValue(new Error('')) + + const result = await ffmpegServerTool.execute( + { operation: 'convert', inputs: { files: [{ path: 'files/input.mp4' }] } }, + { ...context, resolvedSecretTraceRegistry: errorRegistry } + ) + + expect(result).toEqual({ + success: false, + message: 'ffmpeg convert failed: The media operation failed safely', + }) + }) +}) diff --git a/apps/sim/lib/copilot/tools/server/media/ffmpeg.ts b/apps/sim/lib/copilot/tools/server/media/ffmpeg.ts index fa2e1381221..1d18d2057a6 100644 --- a/apps/sim/lib/copilot/tools/server/media/ffmpeg.ts +++ b/apps/sim/lib/copilot/tools/server/media/ffmpeg.ts @@ -12,8 +12,16 @@ import { fetchWorkspaceFileBuffer, resolveWorkspaceFileReference, } from '@/lib/uploads/contexts/workspace/workspace-file-manager' +import { + createWorkspaceFileSecretProvenanceFromRegistry, + getBoundWorkspaceFileSecretProvenance, + mergeWorkspaceFileSecretProvenance, + type WorkspaceFileSecretProvenance, +} from '@/lib/uploads/contexts/workspace/workspace-file-secret-provenance' +import { projectResolvedSecretModelContent } from '@/executor/utils/resolved-secret-content-projection' const logger = createLogger('FfmpegTool') +const MEDIA_OPERATION_FAILED_SAFELY = 'The media operation failed safely' const VALID_OPERATIONS: FfmpegOperation[] = [ 'overlay_audio', @@ -80,13 +88,23 @@ export const ffmpegServerTool: BaseServerTool = { return { success: false, message: 'At least one input file is required in inputs.files' } } + let inputRequiresOpaqueError = false try { const mediaFiles: MediaFile[] = [] + const inputProvenances: WorkspaceFileSecretProvenance[] = [] for (const filePath of inputPaths) { const fileRecord = await resolveWorkspaceFileReference(workspaceId, filePath) if (!fileRecord) { return { success: false, message: `Input file not found: ${filePath}` } } + const fileProvenance = await getBoundWorkspaceFileSecretProvenance(workspaceId, { + fileId: fileRecord.id, + key: fileRecord.key, + context: fileRecord.storageContext ?? 'workspace', + }) + inputRequiresOpaqueError ||= + fileProvenance.status === 'unknown' || fileProvenance.entries.length > 0 + inputProvenances.push(fileProvenance) const buffer = await fetchWorkspaceFileBuffer(fileRecord) mediaFiles.push({ buffer, @@ -95,6 +113,9 @@ export const ffmpegServerTool: BaseServerTool = { }) } + const inputProvenance = mergeWorkspaceFileSecretProvenance(...inputProvenances) + inputRequiresOpaqueError ||= + inputProvenance.status === 'unknown' || inputProvenance.entries.length > 0 assertServerToolNotAborted(context) const result = await runFfmpegOperation(params.operation, mediaFiles, { text: params.text, @@ -126,6 +147,18 @@ export const ffmpegServerTool: BaseServerTool = { const outputFile = params.outputs?.files?.[0] const outputPath = outputFile?.path || `files/ffmpeg-${params.operation}.${result.ext}` const mode = outputFile?.mode ?? 'create' + let outputProvenance = inputProvenance + if (params.operation === 'add_text' && params.text !== undefined) { + const textProvenance = await createWorkspaceFileSecretProvenanceFromRegistry( + context.resolvedSecretTraceRegistry, + params.text, + { userId: context.userId, workspaceId } + ) + outputProvenance = mergeWorkspaceFileSecretProvenance( + outputProvenance, + textProvenance.safe ? textProvenance.provenance : { status: 'unknown' as const } + ) + } assertServerToolNotAborted(context) const written = await writeWorkspaceFileByPath({ @@ -134,6 +167,7 @@ export const ffmpegServerTool: BaseServerTool = { target: { path: outputPath, mode, mimeType: outputFile?.mimeType }, buffer: result.buffer, inferredMimeType: result.contentType || 'application/octet-stream', + secretProvenance: outputProvenance, }) logger.info('ffmpeg operation completed', { @@ -151,9 +185,18 @@ export const ffmpegServerTool: BaseServerTool = { downloadUrl: written.downloadUrl, } } catch (error) { - const msg = getErrorMessage(error, 'Unknown error') - logger.error('ffmpeg operation failed', { operation: params.operation, error: msg }) - return { success: false, message: `ffmpeg ${params.operation} failed: ${msg}` } + const errorMessage = getErrorMessage(error, '') + const projection = inputRequiresOpaqueError + ? undefined + : errorMessage + ? projectResolvedSecretModelContent(errorMessage, context.resolvedSecretTraceRegistry) + : undefined + const message = + projection?.safe && typeof projection.value === 'string' && projection.value.length > 0 + ? projection.value + : MEDIA_OPERATION_FAILED_SAFELY + logger.error('ffmpeg operation failed', { operation: params.operation, error: message }) + return { success: false, message: `ffmpeg ${params.operation} failed: ${message}` } } }, } diff --git a/apps/sim/lib/copilot/tools/server/media/generate-audio.ts b/apps/sim/lib/copilot/tools/server/media/generate-audio.ts index d13a6669019..de112c457ab 100644 --- a/apps/sim/lib/copilot/tools/server/media/generate-audio.ts +++ b/apps/sim/lib/copilot/tools/server/media/generate-audio.ts @@ -6,10 +6,7 @@ import { type BaseServerTool, type ServerToolContext, } from '@/lib/copilot/tools/server/base-tool' -import { - assertOpaqueWorkspaceFileModelSafe, - projectServerToolModelInput, -} from '@/lib/copilot/tools/server/model-input' +import { assertOpaqueWorkspaceFileModelSafe } from '@/lib/copilot/tools/server/model-input' import { writeWorkspaceFileByPath } from '@/lib/copilot/vfs/resource-writer' import { type AudioType, generateFalAudio } from '@/lib/media/falai-audio' import { @@ -85,11 +82,6 @@ export const generateAudioServerTool: BaseServerTool ({ @@ -16,7 +16,7 @@ const { mockGenerateContent: vi.fn(), mockGenerateFalAudio: vi.fn(), mockGenerateFalVideo: vi.fn(), - mockImportWorkspaceFileSecretProvenanceForValue: vi.fn(), + mockIsOpaqueWorkspaceFileEgressSafe: vi.fn(), mockResolveWorkspaceFileReference: vi.fn(), mockWriteWorkspaceFileByPath: vi.fn(), })) @@ -37,7 +37,7 @@ vi.mock('@/lib/uploads/contexts/workspace/workspace-file-manager', () => ({ resolveWorkspaceFileReference: mockResolveWorkspaceFileReference, })) vi.mock('@/lib/uploads/contexts/workspace/workspace-file-secret-provenance', () => ({ - importWorkspaceFileSecretProvenanceForValue: mockImportWorkspaceFileSecretProvenanceForValue, + isOpaqueWorkspaceFileEgressSafe: mockIsOpaqueWorkspaceFileEgressSafe, MODEL_UNSAFE_WORKSPACE_FILE_ERROR_MESSAGE: 'File cannot be sent to a model because its secret provenance is unavailable', })) @@ -79,7 +79,7 @@ function contextWithSecrets( describe('Mothership media model boundaries', () => { beforeEach(() => { vi.clearAllMocks() - mockImportWorkspaceFileSecretProvenanceForValue.mockResolvedValue(true) + mockIsOpaqueWorkspaceFileEgressSafe.mockResolvedValue(true) mockResolveWorkspaceFileReference.mockResolvedValue(file) mockFetchWorkspaceFileBuffer.mockResolvedValue(Buffer.from('opaque-media')) mockWriteWorkspaceFileByPath.mockResolvedValue({ @@ -110,7 +110,7 @@ describe('Mothership media model boundaries', () => { }) }) - it('projects image prompts immediately before the Gemini call', async () => { + it('preserves image prompts that merely collide with ambient secret plaintext', async () => { const context = contextWithSecrets([{ name: 'PROMPT', plaintext: 'private prompt' }]) await generateImageServerTool.execute({ prompt: 'private prompt' }, context) @@ -119,15 +119,16 @@ describe('Mothership media model boundaries', () => { expect.objectContaining({ contents: [ expect.objectContaining({ - parts: [expect.objectContaining({ text: expect.stringContaining('{{PROMPT}}') })], + parts: [expect.objectContaining({ text: expect.stringContaining('private prompt') })], }), ], }) ) - expect(JSON.stringify(mockGenerateContent.mock.calls[0]?.[0])).not.toContain('private prompt') + expect(JSON.stringify(mockGenerateContent.mock.calls[0]?.[0])).toContain('private prompt') + expect(JSON.stringify(mockGenerateContent.mock.calls[0]?.[0])).not.toContain('{{PROMPT}}') }) - it('projects video prompt fields immediately before the Fal call', async () => { + it('preserves video prompt fields that merely collide with ambient secret plaintext', async () => { const context = contextWithSecrets([ { name: 'PROMPT', plaintext: 'private prompt' }, { name: 'NEGATIVE', plaintext: 'private negative prompt' }, @@ -139,11 +140,14 @@ describe('Mothership media model boundaries', () => { ) expect(mockGenerateFalVideo).toHaveBeenCalledWith( - expect.objectContaining({ prompt: '{{PROMPT}}', negativePrompt: '{{NEGATIVE}}' }) + expect.objectContaining({ + prompt: 'private prompt', + negativePrompt: 'private negative prompt', + }) ) }) - it('projects audio prompt fields immediately before the Fal call', async () => { + it('preserves audio prompt fields that merely collide with ambient secret plaintext', async () => { const context = contextWithSecrets([ { name: 'PROMPT', plaintext: 'private prompt' }, { name: 'LYRICS', plaintext: 'private lyrics' }, @@ -155,7 +159,7 @@ describe('Mothership media model boundaries', () => { ) expect(mockGenerateFalAudio).toHaveBeenCalledWith( - expect.objectContaining({ prompt: '{{PROMPT}}', lyrics: '{{LYRICS}}' }) + expect.objectContaining({ prompt: 'private prompt', lyrics: 'private lyrics' }) ) }) @@ -187,7 +191,7 @@ describe('Mothership media model boundaries', () => { ])( 'rejects unsafe %s references before fetching bytes or calling a model', async (_name, run) => { - mockImportWorkspaceFileSecretProvenanceForValue.mockResolvedValue(false) + mockIsOpaqueWorkspaceFileEgressSafe.mockResolvedValue(false) await expect(run()).resolves.toEqual( expect.objectContaining({ diff --git a/apps/sim/lib/copilot/tools/server/model-input.test.ts b/apps/sim/lib/copilot/tools/server/model-input.test.ts index ac4daaaf882..19692d6dee4 100644 --- a/apps/sim/lib/copilot/tools/server/model-input.test.ts +++ b/apps/sim/lib/copilot/tools/server/model-input.test.ts @@ -3,21 +3,17 @@ */ import { beforeEach, describe, expect, it, vi } from 'vitest' -const { mockImportWorkspaceFileSecretProvenanceForValue } = vi.hoisted(() => ({ - mockImportWorkspaceFileSecretProvenanceForValue: vi.fn(), +const { mockIsOpaqueWorkspaceFileEgressSafe } = vi.hoisted(() => ({ + mockIsOpaqueWorkspaceFileEgressSafe: vi.fn(), })) vi.mock('@/lib/uploads/contexts/workspace/workspace-file-secret-provenance', () => ({ - importWorkspaceFileSecretProvenanceForValue: mockImportWorkspaceFileSecretProvenanceForValue, + isOpaqueWorkspaceFileEgressSafe: mockIsOpaqueWorkspaceFileEgressSafe, MODEL_UNSAFE_WORKSPACE_FILE_ERROR_MESSAGE: 'File cannot be sent to a model because its secret provenance is unavailable', })) -import { - assertOpaqueWorkspaceFileModelSafe, - projectServerToolModelInput, -} from '@/lib/copilot/tools/server/model-input' -import { ResolvedSecretTraceRegistry } from '@/executor/utils/resolved-secret-trace-registry' +import { assertOpaqueWorkspaceFileModelSafe } from '@/lib/copilot/tools/server/model-input' const file = { id: 'file-1', @@ -36,65 +32,25 @@ const file = { describe('server tool model-input boundary', () => { beforeEach(() => { vi.clearAllMocks() - mockImportWorkspaceFileSecretProvenanceForValue.mockResolvedValue(true) - }) - - it('projects only active text secrets to their canonical aliases', () => { - const registry = new ResolvedSecretTraceRegistry([ - { name: 'PROMPT', plaintext: 'private prompt', encryptedValue: 'encrypted-prompt' }, - { name: 'LYRICS', plaintext: 'private lyrics', encryptedValue: 'encrypted-lyrics' }, - ]) - registry.recordResolved('PROMPT', 'private prompt') - registry.recordResolved('LYRICS', 'private lyrics') - - expect( - projectServerToolModelInput( - { prompt: 'private prompt', lyrics: 'private lyrics', ordinary: 'keep me' }, - { userId: 'user-1', resolvedSecretTraceRegistry: registry } - ) - ).toEqual({ prompt: '{{PROMPT}}', lyrics: '{{LYRICS}}', ordinary: 'keep me' }) - }) - - it('fails closed when the child registry is missing or incomplete', () => { - expect(() => projectServerToolModelInput({ prompt: 'hello' })).toThrow( - 'could not be projected safely' - ) - - const registry = new ResolvedSecretTraceRegistry() - registry.markIncomplete() - expect(() => - projectServerToolModelInput( - { prompt: 'hello' }, - { userId: 'user-1', resolvedSecretTraceRegistry: registry } - ) - ).toThrow('could not be projected safely') + mockIsOpaqueWorkspaceFileEgressSafe.mockResolvedValue(true) }) it('binds opaque checks to the exact workspace file before allowing model egress', async () => { - const registry = new ResolvedSecretTraceRegistry() - await expect( assertOpaqueWorkspaceFileModelSafe({ workspaceId: 'workspace-1', file, - context: { userId: 'user-1', resolvedSecretTraceRegistry: registry }, }) ).resolves.toBeUndefined() - expect(mockImportWorkspaceFileSecretProvenanceForValue).toHaveBeenCalledWith({ - workspaceId: 'workspace-1', - identity: { - fileId: 'file-1', - key: 'workspace/workspace-1/reference.png', - context: 'workspace', - }, - value: { key: 'workspace/workspace-1/reference.png' }, - registry, - opaqueAttachment: true, + expect(mockIsOpaqueWorkspaceFileEgressSafe).toHaveBeenCalledWith('workspace-1', { + fileId: 'file-1', + key: 'workspace/workspace-1/reference.png', + context: 'workspace', }) }) it('rejects tainted or unavailable opaque provenance', async () => { - mockImportWorkspaceFileSecretProvenanceForValue.mockResolvedValue(false) + mockIsOpaqueWorkspaceFileEgressSafe.mockResolvedValue(false) await expect( assertOpaqueWorkspaceFileModelSafe({ workspaceId: 'workspace-1', file }) diff --git a/apps/sim/lib/copilot/tools/server/model-input.ts b/apps/sim/lib/copilot/tools/server/model-input.ts index 2153b16e92f..6790c53e825 100644 --- a/apps/sim/lib/copilot/tools/server/model-input.ts +++ b/apps/sim/lib/copilot/tools/server/model-input.ts @@ -1,10 +1,8 @@ -import type { ServerToolContext } from '@/lib/copilot/tools/server/base-tool' import type { WorkspaceFileRecord } from '@/lib/uploads/contexts/workspace/workspace-file-manager' import { - importWorkspaceFileSecretProvenanceForValue, + isOpaqueWorkspaceFileEgressSafe, MODEL_UNSAFE_WORKSPACE_FILE_ERROR_MESSAGE, } from '@/lib/uploads/contexts/workspace/workspace-file-secret-provenance' -import { projectResolvedSecretModelContent } from '@/executor/utils/resolved-secret-content-projection' export class ServerToolModelInputError extends Error { constructor(message: string) { @@ -13,15 +11,6 @@ export class ServerToolModelInputError extends Error { } } -/** Projects rewritable server-tool input immediately before it crosses a model boundary. */ -export function projectServerToolModelInput(value: T, context?: ServerToolContext): T { - const projection = projectResolvedSecretModelContent(value, context?.resolvedSecretTraceRegistry) - if (!projection.safe) { - throw new ServerToolModelInputError('Model input could not be projected safely') - } - return projection.value as T -} - /** * Verifies the exact persisted provenance bound to an opaque workspace file before its bytes leave * Sim. Opaque media cannot be rewritten safely, so tracked or unavailable provenance is rejected. @@ -29,18 +18,11 @@ export function projectServerToolModelInput(value: T, context?: ServerToolCon export async function assertOpaqueWorkspaceFileModelSafe(args: { workspaceId: string file: WorkspaceFileRecord - context?: ServerToolContext }): Promise { - const safe = await importWorkspaceFileSecretProvenanceForValue({ - workspaceId: args.workspaceId, - identity: { - fileId: args.file.id, - key: args.file.key, - context: args.file.storageContext ?? 'workspace', - }, - value: { key: args.file.key }, - registry: args.context?.resolvedSecretTraceRegistry, - opaqueAttachment: true, + const safe = await isOpaqueWorkspaceFileEgressSafe(args.workspaceId, { + fileId: args.file.id, + key: args.file.key, + context: args.file.storageContext ?? 'workspace', }) if (!safe) { throw new ServerToolModelInputError(MODEL_UNSAFE_WORKSPACE_FILE_ERROR_MESSAGE) diff --git a/apps/sim/lib/copilot/tools/server/other/search-online.test.ts b/apps/sim/lib/copilot/tools/server/other/search-online.test.ts index f8dbbc0ef98..cfd3ebaa678 100644 --- a/apps/sim/lib/copilot/tools/server/other/search-online.test.ts +++ b/apps/sim/lib/copilot/tools/server/other/search-online.test.ts @@ -24,7 +24,7 @@ function activeQueryRegistry(): ResolvedSecretTraceRegistry { encryptedValue: 'encrypted-query', }, ]) - registry.recordResolved('SEARCH_QUERY', 'private search query') + registry.recordResolved('SEARCH_QUERY', 'private search query', { propagated: true }) return registry } diff --git a/apps/sim/lib/copilot/vfs/file-reader.ts b/apps/sim/lib/copilot/vfs/file-reader.ts index 05283aedca0..e23c647b77c 100644 --- a/apps/sim/lib/copilot/vfs/file-reader.ts +++ b/apps/sim/lib/copilot/vfs/file-reader.ts @@ -60,7 +60,7 @@ const TEXT_TYPES = new Set([ const PARSEABLE_EXTENSIONS = new Set(['pdf', 'docx', 'doc', 'xlsx', 'xls', 'pptx', 'ppt']) -function isReadableType(contentType: string): boolean { +export function isReadableFileType(contentType: string): boolean { return TEXT_TYPES.has(contentType) || contentType.startsWith('text/') } @@ -374,7 +374,7 @@ export async function readFileRecord(record: WorkspaceFileRecord): Promise MAX_TEXT_READ_BYTES) { span.setAttribute(TraceAttr.CopilotVfsReadOutcome, CopilotVfsReadOutcome.TextTooLarge) diff --git a/apps/sim/lib/copilot/vfs/workspace-vfs.ts b/apps/sim/lib/copilot/vfs/workspace-vfs.ts index 4a7b340a07c..251e4cf0a4a 100644 --- a/apps/sim/lib/copilot/vfs/workspace-vfs.ts +++ b/apps/sim/lib/copilot/vfs/workspace-vfs.ts @@ -48,7 +48,11 @@ import { } from '@/lib/copilot/tools/server/workflow/edit-workflow/lint' import { UNRESOLVABLE_AT_LINT_NOTE } from '@/lib/copilot/tools/server/workflow/edit-workflow/validation' import { extractDocumentStyle } from '@/lib/copilot/vfs/document-style' -import { type FileReadResult, readFileRecord } from '@/lib/copilot/vfs/file-reader' +import { + type FileReadResult, + isReadableFileType, + readFileRecord, +} from '@/lib/copilot/vfs/file-reader' import { normalizeVfsSegment } from '@/lib/copilot/vfs/normalize-segment' import type { GrepMatch, GrepOptions, ReadResult } from '@/lib/copilot/vfs/operations' import * as ops from '@/lib/copilot/vfs/operations' @@ -126,7 +130,10 @@ import { listWorkspaceFiles, type WorkspaceFileRecord, } from '@/lib/uploads/contexts/workspace/workspace-file-manager' -import type { WorkspaceFileSecretProvenanceEnvelope } from '@/lib/uploads/contexts/workspace/workspace-file-secret-provenance' +import type { + WorkspaceFileSecretProvenanceEnvelope, + WorkspaceFileSecretProvenanceIdentity, +} from '@/lib/uploads/contexts/workspace/workspace-file-secret-provenance' import { listCustomBlocksWithInputsForWorkspace } from '@/lib/workflows/custom-blocks/operations' import { getCustomToolById } from '@/lib/workflows/custom-tools/operations' import { loadWorkflowFromNormalizedTables } from '@/lib/workflows/persistence/utils' @@ -158,18 +165,29 @@ const MAX_COMPILED_ATTACHMENT_BYTES = 5 * 1024 * 1024 function bindWorkspaceFileResult( record: WorkspaceFileRecord, - value: T + value: T, + view: 'complete' | 'derived' = 'derived', + contributingFiles: readonly WorkspaceFileSecretProvenanceIdentity[] = [] ): WorkspaceFileSecretProvenanceEnvelope { return { value, + view, file: { fileId: record.id, key: record.key, context: record.storageContext ?? 'workspace', }, + ...(contributingFiles.length > 0 ? { contributingFiles } : {}), } } +function recordContributingFile( + files: Map, + identity: WorkspaceFileSecretProvenanceIdentity +): void { + files.set(`${identity.context}:${identity.fileId}:${identity.key}`, identity) +} + /** * Static component files, computed once and shared across all VFS instances. * Built from the UNGATED registry universe (preview blocks included) so this @@ -1051,7 +1069,8 @@ export class WorkspaceVFS { private async renderDocRecordResult( record: WorkspaceFileRecord, ext: string, - buildMessage: (pageCount: number) => string + buildMessage: (pageCount: number) => string, + contributingFiles: Map ): Promise { if (typeof record.size === 'number' && record.size > MAX_DOC_READ_INPUT_BYTES) { return { @@ -1080,10 +1099,16 @@ export class WorkspaceVFS { totalLines: 1, } } - if (isDocSandboxEnabled && (await getE2BDocFormat(record.name))) { - bin = ( - await compileDoc({ source: code, fileName: record.name, workspaceId: this._workspaceId }) - ).buffer + if (await getE2BDocFormat(record.name)) { + const compiled = await compileDoc({ + source: code, + fileName: record.name, + workspaceId: this._workspaceId, + }) + for (const identity of compiled.contributingFiles ?? []) { + recordContributingFile(contributingFiles, identity) + } + bin = compiled.buffer } else { const taskId = BINARY_DOC_TASKS[ext] if (!taskId) { @@ -1092,7 +1117,14 @@ export class WorkspaceVFS { totalLines: 1, } } - bin = await runSandboxTask(taskId, { code, workspaceId: this._workspaceId }) + bin = await runSandboxTask( + taskId, + { code, workspaceId: this._workspaceId }, + { + onWorkspaceFileAccess: (identity) => + recordContributingFile(contributingFiles, identity), + } + ) } } const { grid, pageCount } = await renderDocToGrid({ @@ -1135,13 +1167,14 @@ export class WorkspaceVFS { const compiledMatch = /^files\/.+\/compiled$/.test(path) if (compiledMatch) { let record: WorkspaceFileRecord | null = null + const contributingFiles = new Map() try { record = await this.resolveWorkspaceFileForDynamicRead(path, 'compiled') if (!record) return null const ext = record.name.split('.').pop()?.toLowerCase() ?? '' - const e2bFmt = isDocSandboxEnabled ? await getE2BDocFormat(record.name) : null + const docFmt = await getE2BDocFormat(record.name) const taskId = BINARY_DOC_TASKS[ext] - if (!e2bFmt && !taskId) return null + if (!docFmt && !taskId) return null // Only PDF can be attached as a model-readable `document` block — // Bedrock/Anthropic document blocks accept application/pdf ONLY. Attaching @@ -1152,15 +1185,16 @@ export class WorkspaceVFS { if (ext !== 'pdf') { if (isRenderableDocExt(ext)) { const compiledName = record.name - return bindWorkspaceFileResult( + const rendered = await this.renderDocRecordResult( record, - await this.renderDocRecordResult( - record, - ext, - (pageCount) => - `${compiledName}: the raw ${ext.toUpperCase()} binary isn't model-readable, so it was rendered to ${pageCount} page image(s) for inspection.` - ) + ext, + (pageCount) => + `${compiledName}: the raw ${ext.toUpperCase()} binary isn't model-readable, so it was rendered to ${pageCount} page image(s) for inspection.`, + contributingFiles ) + return bindWorkspaceFileResult(record, rendered, 'derived', [ + ...contributingFiles.values(), + ]) } const extractPath = `${canonicalWorkspaceFilePath({ folderPath: record.folderPath, @@ -1180,34 +1214,51 @@ export class WorkspaceVFS { totalLines: 1, }) } - const compiled = e2bFmt - ? ( - await compileDoc({ - source: code, - fileName: record.name, - workspaceId: this._workspaceId, - }) - ).buffer - : await runSandboxTask(taskId, { code, workspaceId: this._workspaceId }) + let compiled: Buffer + if (docFmt) { + const compiledResult = await compileDoc({ + source: code, + fileName: record.name, + workspaceId: this._workspaceId, + }) + for (const identity of compiledResult.contributingFiles ?? []) { + recordContributingFile(contributingFiles, identity) + } + compiled = compiledResult.buffer + } else { + compiled = await runSandboxTask( + taskId, + { code, workspaceId: this._workspaceId }, + { + onWorkspaceFileAccess: (identity) => + recordContributingFile(contributingFiles, identity), + } + ) + } if (compiled.length > MAX_COMPILED_ATTACHMENT_BYTES) { return bindWorkspaceFileResult(record, { content: `[Compiled artifact too large: ${record.name} (${compiled.length} bytes, limit ${MAX_COMPILED_ATTACHMENT_BYTES})]`, totalLines: 1, }) } - return bindWorkspaceFileResult(record, { - content: `Compiled file: ${record.name} (${compiled.length} bytes, application/pdf)`, - totalLines: 1, - attachment: { - type: 'file', - name: record.name, - source: { - type: 'base64', - media_type: 'application/pdf', - data: compiled.toString('base64'), + return bindWorkspaceFileResult( + record, + { + content: `Compiled file: ${record.name} (${compiled.length} bytes, application/pdf)`, + totalLines: 1, + attachment: { + type: 'file', + name: record.name, + source: { + type: 'base64', + media_type: 'application/pdf', + data: compiled.toString('base64'), + }, }, }, - }) + 'derived', + [...contributingFiles.values()] + ) } catch (err) { logger.warn('Compiled artifact read failed via VFS', { workspaceId: this._workspaceId, @@ -1232,6 +1283,7 @@ export class WorkspaceVFS { const renderMatch = /^files\/.+\/render$/.test(path) if (renderMatch) { let record: WorkspaceFileRecord | null = null + const contributingFiles = new Map() try { record = await this.resolveWorkspaceFileForDynamicRead(path, 'render') if (!record) return null @@ -1246,15 +1298,14 @@ export class WorkspaceVFS { }) } const renderName = record.name - return bindWorkspaceFileResult( + const rendered = await this.renderDocRecordResult( record, - await this.renderDocRecordResult( - record, - ext, - (pageCount) => - `Rendered ${pageCount} page(s) of ${renderName} as a contact-sheet grid for visual QA. Inspect each page for text overflow/cutoff, overlapping elements, low contrast, misalignment, and leftover placeholder text; fix and re-render until clean.` - ) + ext, + (pageCount) => + `Rendered ${pageCount} page(s) of ${renderName} as a contact-sheet grid for visual QA. Inspect each page for text overflow/cutoff, overlapping elements, low contrast, misalignment, and leftover placeholder text; fix and re-render until clean.`, + contributingFiles ) + return bindWorkspaceFileResult(record, rendered, 'derived', [...contributingFiles.values()]) } catch (err) { logger.warn('Render read failed via VFS', { workspaceId: this._workspaceId, @@ -1444,7 +1495,13 @@ export class WorkspaceVFS { const record = findWorkspaceFileRecord(files, fileReference) if (!record) return null const result = await readFileRecord(record) - return result ? bindWorkspaceFileResult(record, result) : null + return result + ? bindWorkspaceFileResult( + record, + result, + isReadableFileType(record.type) ? 'complete' : 'derived' + ) + : null } catch (err) { logger.warn('Failed to list workspace files for readFileContent', { workspaceId: this._workspaceId, diff --git a/apps/sim/lib/execution/isolated-vm-limits.ts b/apps/sim/lib/execution/isolated-vm-limits.ts new file mode 100644 index 00000000000..d9564a5c26f --- /dev/null +++ b/apps/sim/lib/execution/isolated-vm-limits.ts @@ -0,0 +1,6 @@ +import { env } from '@/lib/core/config/env' + +export const MAX_ISOLATED_VM_BROKER_RESULT_JSON_CHARS = + Number.parseInt(env.IVM_MAX_BROKER_RESULT_JSON_CHARS) || 16_777_216 + +export const MAX_SANDBOX_IMAGE_DATA_URI_CHARS = 8 * 1024 * 1024 diff --git a/apps/sim/lib/execution/isolated-vm.ts b/apps/sim/lib/execution/isolated-vm.ts index e7efa7f7dfc..db82a40f126 100644 --- a/apps/sim/lib/execution/isolated-vm.ts +++ b/apps/sim/lib/execution/isolated-vm.ts @@ -15,6 +15,7 @@ import { } from '@/lib/core/security/input-validation.server' import type { CodePlaceholderRuntimeBinding } from '@/lib/execution/code-placeholders' import { buildJavaScriptRuntimeBindingsSource } from '@/lib/execution/code-placeholders/javascript-runtime' +import { MAX_ISOLATED_VM_BROKER_RESULT_JSON_CHARS } from '@/lib/execution/isolated-vm-limits' const logger = createLogger('IsolatedVMExecution') @@ -137,8 +138,6 @@ const DISTRIBUTED_MAX_INFLIGHT_PER_OWNER = const DISTRIBUTED_LEASE_MIN_TTL_MS = Number.parseInt(env.IVM_DISTRIBUTED_LEASE_MIN_TTL_MS) || 120000 const MAX_EXECUTIONS_PER_WORKER = Number.parseInt(env.IVM_MAX_EXECUTIONS_PER_WORKER) || 200 const MAX_BROKER_ARGS_JSON_CHARS = Number.parseInt(env.IVM_MAX_BROKER_ARGS_JSON_CHARS) || 262_144 -const MAX_BROKER_RESULT_JSON_CHARS = - Number.parseInt(env.IVM_MAX_BROKER_RESULT_JSON_CHARS) || 16_777_216 const MAX_BROKERS_PER_EXECUTION = Number.parseInt(env.IVM_MAX_BROKERS_PER_EXECUTION) || 1000 const DISTRIBUTED_KEY_PREFIX = 'ivm:fair:v1:owner' const LEASE_REDIS_DEADLINE_MS = 200 @@ -715,10 +714,10 @@ function handleBrokerMessage( sendResponse({ error: 'Broker result is not JSON-serializable' }) return } - if (resultJson.length > MAX_BROKER_RESULT_JSON_CHARS) { + if (resultJson.length > MAX_ISOLATED_VM_BROKER_RESULT_JSON_CHARS) { logReject('result_too_large', { resultJsonLength: resultJson.length }) sendResponse({ - error: `Broker result exceeds maximum size (${MAX_BROKER_RESULT_JSON_CHARS} chars)`, + error: `Broker result exceeds maximum size (${MAX_ISOLATED_VM_BROKER_RESULT_JSON_CHARS} chars)`, }) return } diff --git a/apps/sim/lib/execution/model-input-provenance.test.ts b/apps/sim/lib/execution/model-input-provenance.test.ts index 61492a980b9..80a8b7b9ad5 100644 --- a/apps/sim/lib/execution/model-input-provenance.test.ts +++ b/apps/sim/lib/execution/model-input-provenance.test.ts @@ -4,8 +4,14 @@ import { describe, expect, it } from 'vitest' import { createModelInputProvenanceRequestMetadata, + inspectModelInputProjectionState, inspectModelInputProvenanceRequest, PRIVATE_MODEL_INPUT_PROVENANCE_HEADER, + PRIVATE_MODEL_INPUT_STATE_HEADER, + PROJECTED_MODEL_INPUT_PATHS_V1, + projectModelSchemaAnnotations, + projectResolvedModelInput, + selectModelSchemaInputPaths, validateOpaqueModelInputProvenance, } from '@/lib/execution/model-input-provenance' import { @@ -21,13 +27,11 @@ const ENTRY = { } describe('model input provenance transport', () => { - it('exports only committed provenance present in the selected model input', () => { + it('exports only committed provenance recorded at the selected input paths', () => { const registry = new ResolvedSecretTraceRegistry([ENTRY]) - registry.recordResolved(ENTRY.name, ENTRY.plaintext) + registry.recordResolvedAtInputPath(ENTRY.name, ENTRY.plaintext, ['prompt']) - const metadata = createModelInputProvenanceRequestMetadata(registry, { - prompt: ENTRY.plaintext, - }) + const metadata = createModelInputProvenanceRequestMetadata(registry, [['prompt']]) expect(metadata).toEqual({ provenance: { @@ -46,12 +50,9 @@ describe('model input provenance transport', () => { const registry = new ResolvedSecretTraceRegistry([ { name: 'TOKEN', plaintext: secret, encryptedValue: 'encrypted-token' }, ]) - registry.recordResolved('TOKEN', secret) + registry.recordResolvedAtInputPath('TOKEN', secret, ['messages', '0', 'content']) - const metadata = createModelInputProvenanceRequestMetadata( - registry, - JSON.stringify([{ role: 'user', content: secret }]) - ) + const metadata = createModelInputProvenanceRequestMetadata(registry, [['messages']]) expect(metadata?.provenance).toEqual({ version: 1, @@ -60,6 +61,110 @@ describe('model input provenance transport', () => { }) }) + it('projects only resolver-recorded leaves without matching equal public text', () => { + const registry = new ResolvedSecretTraceRegistry([ + { name: 'TOKEN', plaintext: 'x', encryptedValue: 'encrypted-token' }, + ]) + registry.recordResolvedAtInputPath('TOKEN', 'x', ['prompt']) + registry.recordResolvedInputProjection(['prompt'], 'x', '{{TOKEN}}') + + const projection = projectResolvedModelInput( + registry, + { prompt: 'x', publicText: 'Box eSign' }, + [['prompt']] + ) + + expect(projection).toMatchObject({ + complete: true, + value: { prompt: '{{TOKEN}}', publicText: 'Box eSign' }, + }) + }) + + it('keeps equal secret values tied to their exact resolver paths', () => { + const registry = new ResolvedSecretTraceRegistry([ + { name: 'FIRST', plaintext: 'true', encryptedValue: 'encrypted-first' }, + { name: 'SECOND', plaintext: 'true', encryptedValue: 'encrypted-second' }, + ]) + registry.recordResolvedAtInputPath('FIRST', 'true', ['first']) + registry.recordResolvedInputProjection(['first'], 'true', '{{FIRST}}') + registry.recordResolvedAtInputPath('SECOND', 'true', ['second']) + registry.recordResolvedInputProjection(['second'], 'true', '{{SECOND}}') + + const projection = projectResolvedModelInput( + registry, + { first: 'true', second: 'true', publicValue: 'true' }, + [['first'], ['second']] + ) + + expect(projection).toMatchObject({ + complete: true, + value: { first: '{{FIRST}}', second: '{{SECOND}}', publicValue: 'true' }, + }) + }) + + it('distinguishes schema annotations from a property whose name is an annotation keyword', () => { + const selection = selectModelSchemaInputPaths( + { + type: 'object', + description: 'Model-facing help', + properties: { + description: { + type: 'string', + description: 'Property help', + enum: ['contract-value'], + }, + }, + }, + ['schema'] + ) + + expect(selection.annotationInputPaths).toEqual( + expect.arrayContaining([ + ['schema', 'description'], + ['schema', 'properties', 'description', 'description'], + ]) + ) + expect(selection.semanticInputPaths).toEqual( + expect.arrayContaining([ + ['schema', 'type'], + ['schema', 'properties', 'description', 'type'], + ['schema', 'properties', 'description', 'enum'], + ]) + ) + expect(selection.annotationInputPaths).not.toContainEqual([ + 'schema', + 'properties', + 'description', + ]) + }) + + it('projects schema annotations but rejects changes to semantic values', () => { + const raw = { + type: 'object', + description: 'Private help', + properties: { + description: { type: 'string', enum: ['private-option'] }, + }, + } + const annotationOnly = projectModelSchemaAnnotations(raw, { + ...raw, + description: '{{HELP}}', + }) + + expect(annotationOnly).toEqual({ + safe: true, + value: { ...raw, description: '{{HELP}}' }, + }) + expect( + projectModelSchemaAnnotations(raw, { + ...raw, + properties: { + description: { type: 'string', enum: ['{{OPTION}}'] }, + }, + }) + ).toEqual({ safe: false }) + }) + it('distinguishes legacy requests from complete and partial private envelopes', () => { const provenance = { version: 1, complete: true, entries: [] } @@ -89,7 +194,21 @@ describe('model input provenance transport', () => { ).toEqual({ status: 'invalid' }) }) - it('preserves external opaque inputs and requires internal callers to send an envelope', () => { + it('distinguishes an additive projected-input marker from legacy and invalid states', () => { + expect(inspectModelInputProjectionState(new Headers())).toBe('unmarked') + expect( + inspectModelInputProjectionState( + new Headers({ [PRIVATE_MODEL_INPUT_STATE_HEADER]: PROJECTED_MODEL_INPUT_PATHS_V1 }) + ) + ).toBe('projected') + expect( + inspectModelInputProjectionState( + new Headers({ [PRIVATE_MODEL_INPUT_STATE_HEADER]: 'unsupported-state' }) + ) + ).toBe('invalid') + }) + + it('preserves headerless legacy opaque inputs for external and internal callers', () => { expect( validateOpaqueModelInputProvenance({ headers: new Headers(), @@ -104,11 +223,7 @@ describe('model input provenance transport', () => { payload: {}, isInternalRequest: true, }) - ).toEqual({ - success: false, - error: 'Model input provenance is unavailable', - status: 400, - }) + ).toEqual({ success: true }) expect( validateOpaqueModelInputProvenance({ @@ -123,16 +238,7 @@ describe('model input provenance transport', () => { ).toEqual({ success: true }) }) - it('allows only explicitly opted-in internal legacy requests without an envelope', () => { - expect( - validateOpaqueModelInputProvenance({ - headers: new Headers(), - payload: {}, - isInternalRequest: true, - allowLegacyWithoutEnvelope: true, - }) - ).toEqual({ success: true }) - + it('rejects a partial opaque-input envelope', () => { expect( validateOpaqueModelInputProvenance({ headers: new Headers(), @@ -144,7 +250,6 @@ describe('model input provenance transport', () => { }, }, isInternalRequest: true, - allowLegacyWithoutEnvelope: true, }) ).toEqual({ success: false, error: 'Invalid model input provenance', status: 400 }) }) diff --git a/apps/sim/lib/execution/model-input-provenance.ts b/apps/sim/lib/execution/model-input-provenance.ts index 5155525c0de..40b04e852ef 100644 --- a/apps/sim/lib/execution/model-input-provenance.ts +++ b/apps/sim/lib/execution/model-input-provenance.ts @@ -1,3 +1,4 @@ +import { isPlainRecord } from '@sim/utils/object' import { PRIVATE_SECRET_PROVENANCE_BUNDLE_V1, PRIVATE_SECRET_PROVENANCE_FIELD, @@ -7,11 +8,14 @@ import { } from '@/lib/execution/private-tool-metadata' import { isResolvedSecretTraceProvenanceV1, + type ResolvedSecretInputPath, type ResolvedSecretTraceProvenanceV1, type ResolvedSecretTraceRegistry, } from '@/executor/utils/resolved-secret-trace-registry' export const PRIVATE_MODEL_INPUT_PROVENANCE_HEADER = 'x-sim-private-model-input-provenance' +export const PRIVATE_MODEL_INPUT_STATE_HEADER = 'x-sim-private-model-input-state' +export const PROJECTED_MODEL_INPUT_PATHS_V1 = 'projected-input-paths-v1' export const OPAQUE_MODEL_INPUT_PROVENANCE_UNAVAILABLE_ERROR = 'Model input provenance is unavailable' export const OPAQUE_MODEL_INPUT_RESOLVED_SECRET_ERROR = @@ -24,11 +28,17 @@ interface HeaderReader { get(name: string): string | null } +interface HeaderWriter { + set(name: string, value: string): void +} + export type ModelInputProvenanceInspection = | { status: 'verified'; value: unknown } | { status: 'unsupported' } | { status: 'invalid' } +export type ModelInputProjectionState = 'unmarked' | 'projected' | 'invalid' + export type OpaqueModelInputProvenanceValidation = | { success: true } | { success: false; error: string; status: 400 } @@ -42,7 +52,7 @@ export interface ModelInputProvenanceRequestMetadata { export interface PrivateSecretProvenanceSelection { key: string - value: unknown + inputPaths: readonly ResolvedSecretInputPath[] } export interface PrivateSecretProvenanceBundleV1 { @@ -58,15 +68,296 @@ export interface PrivateSecretProvenanceRequestMetadata { fieldName: typeof PRIVATE_SECRET_PROVENANCE_FIELD } -/** Builds private metadata from only committed secrets present in this model-bound value. */ +export type ResolvedModelInputProjection> = + | { + complete: true + value: T + registry?: ResolvedSecretTraceRegistry + } + | { complete: false } + +const MODEL_SCHEMA_ANNOTATION_KEYS = new Set([ + '$comment', + 'description', + 'example', + 'examples', + 'title', +]) +const MODEL_SCHEMA_MAP_KEYS = new Set([ + '$defs', + 'definitions', + 'dependentSchemas', + 'patternProperties', + 'properties', +]) +const MODEL_SCHEMA_SINGLE_KEYS = new Set([ + 'additionalItems', + 'additionalProperties', + 'contains', + 'contentSchema', + 'else', + 'if', + 'items', + 'not', + 'propertyNames', + 'then', + 'unevaluatedItems', + 'unevaluatedProperties', +]) +const MODEL_SCHEMA_ARRAY_KEYS = new Set(['allOf', 'anyOf', 'oneOf', 'prefixItems']) + +export interface ModelSchemaInputPathSelection { + annotationInputPaths: ResolvedSecretInputPath[] + semanticInputPaths: ResolvedSecretInputPath[] +} + +export type ModelSchemaProjection = { safe: true; value: unknown } | { safe: false } + +function haveSameRecordKeys( + left: Record, + right: Record +): boolean { + const leftKeys = Object.keys(left) + const rightKeys = Object.keys(right) + return leftKeys.length === rightKeys.length && leftKeys.every((key) => Object.hasOwn(right, key)) +} + +function areSchemaValuesEqual(left: unknown, right: unknown): boolean { + if (Object.is(left, right)) return true + if (Array.isArray(left) || Array.isArray(right)) { + return ( + Array.isArray(left) && + Array.isArray(right) && + left.length === right.length && + left.every((value, index) => areSchemaValuesEqual(value, right[index])) + ) + } + if (!isPlainRecord(left) || !isPlainRecord(right) || !haveSameRecordKeys(left, right)) { + return false + } + return Object.keys(left).every((key) => areSchemaValuesEqual(left[key], right[key])) +} + +function selectSchemaMapInputPaths( + value: unknown, + path: ResolvedSecretInputPath, + visitSchema: (schema: unknown, path: ResolvedSecretInputPath) => void, + semanticInputPaths: ResolvedSecretInputPath[] +): void { + if (!isPlainRecord(value)) { + semanticInputPaths.push(path) + return + } + for (const [name, childSchema] of Object.entries(value)) { + visitSchema(childSchema, [...path, name]) + } +} + +/** Selects JSON Schema annotations separately from fields that define its contract. */ +export function selectModelSchemaInputPaths( + schema: unknown, + rootPath: ResolvedSecretInputPath +): ModelSchemaInputPathSelection { + const annotationInputPaths: ResolvedSecretInputPath[] = [] + const semanticInputPaths: ResolvedSecretInputPath[] = [] + + const visitSchema = (value: unknown, path: ResolvedSecretInputPath): void => { + if (!isPlainRecord(value)) { + semanticInputPaths.push(path) + return + } + + for (const [key, keywordValue] of Object.entries(value)) { + const keywordPath = [...path, key] + if (MODEL_SCHEMA_ANNOTATION_KEYS.has(key)) { + annotationInputPaths.push(keywordPath) + continue + } + if (MODEL_SCHEMA_MAP_KEYS.has(key)) { + selectSchemaMapInputPaths(keywordValue, keywordPath, visitSchema, semanticInputPaths) + continue + } + if (MODEL_SCHEMA_ARRAY_KEYS.has(key)) { + if (!Array.isArray(keywordValue)) { + semanticInputPaths.push(keywordPath) + continue + } + keywordValue.forEach((childSchema, index) => + visitSchema(childSchema, [...keywordPath, String(index)]) + ) + continue + } + if (MODEL_SCHEMA_SINGLE_KEYS.has(key)) { + if (key === 'items' && Array.isArray(keywordValue)) { + keywordValue.forEach((childSchema, index) => + visitSchema(childSchema, [...keywordPath, String(index)]) + ) + } else { + visitSchema(keywordValue, keywordPath) + } + continue + } + if (key === 'dependencies' && isPlainRecord(keywordValue)) { + for (const [name, dependency] of Object.entries(keywordValue)) { + const dependencyPath = [...keywordPath, name] + if (Array.isArray(dependency)) semanticInputPaths.push(dependencyPath) + else visitSchema(dependency, dependencyPath) + } + continue + } + semanticInputPaths.push(keywordPath) + } + } + + visitSchema(schema, rootPath) + return { annotationInputPaths, semanticInputPaths } +} + +function projectSchemaArray(rawValue: unknown, projectedValue: unknown): ModelSchemaProjection { + if ( + !Array.isArray(rawValue) || + !Array.isArray(projectedValue) || + rawValue.length !== projectedValue.length + ) { + return { safe: false } + } + const value: unknown[] = [] + for (let index = 0; index < rawValue.length; index++) { + const child = projectModelSchemaAnnotations(rawValue[index], projectedValue[index]) + if (!child.safe) return child + value.push(child.value) + } + return { safe: true, value } +} + +function projectSchemaMap(rawValue: unknown, projectedValue: unknown): ModelSchemaProjection { + if ( + !isPlainRecord(rawValue) || + !isPlainRecord(projectedValue) || + !haveSameRecordKeys(rawValue, projectedValue) + ) { + return { safe: false } + } + const value: Record = {} + for (const key of Object.keys(rawValue)) { + const child = projectModelSchemaAnnotations(rawValue[key], projectedValue[key]) + if (!child.safe) return child + value[key] = child.value + } + return { safe: true, value } +} + +function projectSchemaDependencies( + rawValue: unknown, + projectedValue: unknown +): ModelSchemaProjection { + if ( + !isPlainRecord(rawValue) || + !isPlainRecord(projectedValue) || + !haveSameRecordKeys(rawValue, projectedValue) + ) { + return areSchemaValuesEqual(rawValue, projectedValue) + ? { safe: true, value: rawValue } + : { safe: false } + } + const value: Record = {} + for (const key of Object.keys(rawValue)) { + const rawDependency = rawValue[key] + const projectedDependency = projectedValue[key] + if (Array.isArray(rawDependency)) { + if (!areSchemaValuesEqual(rawDependency, projectedDependency)) return { safe: false } + value[key] = rawDependency + continue + } + const child = projectModelSchemaAnnotations(rawDependency, projectedDependency) + if (!child.safe) return child + value[key] = child.value + } + return { safe: true, value } +} + +/** Applies exact projections only to annotations while preserving schema contract fields. */ +export function projectModelSchemaAnnotations( + rawValue: unknown, + projectedValue: unknown +): ModelSchemaProjection { + if (Object.is(rawValue, projectedValue)) return { safe: true, value: rawValue } + if (!isPlainRecord(rawValue) || !isPlainRecord(projectedValue)) return { safe: false } + if (!haveSameRecordKeys(rawValue, projectedValue)) return { safe: false } + + const value: Record = {} + for (const key of Object.keys(rawValue)) { + const rawKeyword = rawValue[key] + const projectedKeyword = projectedValue[key] + if (MODEL_SCHEMA_ANNOTATION_KEYS.has(key)) { + value[key] = projectedKeyword + continue + } + if (MODEL_SCHEMA_MAP_KEYS.has(key)) { + const child = projectSchemaMap(rawKeyword, projectedKeyword) + if (!child.safe) return child + value[key] = child.value + continue + } + if (MODEL_SCHEMA_ARRAY_KEYS.has(key)) { + const child = projectSchemaArray(rawKeyword, projectedKeyword) + if (!child.safe) return child + value[key] = child.value + continue + } + if (MODEL_SCHEMA_SINGLE_KEYS.has(key)) { + const child = + key === 'items' && Array.isArray(rawKeyword) + ? projectSchemaArray(rawKeyword, projectedKeyword) + : projectModelSchemaAnnotations(rawKeyword, projectedKeyword) + if (!child.safe) return child + value[key] = child.value + continue + } + if (key === 'dependencies') { + const child = projectSchemaDependencies(rawKeyword, projectedKeyword) + if (!child.safe) return child + value[key] = child.value + continue + } + if (!areSchemaValuesEqual(rawKeyword, projectedKeyword)) return { safe: false } + value[key] = rawKeyword + } + return { safe: true, value } +} + +/** + * Builds a model-facing copy from resolver-recorded leaves only. + * + * The selected record must retain the same top-level keys and nested paths as the block inputs + * that passed through `VariableResolver`. No plaintext matching is performed here. + */ +export function projectResolvedModelInput>( + registry: ResolvedSecretTraceRegistry | undefined, + selected: T, + inputPaths: readonly ResolvedSecretInputPath[] +): ResolvedModelInputProjection { + if (!registry) return { complete: true, value: selected } + + const modelRegistry = registry.forkForInputPaths(inputPaths) + const projection = modelRegistry.projectResolvedInputSelection(selected) + if (!projection.complete) return { complete: false } + return { + complete: true, + value: projection.value as T, + registry: modelRegistry, + } +} + +/** Builds private metadata from only resolver-recorded paths selected for this model request. */ export function createModelInputProvenanceRequestMetadata( registry: ResolvedSecretTraceRegistry | undefined, - modelInput: unknown + inputPaths: readonly ResolvedSecretInputPath[] ): ModelInputProvenanceRequestMetadata | undefined { if (!registry) return undefined return { - provenance: registry.exportCommittedProvenanceForValue(modelInput), + provenance: registry.exportCommittedProvenanceForInputPaths(inputPaths), headerName: PRIVATE_MODEL_INPUT_PROVENANCE_HEADER, headerValue: RESOLVED_SECRET_PROVENANCE_METADATA_V1, fieldName: RESOLVED_SECRET_PROVENANCE_FIELD, @@ -90,7 +381,7 @@ export function createPrivateSecretProvenanceRequestMetadata( break } keys.add(selection.key) - const provenance = registry.exportCommittedProvenanceForValue(selection.value) + const provenance = registry.exportCommittedProvenanceForInputPaths(selection.inputPaths) if (!provenance.complete) { complete = false break @@ -131,6 +422,18 @@ export function addModelInputProvenanceToRequest( return { ...payload, [metadata.fieldName]: metadata.provenance } } +/** Marks an authenticated private-provenance request whose selected inputs were projected. */ +export function markModelInputProjected(headers: HeaderWriter): void { + headers.set(PRIVATE_MODEL_INPUT_STATE_HEADER, PROJECTED_MODEL_INPUT_PATHS_V1) +} + +/** Reads the additive projection state without changing the existing v1 envelope protocol. */ +export function inspectModelInputProjectionState(headers: HeaderReader): ModelInputProjectionState { + const state = headers.get(PRIVATE_MODEL_INPUT_STATE_HEADER) + if (state === null) return 'unmarked' + return state === PROJECTED_MODEL_INPUT_PATHS_V1 ? 'projected' : 'invalid' +} + export function isPrivateSecretProvenanceBundleV1( value: unknown ): value is PrivateSecretProvenanceBundleV1 { @@ -206,26 +509,17 @@ export function inspectPrivateSecretProvenanceRequest( /** * Validates model-bound inputs that cannot be rewritten safely, such as file bytes and signed - * URLs. External headerless calls retain their existing behavior. Internal callers must use the - * authenticated envelope unless a compatibility route explicitly opts into its pre-envelope - * protocol. Incomplete, malformed, or secret-bearing envelopes always fail closed. + * URLs. A missing envelope is the additive legacy protocol for both external and internal calls. + * Once either half of the private protocol is present, incomplete, malformed, forged, or + * secret-bearing envelopes fail closed. */ export function validateOpaqueModelInputProvenance(options: { headers: HeaderReader payload: unknown isInternalRequest: boolean - allowLegacyWithoutEnvelope?: boolean }): OpaqueModelInputProvenanceValidation { const inspection = inspectModelInputProvenanceRequest(options.headers, options.payload) - if (inspection.status === 'unsupported') { - return options.isInternalRequest && !options.allowLegacyWithoutEnvelope - ? { - success: false, - error: OPAQUE_MODEL_INPUT_PROVENANCE_UNAVAILABLE_ERROR, - status: 400, - } - : { success: true } - } + if (inspection.status === 'unsupported') return { success: true } if (inspection.status === 'invalid' || !options.isInternalRequest) { return { success: false, error: 'Invalid model input provenance', status: 400 } } diff --git a/apps/sim/lib/execution/mounted-file-secret-provenance.test.ts b/apps/sim/lib/execution/mounted-file-secret-provenance.test.ts index e0530141a9c..77be6d392a0 100644 --- a/apps/sim/lib/execution/mounted-file-secret-provenance.test.ts +++ b/apps/sim/lib/execution/mounted-file-secret-provenance.test.ts @@ -84,18 +84,23 @@ describe('mounted file output provenance scanner', () => { expect(scanner?.hasSecrets).toBe(true) }) - it('fails closed when encrypted provenance is incomplete or cannot be decrypted', async () => { - await expect( - createMountedFileSecretProvenanceScanner({ version: 1, complete: false, entries: [] }) - ).resolves.toBeUndefined() + it('classifies outputs unknown when authenticated mount provenance cannot be inspected', async () => { + const incomplete = await createMountedFileSecretProvenanceScanner({ + version: 1, + complete: false, + entries: [], + }) + expect(incomplete?.hasSecrets).toBe(true) + expect(incomplete?.scan(Buffer.from('raw output'))).toEqual({ status: 'unknown' }) encryptionMockFns.mockDecryptSecret.mockRejectedValueOnce(new Error('decrypt failed')) - await expect( - createMountedFileSecretProvenanceScanner({ - version: 1, - complete: true, - entries: [{ encryptedValue: 'encrypted-a' }], - }) - ).resolves.toBeUndefined() + const unavailable = await createMountedFileSecretProvenanceScanner({ + version: 1, + complete: true, + entries: [{ encryptedValue: 'encrypted-a' }], + scope: { userId: 'user-1', workspaceId: 'workspace-1' }, + }) + expect(unavailable?.hasSecrets).toBe(true) + expect(unavailable?.scan(Buffer.from('raw output'))).toEqual({ status: 'unknown' }) }) }) diff --git a/apps/sim/lib/execution/mounted-file-secret-provenance.ts b/apps/sim/lib/execution/mounted-file-secret-provenance.ts index cc1c7d0d8c0..001496e1d5a 100644 --- a/apps/sim/lib/execution/mounted-file-secret-provenance.ts +++ b/apps/sim/lib/execution/mounted-file-secret-provenance.ts @@ -22,6 +22,11 @@ export interface MountedFileSecretProvenanceScanner { scan(buffer: Buffer): WorkspaceFileSecretProvenance } +const UNKNOWN_MOUNTED_FILE_SECRET_PROVENANCE_SCANNER: MountedFileSecretProvenanceScanner = { + hasSecrets: true, + scan: () => ({ status: 'unknown' }), +} + function compareStrings(left: string, right: string): number { return left < right ? -1 : left > right ? 1 : 0 } @@ -34,7 +39,8 @@ function compareStrings(left: string, right: string): number { export async function createMountedFileSecretProvenanceScanner( provenance: ResolvedSecretTraceProvenanceV1 ): Promise { - if (!provenance.complete || !provenance.scope?.userId) return undefined + if (!provenance.complete) return UNKNOWN_MOUNTED_FILE_SECRET_PROVENANCE_SCANNER + if (!provenance.scope?.userId) return undefined const hasSecrets = provenance.entries.length > 0 const entriesByScanLiteral = new Map>() @@ -42,7 +48,7 @@ export async function createMountedFileSecretProvenanceScanner( for (const entry of provenance.entries) { const { decrypted: plaintext } = await decryptSecret(entry.encryptedValue) if (!plaintext) continue - const fileEntry = { + const fileEntry: WorkspaceFileSecretProvenanceEntry = { name: entry.name || ANONYMOUS_MOUNTED_FILE_SECRET_NAME, encryptedValue: entry.encryptedValue, sourceUserId: provenance.scope.userId, @@ -55,14 +61,14 @@ export async function createMountedFileSecretProvenanceScanner( entriesByScanLiteral.get(scanLiteral) ?? new Map() entries.set( - `${fileEntry.sourceUserId}\u0000${fileEntry.sourceWorkspaceId ?? ''}\u0000${fileEntry.name}\u0000${fileEntry.encryptedValue}`, + `${fileEntry.sourceUserId}\u0000${fileEntry.sourceWorkspaceId ?? ''}\u0000${fileEntry.name ?? ''}\u0000${fileEntry.encryptedValue}`, fileEntry ) entriesByScanLiteral.set(scanLiteral, entries) } } } catch { - return undefined + return UNKNOWN_MOUNTED_FILE_SECRET_PROVENANCE_SCANNER } if (entriesByScanLiteral.size === 0) { @@ -75,7 +81,7 @@ export async function createMountedFileSecretProvenanceScanner( [...entriesByScanLiteral.keys()].map((plaintext) => ({ plaintext, replacement: '' })) ) } catch { - return undefined + return UNKNOWN_MOUNTED_FILE_SECRET_PROVENANCE_SCANNER } if (!matcher) { return { hasSecrets, scan: () => ({ status: 'exact', entries: [] }) } @@ -92,7 +98,7 @@ export async function createMountedFileSecretProvenanceScanner( (scanLiteral) => { for (const entry of entriesByScanLiteral.get(scanLiteral)?.values() ?? []) { matched.set( - `${entry.sourceUserId}\u0000${entry.sourceWorkspaceId ?? ''}\u0000${entry.name}\u0000${entry.encryptedValue}`, + `${entry.sourceUserId}\u0000${entry.sourceWorkspaceId ?? ''}\u0000${entry.name ?? ''}\u0000${entry.encryptedValue}`, entry ) } @@ -109,7 +115,7 @@ export async function createMountedFileSecretProvenanceScanner( (left, right) => compareStrings(left.sourceUserId, right.sourceUserId) || compareStrings(left.sourceWorkspaceId ?? '', right.sourceWorkspaceId ?? '') || - compareStrings(left.name, right.name) || + compareStrings(left.name ?? '', right.name ?? '') || compareStrings(left.encryptedValue, right.encryptedValue) ), } diff --git a/apps/sim/lib/execution/payloads/materialization.server.ts b/apps/sim/lib/execution/payloads/materialization.server.ts index 35fc79f4565..ec7c7bfb82e 100644 --- a/apps/sim/lib/execution/payloads/materialization.server.ts +++ b/apps/sim/lib/execution/payloads/materialization.server.ts @@ -16,6 +16,7 @@ import { } from '@/lib/execution/payloads/limits' import { ExecutionResourceLimitError } from '@/lib/execution/resource-errors' import type { StorageContext } from '@/lib/uploads' +import type { WorkspaceFileSecretProvenanceIdentity } from '@/lib/uploads/contexts/workspace/workspace-file-secret-provenance' import { bufferToBase64, inferContextFromKey, @@ -52,6 +53,11 @@ export interface ReadUserFileContentOptions extends ExecutionMaterializationCont encoding: 'base64' | 'text' } +export interface ReadUserFileContentResult { + content: string + contributingFiles?: readonly WorkspaceFileSecretProvenanceIdentity[] +} + function getLogger(options: ExecutionMaterializationContext): Logger { return options.logger ?? logger } @@ -277,10 +283,10 @@ export async function assertUserFileContentAccess( * file's size to the rendered artifact size so downstream attachment routing does * not make decisions from the smaller generation-source size. */ -export async function readUserFileContent( +export async function readUserFileContentWithContributors( file: unknown, options: ReadUserFileContentOptions -): Promise { +): Promise { if (!isUserFileWithMetadata(file)) { throw new Error('Expected a file object with metadata.') } @@ -297,13 +303,16 @@ export async function readUserFileContent( } let buffer: Buffer | null = null + let contributingFiles: readonly WorkspaceFileSecretProvenanceIdentity[] | undefined const log = getLogger(options) const requestId = options.requestId ?? 'unknown' try { - buffer = ( - await downloadServableFileFromStorage(file, requestId, log, { maxBytes: maxSourceBytes }) - ).buffer + const servable = await downloadServableFileFromStorage(file, requestId, log, { + maxBytes: maxSourceBytes, + }) + buffer = servable.buffer + contributingFiles = servable.contributingFiles } catch (error) { if (isPayloadSizeLimitError(error)) { if (isGeneratedDocumentSourceType(file.type) && error.observedBytes !== undefined) { @@ -337,7 +346,17 @@ export async function readUserFileContent( const selected = shouldSlice ? normalizeRange(buffer, options) : buffer assertInlineMaterializationSize(selected.length, options.maxBytes ?? MAX_FUNCTION_INLINE_BYTES) - return options.encoding === 'base64' ? bufferToBase64(selected) : selected.toString('utf8') + return { + content: options.encoding === 'base64' ? bufferToBase64(selected) : selected.toString('utf8'), + ...(contributingFiles && contributingFiles.length > 0 ? { contributingFiles } : {}), + } +} + +export async function readUserFileContent( + file: unknown, + options: ReadUserFileContentOptions +): Promise { + return (await readUserFileContentWithContributors(file, options)).content } export function unavailableLargeValueError(ref: LargeValueRef): Error { diff --git a/apps/sim/lib/execution/remote-sandbox/conformance.test.ts b/apps/sim/lib/execution/remote-sandbox/conformance.test.ts index d59504d28c8..18b9216035f 100644 --- a/apps/sim/lib/execution/remote-sandbox/conformance.test.ts +++ b/apps/sim/lib/execution/remote-sandbox/conformance.test.ts @@ -377,19 +377,17 @@ describe.each(PROVIDERS)('sandbox conformance [%s]', (provider) => { expect(provider === 'e2b' ? mockE2BKill : mockDelete).toHaveBeenCalledTimes(1) }) - it('exempts a caller-consumed stream from the budget and keeps a diagnostic tail', async () => { - // A Pi agent turn streams one JSONL event per step and routinely passes the retention budget - // while producing no oversized result — the caller parses every chunk and keeps none of it. - const oversized = 'x'.repeat(MAX_SANDBOX_PROCESS_OUTPUT_BYTES + 1024) + it('keeps only a diagnostic tail from a caller-consumed stream', async () => { + const streamedOutput = 'x'.repeat(MAX_SANDBOX_STREAMED_OUTPUT_TAIL_BYTES + 1024) if (provider === 'e2b') { mockE2BCommandsRun.mockImplementationOnce(async (_cmd, options) => { - options.onStdout(`${oversized}TAIL_MARKER`) - return { stdout: `${oversized}TAIL_MARKER`, stderr: '', exitCode: 0 } + options.onStdout(`${streamedOutput}TAIL_MARKER`) + return { stdout: `${streamedOutput}TAIL_MARKER`, stderr: '', exitCode: 0 } }) } else { mockGetSessionCommandLogs.mockImplementationOnce( async (_sessionId: string, _commandId: string, onStdout: (chunk: string) => void) => { - onStdout(`${oversized}TAIL_MARKER`) + onStdout(`${streamedOutput}TAIL_MARKER`) } ) mockGetSessionCommand.mockResolvedValue({ exitCode: 0 }) @@ -406,10 +404,9 @@ describe.each(PROVIDERS)('sandbox conformance [%s]', (provider) => { ) // Delivered in full to the caller... - expect(streamedBytes).toBeGreaterThan(MAX_SANDBOX_PROCESS_OUTPUT_BYTES) + expect(streamedBytes).toBeGreaterThan(MAX_SANDBOX_STREAMED_OUTPUT_TAIL_BYTES) expect(result.exitCode).toBe(0) - // ...but only the tail is retained. Reaching exitCode 0 at all is the point: before the - // exemption this threw `sandbox_output_limit_exceeded` and killed the sandbox mid-run. + /** Only a small diagnostic tail is returned after the caller consumes the full live stream. */ expect(result.stdout).toContain('TAIL_MARKER') // The tail plus its truncation note, NOT a multiple of it. A looser bound here passes on both // providers even when one returns twice as much as the other, which is the divergence this @@ -466,7 +463,7 @@ describe.each(PROVIDERS)('sandbox conformance [%s]', (provider) => { ) } - // stdout is streamed, stderr is not — the exemption is per stream, not per command. + /** A stdout callback must not exempt unconsumed stderr from the process-output budget. */ await expect( withPiSandbox({}, (runner) => runner.run('pi run', { timeoutMs: 1000, onStdout: () => {} })) ).rejects.toMatchObject({ code: 'sandbox_output_limit_exceeded', outputKind: 'process' }) @@ -1157,6 +1154,35 @@ describe.each(PROVIDERS)('sandbox conformance [%s]', (provider) => { }) }) +describe('E2B streamed output safety', () => { + it('bounds callback-delivered output that the E2B SDK also retains', async () => { + useProvider('e2b') + mockE2BCommandsRun.mockImplementationOnce(async (_command, options) => { + options.onStdout?.('1234') + options.onStdout?.('56789') + return { stdout: '123456789', stderr: '', exitCode: 0 } + }) + + const streamed: string[] = [] + const sandbox = await e2bProvider.create('pi') + + await expect( + sandbox.runCommand('pi run', { + timeoutMs: 1000, + maxOutputBytes: 8, + onStdout: (chunk) => streamed.push(chunk), + }) + ).rejects.toMatchObject({ + code: 'sandbox_output_limit_exceeded', + outputKind: 'process', + attemptedBytes: 9, + limitBytes: 8, + }) + expect(streamed).toEqual(['1234']) + expect(mockE2BKill).toHaveBeenCalledTimes(1) + }) +}) + describe('custom dependency sets', () => { it.each(PROVIDERS)('honors imageRef for code executions [%s]', async (provider) => { useProvider(provider) diff --git a/apps/sim/lib/execution/remote-sandbox/e2b.ts b/apps/sim/lib/execution/remote-sandbox/e2b.ts index c7adace21d8..a9c9e39c7fc 100644 --- a/apps/sim/lib/execution/remote-sandbox/e2b.ts +++ b/apps/sim/lib/execution/remote-sandbox/e2b.ts @@ -390,19 +390,20 @@ class E2BSandboxHandle implements SandboxHandle { const outputBudget = new SandboxProcessOutputBudget( options.maxOutputBytes ?? MAX_SANDBOX_PROCESS_OUTPUT_BYTES ) - // The budget bounds what Sim RETAINS, so a stream the caller consumes itself is exempt: it has - // already been delivered chunk by chunk, and only a diagnostic tail is kept. Per stream, not - // per command — a caller that streams stdout but not stderr still has stderr fully bounded. + /** + * E2B's SDK accumulates every callback-delivered chunk in its own result strings, so all streams + * must share the process budget even when Sim's caller consumes them incrementally. The callback + * still receives each chunk that fits; the sandbox is stopped before later chunks can make the + * SDK's retained copy grow without bound. + */ const retainStdout = options.onStdout === undefined const retainStderr = options.onStderr === undefined - const guardOutput = (value: string, retain: boolean, callback?: (chunk: string) => void) => { - if (retain) { - try { - outputBudget.add(value) - } catch (error) { - void this.kill().catch(() => {}) - throw error - } + const guardOutput = (value: string, callback?: (chunk: string) => void) => { + try { + outputBudget.add(value) + } catch (error) { + void this.kill().catch(() => {}) + throw error } callback?.(value) } @@ -413,13 +414,10 @@ class E2BSandboxHandle implements SandboxHandle { timeoutMs: e2bTimeoutMs(options.timeoutMs), ...(options.signal ? { signal: options.signal } : {}), ...(options.rootUser ? { user: 'root' as const } : {}), - onStdout: (chunk) => guardOutput(chunk, retainStdout, options.onStdout), - onStderr: (chunk) => guardOutput(chunk, retainStderr, options.onStderr), + onStdout: (chunk) => guardOutput(chunk, options.onStdout), + onStderr: (chunk) => guardOutput(chunk, options.onStderr), }) - assertSandboxProcessOutputWithinLimit( - [retainStdout ? result.stdout : undefined, retainStderr ? result.stderr : undefined], - options.maxOutputBytes - ) + assertSandboxProcessOutputWithinLimit([result.stdout, result.stderr], options.maxOutputBytes) return { stdout: retainStdout ? result.stdout : tailStreamedSandboxOutput(result.stdout), stderr: retainStderr ? result.stderr : tailStreamedSandboxOutput(result.stderr), @@ -447,16 +445,8 @@ class E2BSandboxHandle implements SandboxHandle { ) { throw error } - // The SDK throws on a non-zero exit, so this is the ordinary path for a failing streamed - // command — the same retention exemption has to apply here or a failing Pi turn still trips - // the budget on output the caller already consumed. `message` never streams, so it is always - // retained and billed. assertSandboxProcessOutputWithinLimit( - [ - retainStdout ? failure.stdout : undefined, - retainStderr ? failure.stderr : undefined, - failure.message, - ], + [failure.stdout, failure.stderr, failure.message], options.maxOutputBytes ) const tailIfStreamed = (value: string | undefined, retain: boolean) => diff --git a/apps/sim/lib/execution/remote-sandbox/output-limits.ts b/apps/sim/lib/execution/remote-sandbox/output-limits.ts index a273822d90d..91fc5cb3616 100644 --- a/apps/sim/lib/execution/remote-sandbox/output-limits.ts +++ b/apps/sim/lib/execution/remote-sandbox/output-limits.ts @@ -10,11 +10,9 @@ export const MAX_SANDBOX_PROCESS_OUTPUT_BYTES = 10 * 1024 * 1024 /** * Diagnostic tail kept from a stream the caller consumed itself. * - * A caller that passes `onStdout`/`onStderr` takes delivery of every chunk as it arrives, so the - * adapter's accumulated copy is never the result — it is only ever read back to explain a failure. - * Billing that copy to the retention budget kills runs whose live stream is legitimately long while - * producing no oversized result: a Pi agent turn emits one JSONL event per step and passes 10 MB on - * an ordinary session, even though the caller has already parsed every event and keeps none of it. + * Providers that can discard callback-delivered output use this tail as their only retained copy. + * E2B's SDK always accumulates the full streams internally, so its adapter must still enforce the + * process-output budget before applying this diagnostic tail to the returned result. */ export const MAX_SANDBOX_STREAMED_OUTPUT_TAIL_BYTES = 64 * 1024 diff --git a/apps/sim/lib/execution/sandbox/brokers/workspace-file.test.ts b/apps/sim/lib/execution/sandbox/brokers/workspace-file.test.ts new file mode 100644 index 00000000000..5f040df5ccf --- /dev/null +++ b/apps/sim/lib/execution/sandbox/brokers/workspace-file.test.ts @@ -0,0 +1,107 @@ +/** + * @vitest-environment node + */ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { fetchWorkspaceFileBufferMock, getWorkspaceFileMock } = vi.hoisted(() => ({ + fetchWorkspaceFileBufferMock: vi.fn(), + getWorkspaceFileMock: vi.fn(), +})) + +vi.mock('@/lib/uploads/contexts/workspace/workspace-file-manager', () => ({ + fetchWorkspaceFileBuffer: fetchWorkspaceFileBufferMock, + getWorkspaceFile: getWorkspaceFileMock, +})) + +import { + MAX_ISOLATED_VM_BROKER_RESULT_JSON_CHARS, + MAX_SANDBOX_IMAGE_DATA_URI_CHARS, +} from '@/lib/execution/isolated-vm-limits' +import { workspaceFileBroker } from '@/lib/execution/sandbox/brokers/workspace-file' + +const CONTEXT = { workspaceId: 'workspace-1', requestId: 'request-1' } + +function workspaceFileRecord(version: number) { + const timestamp = new Date(`2026-08-05T00:00:0${version}.000Z`) + return { + id: 'file-1', + workspaceId: CONTEXT.workspaceId, + name: 'reference.png', + key: `workspace/${CONTEXT.workspaceId}/reference-v${version}.png`, + path: `/api/files/serve/reference-v${version}.png`, + size: version, + type: 'image/png', + uploadedBy: 'user-1', + uploadedAt: timestamp, + updatedAt: timestamp, + contentUpdatedAt: timestamp, + storageContext: 'workspace' as const, + } +} + +describe('workspaceFileBroker', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('resolves and verifies the current file version on every broker call', async () => { + const first = workspaceFileRecord(1) + const second = workspaceFileRecord(2) + getWorkspaceFileMock.mockResolvedValueOnce(first).mockResolvedValueOnce(second) + fetchWorkspaceFileBufferMock + .mockResolvedValueOnce(Buffer.from('first')) + .mockResolvedValueOnce(Buffer.from('second')) + const onWorkspaceFileAccess = vi.fn() + const context = { ...CONTEXT, onWorkspaceFileAccess } + + await expect(workspaceFileBroker.handle(context, { fileId: 'file-1' })).resolves.toEqual({ + dataUri: `data:image/png;base64,${Buffer.from('first').toString('base64')}`, + }) + await expect(workspaceFileBroker.handle(context, { fileId: 'file-1' })).resolves.toEqual({ + dataUri: `data:image/png;base64,${Buffer.from('second').toString('base64')}`, + }) + + expect(onWorkspaceFileAccess).toHaveBeenNthCalledWith(1, { + fileId: first.id, + key: first.key, + context: 'workspace', + contentUpdatedAt: first.contentUpdatedAt, + }) + expect(onWorkspaceFileAccess).toHaveBeenNthCalledWith(2, { + fileId: second.id, + key: second.key, + context: 'workspace', + contentUpdatedAt: second.contentUpdatedAt, + }) + const dataUriPrefix = 'data:image/png;base64,' + const envelopeChars = JSON.stringify({ dataUri: dataUriPrefix }).length + const availableBase64Chars = Math.min( + MAX_SANDBOX_IMAGE_DATA_URI_CHARS - dataUriPrefix.length, + MAX_ISOLATED_VM_BROKER_RESULT_JSON_CHARS - envelopeChars + ) + const maxBytes = Math.floor(availableBase64Chars / 4) * 3 + expect(dataUriPrefix.length + 4 * Math.ceil(maxBytes / 3)).toBeLessThanOrEqual( + MAX_SANDBOX_IMAGE_DATA_URI_CHARS + ) + expect(dataUriPrefix.length + 4 * Math.ceil((maxBytes + 1) / 3)).toBeGreaterThan( + MAX_SANDBOX_IMAGE_DATA_URI_CHARS + ) + expect(envelopeChars + 4 * Math.ceil(maxBytes / 3)).toBeLessThanOrEqual( + MAX_ISOLATED_VM_BROKER_RESULT_JSON_CHARS + ) + expect(fetchWorkspaceFileBufferMock).toHaveBeenNthCalledWith(1, first, { maxBytes }) + expect(fetchWorkspaceFileBufferMock).toHaveBeenNthCalledWith(2, second, { maxBytes }) + }) + + it('does not report a file access when reading its bytes fails', async () => { + const record = workspaceFileRecord(1) + getWorkspaceFileMock.mockResolvedValue(record) + fetchWorkspaceFileBufferMock.mockRejectedValue(new Error('read failed')) + const onWorkspaceFileAccess = vi.fn() + + await expect( + workspaceFileBroker.handle({ ...CONTEXT, onWorkspaceFileAccess }, { fileId: record.id }) + ).rejects.toThrow('read failed') + expect(onWorkspaceFileAccess).not.toHaveBeenCalled() + }) +}) diff --git a/apps/sim/lib/execution/sandbox/brokers/workspace-file.ts b/apps/sim/lib/execution/sandbox/brokers/workspace-file.ts index 38ea0695863..32277c4419b 100644 --- a/apps/sim/lib/execution/sandbox/brokers/workspace-file.ts +++ b/apps/sim/lib/execution/sandbox/brokers/workspace-file.ts @@ -1,4 +1,8 @@ import { createLogger } from '@sim/logger' +import { + MAX_ISOLATED_VM_BROKER_RESULT_JSON_CHARS, + MAX_SANDBOX_IMAGE_DATA_URI_CHARS, +} from '@/lib/execution/isolated-vm-limits' import type { SandboxBroker } from '@/lib/execution/sandbox/types' import { fetchWorkspaceFileBuffer, @@ -40,8 +44,24 @@ export const workspaceFileBroker: SandboxBroker( const brokerContext: SandboxBrokerContext = { workspaceId: input.workspaceId, requestId, + onWorkspaceFileAccess: options.onWorkspaceFileAccess, } const brokers: Record = {} for (const broker of task.brokers) { diff --git a/apps/sim/lib/execution/sandbox/types.ts b/apps/sim/lib/execution/sandbox/types.ts index 7f27b85affe..08f7e1ff7fb 100644 --- a/apps/sim/lib/execution/sandbox/types.ts +++ b/apps/sim/lib/execution/sandbox/types.ts @@ -24,6 +24,12 @@ export interface SandboxBroker { export interface SandboxBrokerContext { workspaceId: string requestId: string + onWorkspaceFileAccess?: (identity: { + fileId: string + key: string + context: 'workspace' | 'mothership' + contentUpdatedAt: Date + }) => void } export interface SandboxTaskInput { diff --git a/apps/sim/lib/guardrails/validate_hallucination.test.ts b/apps/sim/lib/guardrails/validate_hallucination.test.ts index e522db31b2e..8301b0d9c6f 100644 --- a/apps/sim/lib/guardrails/validate_hallucination.test.ts +++ b/apps/sim/lib/guardrails/validate_hallucination.test.ts @@ -103,13 +103,23 @@ describe('validateHallucination', () => { vi.unstubAllGlobals() }) - it('uses authenticated private Knowledge transport and carries result provenance into the provider boundary', async () => { + it('carries exact query and result provenance across both model boundaries', async () => { const registry = new ResolvedSecretTraceRegistry([ { name: 'TOKEN', plaintext: 'secret-value', encryptedValue: 'ciphertext' }, + { name: 'UNUSED', plaintext: 'x', encryptedValue: 'unused-ciphertext' }, ]) - expect(registry.recordResolved('TOKEN', 'secret-value')).toBe(true) + expect( + registry.recordResolvedAtInputPath('TOKEN', 'secret-value', ['input'], { + propagated: true, + }) + ).toBe(true) + registry.recordResolvedInputProjection( + ['input'], + 'secret-value __var_FOREIGN', + '{{TOKEN}} __var_FOREIGN' + ) const knowledgeBody = { - data: { results: [{ content: 'reference-secret' }] }, + data: { results: [{ content: 'Box reference-secret' }] }, } const fetchMock = vi.fn(async () => createPrivateKnowledgeResponse(knowledgeBody, { @@ -141,13 +151,12 @@ describe('validateHallucination', () => { expect(searchHeaders.get('x-sim-private-model-input-provenance')).toBe( RESOLVED_SECRET_PROVENANCE_METADATA_V1 ) - expect(searchBody.query).toBe('{{TOKEN}} __var_FOREIGN') + expect(searchBody.query).toBe('secret-value __var_FOREIGN') expect(searchBody.__resolvedSecretTraceProvenance).toEqual({ version: 1, complete: true, - entries: [], + entries: [{ encryptedValue: 'ciphertext', name: 'TOKEN' }], }) - expect(JSON.stringify(searchBody)).not.toContain('secret-value') expect(JSON.stringify(searchBody)).toContain('__var_FOREIGN') const providerCall = mockExecuteProviderRequest.mock.calls[0] @@ -155,18 +164,23 @@ describe('validateHallucination', () => { const providerContext = providerCall[2] as { resolvedSecretTraceRegistry: ResolvedSecretTraceRegistry } - expect(providerRequest.messages[0].content).toContain('reference-secret') + expect(providerRequest.messages[0].content).toContain('{{TOKEN}} __var_FOREIGN') + expect(providerRequest.messages[0].content).toContain('Box {{KB_TOKEN}}') + expect(providerRequest.messages[0].content).not.toContain('{{UNUSED}}') + expect(providerRequest.messages[0].content).not.toContain('secret-value') + expect(providerRequest.messages[0].content).not.toContain('reference-secret') expect(providerRequest.messages[0].content).not.toContain(RESOLVED_SECRET_PROVENANCE_FIELD) expect(providerContext.resolvedSecretTraceRegistry).not.toBe(registry) expect(providerContext.resolvedSecretTraceRegistry.getModelEgressSnapshot()).toMatchObject({ complete: true, matches: expect.arrayContaining([ + { plaintext: 'secret-value', replacement: '{{TOKEN}}' }, { plaintext: 'reference-secret', replacement: '{{KB_TOKEN}}' }, ]), }) }) - it('fails only the hallucination model-bound leg when Knowledge omits private provenance', async () => { + it('accepts a successful legacy Knowledge response without private provenance', async () => { const registry = new ResolvedSecretTraceRegistry() vi.stubGlobal( 'fetch', @@ -175,11 +189,11 @@ describe('validateHallucination', () => { const result = await validateHallucination(createInput(registry)) - expect(result).toEqual({ - passed: false, - error: 'Validation error: Knowledge result secret provenance is unavailable', - }) - expect(mockExecuteProviderRequest).not.toHaveBeenCalled() + expect(result).toMatchObject({ passed: true, score: 8 }) + const providerRequest = mockExecuteProviderRequest.mock.calls[0][1] as { + messages: Array<{ content: string }> + } + expect(providerRequest.messages[0].content).toContain('public context') expect(registry.isComplete()).toBe(true) }) diff --git a/apps/sim/lib/guardrails/validate_hallucination.ts b/apps/sim/lib/guardrails/validate_hallucination.ts index d97cf94bf8a..a3396a37ce9 100644 --- a/apps/sim/lib/guardrails/validate_hallucination.ts +++ b/apps/sim/lib/guardrails/validate_hallucination.ts @@ -29,6 +29,7 @@ import { getProviderFromModel } from '@/providers/utils' const logger = createLogger('HallucinationValidator') const KNOWLEDGE_PROVENANCE_ERROR = 'Knowledge result secret provenance is unavailable' +const HALLUCINATION_INPUT_PATHS = [['input']] as const class KnowledgeProvenanceError extends Error { constructor() { @@ -89,7 +90,8 @@ async function queryKnowledgeBase( billingAttribution: BillingAttributionSnapshot, workflowId: string | undefined, resolvedSecretTraceRegistry: ResolvedSecretTraceRegistry -): Promise { +): Promise<{ context: string[]; registry: ResolvedSecretTraceRegistry }> { + const resultRegistry = resolvedSecretTraceRegistry.forkForInputPaths([]) try { const searchUrl = `${getInternalApiBaseUrl()}/api/knowledge/search` const internalToken = await generateInternalToken(actorUserId) @@ -107,11 +109,10 @@ async function queryKnowledgeBase( } const modelInputMetadata = createModelInputProvenanceRequestMetadata( resolvedSecretTraceRegistry, - query + HALLUCINATION_INPUT_PATHS ) - if (!modelInputMetadata) throw new Error('Knowledge model input provenance is unavailable') + if (!modelInputMetadata) throw new KnowledgeProvenanceError() const body = addModelInputProvenanceToRequest(requestBody, headers, modelInputMetadata) - // boundary-raw-fetch: authenticated internal Knowledge call with private provenance envelopes const response = await fetch(searchUrl, { method: 'POST', @@ -123,7 +124,7 @@ async function queryKnowledgeBase( logger.error(`[${requestId}] Knowledge base query failed`, { status: response.status, }) - return [] + return { context: [], registry: resultRegistry } } const payload: unknown = await response.json() @@ -134,38 +135,42 @@ async function queryKnowledgeBase( payload, RESOLVED_SECRET_PROVENANCE_METADATA_V1 ) - if (inspection.status !== 'verified') throw new KnowledgeProvenanceError() - - const functionalResponse = { ...payload } - delete functionalResponse[RESOLVED_SECRET_PROVENANCE_FIELD] - const imported = await resolvedSecretTraceRegistry.importProvenanceForValue( - inspection.value, - functionalResponse, - { trusted: true } - ) - if (!imported || !resolvedSecretTraceRegistry.isComplete()) { - throw new KnowledgeProvenanceError() + if (inspection.status === 'invalid') throw new KnowledgeProvenanceError() + + let functionalResponse = payload + if (inspection.status === 'verified') { + functionalResponse = { ...payload } + delete functionalResponse[RESOLVED_SECRET_PROVENANCE_FIELD] + const imported = await resultRegistry.importProvenance(inspection.value, { + trusted: true, + }) + if (!imported || !resultRegistry.isComplete()) { + throw new KnowledgeProvenanceError() + } } const data = isPlainRecord(functionalResponse.data) ? functionalResponse.data : undefined const results = Array.isArray(data?.results) ? data.results : [] - return results.flatMap((result) => { - if ( - !isPlainRecord(result) || - typeof result.content !== 'string' || - result.content.length === 0 - ) { - return [] - } - return [result.content] - }) + return { + context: results.flatMap((result) => { + if ( + !isPlainRecord(result) || + typeof result.content !== 'string' || + result.content.length === 0 + ) { + return [] + } + return [result.content] + }), + registry: resultRegistry, + } } catch (error: any) { if (error instanceof KnowledgeProvenanceError) throw error logger.error(`[${requestId}] Error querying knowledge base`, { error: error.message, }) - return [] + return { context: [], registry: resultRegistry } } } @@ -348,27 +353,18 @@ export async function validateHallucination( error: 'Knowledge base ID is required', } } - const projection = projectResolvedSecretModelContent(userInput, resolvedSecretTraceRegistry) - if (!projection.safe || typeof projection.value !== 'string') { - throw new Error('Hallucination input could not be safely projected') - } - const modelSafeUserInput = projection.value - const providerRegistry = resolvedSecretTraceRegistry.forkForToolInput(modelSafeUserInput) - if (!providerRegistry.isComplete()) { - throw new Error('Hallucination model input provenance is unavailable') - } - // Step 1: Query knowledge base with RAG - const ragContext = await queryKnowledgeBase( + const knowledgeResult = await queryKnowledgeBase( knowledgeBaseId, - modelSafeUserInput, + userInput, topK, requestId, actorUserId, billingAttribution, workflowId, - providerRegistry + resolvedSecretTraceRegistry ) + const ragContext = knowledgeResult.context if (ragContext.length === 0) { return { @@ -377,10 +373,34 @@ export async function validateHallucination( } } + const inputRegistry = resolvedSecretTraceRegistry.forkForInputPaths(HALLUCINATION_INPUT_PATHS, { + propagated: true, + }) + const inputProjection = projectResolvedSecretModelContent(userInput, inputRegistry) + const contextProjection = projectResolvedSecretModelContent( + ragContext, + knowledgeResult.registry.forkForPropagatedEntries() + ) + if ( + !inputProjection.safe || + typeof inputProjection.value !== 'string' || + !contextProjection.safe || + !Array.isArray(contextProjection.value) || + !contextProjection.value.every((value) => typeof value === 'string') + ) { + throw new Error('Hallucination model input could not be safely projected') + } + + const providerRegistry = inputRegistry + providerRegistry.mergeToolCallRegistry(knowledgeResult.registry) + if (!providerRegistry.isComplete()) { + throw new Error('Hallucination model input provenance is unavailable') + } + // Step 2: Use LLM to score confidence const { score, reasoning, cost } = await scoreHallucinationWithLLM( - modelSafeUserInput, - ragContext, + inputProjection.value, + contextProjection.value, model, apiKey, providerCredentials, diff --git a/apps/sim/lib/knowledge/model-input-provenance.test.ts b/apps/sim/lib/knowledge/model-input-provenance.test.ts index 3ae612a015d..5bf4957acce 100644 --- a/apps/sim/lib/knowledge/model-input-provenance.test.ts +++ b/apps/sim/lib/knowledge/model-input-provenance.test.ts @@ -1,8 +1,8 @@ /** * @vitest-environment node */ -import { environmentUtilsMockFns, resetEnvironmentUtilsMock } from '@sim/testing' -import { afterEach, describe, expect, it } from 'vitest' +import { encryptionMockFns, environmentUtilsMockFns, resetEnvironmentUtilsMock } from '@sim/testing' +import { afterEach, describe, expect, it, vi } from 'vitest' import { PRIVATE_MODEL_INPUT_PROVENANCE_HEADER } from '@/lib/execution/model-input-provenance' import { RESOLVED_SECRET_PROVENANCE_FIELD, @@ -15,6 +15,10 @@ import { } from '@/lib/knowledge/model-input-provenance' import { ResolvedSecretTraceRegistry } from '@/executor/utils/resolved-secret-trace-registry' +vi.mock('@/lib/core/security/encryption', () => ({ + decryptSecret: encryptionMockFns.mockDecryptSecret, +})) + function verifiedHeaders(): Headers { return new Headers({ [PRIVATE_MODEL_INPUT_PROVENANCE_HEADER]: RESOLVED_SECRET_PROVENANCE_METADATA_V1, @@ -36,6 +40,7 @@ function verifiedPayload(): Record { describe('Knowledge model input provenance', () => { afterEach(() => { resetEnvironmentUtilsMock() + encryptionMockFns.mockDecryptSecret.mockReset() }) it('preserves headerless legacy calls without loading an environment catalog', async () => { @@ -52,7 +57,7 @@ describe('Knowledge model input provenance', () => { expect(environmentUtilsMockFns.mockGetEffectiveEnvironmentSnapshot).not.toHaveBeenCalled() }) - it('rejects a headerless internal call without loading an environment catalog', async () => { + it('preserves a headerless internal legacy call without loading an environment catalog', async () => { const result = await prepareKnowledgeModelInputProvenance({ headers: new Headers(), payload: { query: 'missing provenance' }, @@ -62,11 +67,7 @@ describe('Knowledge model input provenance', () => { modelInput: 'missing provenance', }) - expect(result).toEqual({ - success: false, - error: 'Model input provenance is unavailable', - status: 400, - }) + expect(result).toEqual({ success: true }) expect(environmentUtilsMockFns.mockGetEffectiveEnvironmentSnapshot).not.toHaveBeenCalled() }) @@ -85,6 +86,74 @@ describe('Knowledge model input provenance', () => { expect(environmentUtilsMockFns.mockGetEffectiveEnvironmentSnapshot).not.toHaveBeenCalled() }) + it('does not activate an authenticated entry absent from the exact model input', async () => { + environmentUtilsMockFns.mockGetEffectiveEnvironmentSnapshot.mockResolvedValue({ + personalEncrypted: { TOKEN: 'encrypted-token' }, + workspaceEncrypted: {}, + personalDecrypted: { TOKEN: 'secret-value' }, + workspaceDecrypted: {}, + conflicts: [], + decryptionFailures: [], + }) + encryptionMockFns.mockDecryptSecret.mockResolvedValue({ decrypted: 'secret-value' }) + const payload = { + query: 'derived model input', + [RESOLVED_SECRET_PROVENANCE_FIELD]: { + version: 1, + complete: true, + entries: [{ name: 'TOKEN', encryptedValue: 'encrypted-token' }], + scope: { userId: 'user-1', workspaceId: 'workspace-1' }, + }, + } + + const result = await prepareKnowledgeModelInputProvenance({ + headers: verifiedHeaders(), + payload, + isInternalRequest: true, + userId: 'user-1', + workspaceId: 'workspace-1', + modelInput: 'derived model input', + }) + + expect(result.success).toBe(true) + expect(result.success && result.registry?.getActiveMatches()).toEqual([]) + }) + + it('activates an authenticated entry present in the exact model input', async () => { + environmentUtilsMockFns.mockGetEffectiveEnvironmentSnapshot.mockResolvedValue({ + personalEncrypted: { TOKEN: 'encrypted-token' }, + workspaceEncrypted: {}, + personalDecrypted: { TOKEN: 'secret-value' }, + workspaceDecrypted: {}, + conflicts: [], + decryptionFailures: [], + }) + encryptionMockFns.mockDecryptSecret.mockResolvedValue({ decrypted: 'secret-value' }) + const payload = { + query: 'query with secret-value', + [RESOLVED_SECRET_PROVENANCE_FIELD]: { + version: 1, + complete: true, + entries: [{ name: 'TOKEN', encryptedValue: 'encrypted-token' }], + scope: { userId: 'user-1', workspaceId: 'workspace-1' }, + }, + } + + const result = await prepareKnowledgeModelInputProvenance({ + headers: verifiedHeaders(), + payload, + isInternalRequest: true, + userId: 'user-1', + workspaceId: 'workspace-1', + modelInput: payload.query, + }) + + expect(result.success).toBe(true) + expect(result.success && result.registry?.getActiveMatches()).toEqual([ + { plaintext: 'secret-value', replacement: '{{TOKEN}}' }, + ]) + }) + it('rejects private metadata from a session caller and every partial envelope', async () => { await expect( prepareKnowledgeModelInputProvenance({ diff --git a/apps/sim/lib/knowledge/model-input-provenance.ts b/apps/sim/lib/knowledge/model-input-provenance.ts index c7db243ca4b..fb244dc2ca9 100644 --- a/apps/sim/lib/knowledge/model-input-provenance.ts +++ b/apps/sim/lib/knowledge/model-input-provenance.ts @@ -25,9 +25,9 @@ interface KnowledgeModelInputContext { const knowledgeModelInputContext = new AsyncLocalStorage() /** - * Authenticates and imports provenance supplied by the internal tool transport. External - * headerless calls retain their existing behavior; internal calls and private envelopes fail - * closed when metadata is missing, partial, or forged. + * Authenticates and imports provenance supplied by the internal tool transport. A missing envelope + * is the additive legacy protocol. Once either half of the private protocol is present, partial or + * forged metadata fails closed. */ export async function prepareKnowledgeModelInputProvenance(options: { headers: HeaderReader @@ -38,11 +38,7 @@ export async function prepareKnowledgeModelInputProvenance(options: { modelInput: unknown }): Promise { const inspection = inspectModelInputProvenanceRequest(options.headers, options.payload) - if (inspection.status === 'unsupported') { - return options.isInternalRequest - ? { success: false, error: 'Model input provenance is unavailable', status: 400 } - : { success: true } - } + if (inspection.status === 'unsupported') return { success: true } if (inspection.status === 'invalid' || !options.isInternalRequest) { return { success: false, error: 'Invalid model input provenance', status: 400 } } diff --git a/apps/sim/lib/knowledge/secret-provenance-selection.ts b/apps/sim/lib/knowledge/secret-provenance-selection.ts index 8b87a4672f9..e249c80c2bd 100644 --- a/apps/sim/lib/knowledge/secret-provenance-selection.ts +++ b/apps/sim/lib/knowledge/secret-provenance-selection.ts @@ -37,13 +37,6 @@ export function knowledgeDocumentContentSelectionKey(documentIndex: number): str return `document-content:${documentIndex}` } -export function knowledgeDocumentTagNameSelectionKey( - documentIndex: number, - tagIndex: number -): string { - return `document-tag-name:${documentIndex}:${tagIndex}` -} - export function knowledgeDocumentTagValueSelectionKey( documentIndex: number, tagIndex: number diff --git a/apps/sim/lib/logs/execution/logging-session.test.ts b/apps/sim/lib/logs/execution/logging-session.test.ts index f9624ff79d2..b0ccc67eaba 100644 --- a/apps/sim/lib/logs/execution/logging-session.test.ts +++ b/apps/sim/lib/logs/execution/logging-session.test.ts @@ -179,6 +179,44 @@ describe('LoggingSession diagnostic projection', () => { }) }) +describe('LoggingSession response provenance', () => { + it('exports only active secrets present in the settled response without mutating it', () => { + const session = new LoggingSession('workflow-1', 'execution-1', 'manual') + const registry = new ResolvedSecretTraceRegistry( + [ + { name: 'OUTPUT_SECRET', plaintext: 'secret output', encryptedValue: 'encrypted-output' }, + { name: 'UNUSED_SECRET', plaintext: 'public', encryptedValue: 'encrypted-unused' }, + ], + { userId: 'user-1', workspaceId: 'workspace-1' } + ) + registry.recordResolved('OUTPUT_SECRET', 'secret output') + session.setResolvedSecretTraceRegistry(registry) + const responseBody = { success: false, error: 'failed with secret output', public: 'public' } + + expect(session.exportResolvedSecretTraceProvenanceForValue(responseBody)).toEqual({ + version: 1, + complete: true, + entries: [{ name: 'OUTPUT_SECRET', encryptedValue: 'encrypted-output' }], + scope: { userId: 'user-1', workspaceId: 'workspace-1' }, + }) + expect(responseBody).toEqual({ + success: false, + error: 'failed with secret output', + public: 'public', + }) + }) + + it('returns incomplete provenance when the run registry is unavailable', () => { + const session = new LoggingSession('workflow-1', 'execution-1', 'manual') + + expect(session.exportResolvedSecretTraceProvenanceForValue({ output: 'public' })).toEqual({ + version: 1, + complete: false, + entries: [], + }) + }) +}) + describe('LoggingSession terminal provenance', () => { beforeEach(() => { vi.clearAllMocks() diff --git a/apps/sim/lib/logs/execution/logging-session.ts b/apps/sim/lib/logs/execution/logging-session.ts index b565fb2d2e6..d6b51552096 100644 --- a/apps/sim/lib/logs/execution/logging-session.ts +++ b/apps/sim/lib/logs/execution/logging-session.ts @@ -248,6 +248,17 @@ export class LoggingSession { this.resolvedSecretTraceRegistry = registry } + /** Exports exact active provenance for one settled value without changing that value. */ + exportResolvedSecretTraceProvenanceForValue(value: unknown): ResolvedSecretTraceProvenanceV1 { + return ( + this.resolvedSecretTraceRegistry?.exportCommittedProvenanceForValue(value) ?? { + version: 1, + complete: false, + entries: [], + } + ) + } + /** Projects an execution error for operational logs and telemetry without mutating runtime data. */ projectDiagnosticError( error: unknown, diff --git a/apps/sim/lib/model-router/resolve.test.ts b/apps/sim/lib/model-router/resolve.test.ts index 094e1293a86..6b4ce65483e 100644 --- a/apps/sim/lib/model-router/resolve.test.ts +++ b/apps/sim/lib/model-router/resolve.test.ts @@ -165,31 +165,25 @@ describe('resolveAutoModel', () => { } }) - it('projects active secrets from model-visible signals without changing routing controls', async () => { + it('forwards caller-projected signals without rescanning model content or controls', async () => { const registry = new ResolvedSecretTraceRegistry([ { - name: 'NUMBER_SECRET', - plaintext: '123', - encryptedValue: 'encrypted-number-secret', - }, - { - name: 'BOOLEAN_SECRET', - plaintext: 'true', - encryptedValue: 'encrypted-boolean-secret', + name: 'LOW_ENTROPY_SECRET', + plaintext: 'x', + encryptedValue: 'encrypted-low-entropy-secret', }, ]) - registry.recordResolved('NUMBER_SECRET', '123') - registry.recordResolved('BOOLEAN_SECRET', 'true') + registry.recordResolved('LOW_ENTROPY_SECRET', 'x') mockFetchGo.mockResolvedValue(routerResponse({ choice: '1' })) await resolveAutoModel({ ctx: { ...ctx, resolvedSecretTraceRegistry: registry }, blockId: 'b1', signals: makeSignals({ - systemPrompt: 'System 123 true', - lastMessage: 'Message 123 true', + systemPrompt: 'Box eSign {{LOW_ENTROPY_SECRET}}', + lastMessage: 'Brex {{LOW_ENTROPY_SECRET}}', messageCount: 123, - toolNames: ['123', 'true'], + toolNames: ['x', 'true'], hasResponseFormat: true, approxInputTokens: 123, }), @@ -198,10 +192,10 @@ describe('resolveAutoModel', () => { const body = JSON.parse(mockFetchGo.mock.calls[0][1].body as string) expect(body.signals).toEqual({ - systemPrompt: 'System {{NUMBER_SECRET}} {{BOOLEAN_SECRET}}', - lastMessage: 'Message {{NUMBER_SECRET}} {{BOOLEAN_SECRET}}', + systemPrompt: 'Box eSign {{LOW_ENTROPY_SECRET}}', + lastMessage: 'Brex {{LOW_ENTROPY_SECRET}}', messageCount: 123, - toolNames: ['{{NUMBER_SECRET}}', '{{BOOLEAN_SECRET}}'], + toolNames: ['x', 'true'], hasMedia: false, hasResponseFormat: true, approxInputTokens: 123, @@ -229,9 +223,10 @@ describe('resolveAutoModel', () => { expect(body.signals.toolNames).toEqual(['ordinary-tool-name']) }) - it('falls back without calling mothership when signal provenance is incomplete', async () => { + it('does not gate caller-projected signals on ambient registry completeness', async () => { const registry = new ResolvedSecretTraceRegistry() registry.markIncomplete() + mockFetchGo.mockResolvedValue(routerResponse({ choice: '1' })) const result = await resolveAutoModel({ ctx: { ...ctx, resolvedSecretTraceRegistry: registry }, @@ -240,14 +235,10 @@ describe('resolveAutoModel', () => { fallbackModel: 'claude-sonnet-5', }) - expect(result).toEqual({ - model: 'claude-sonnet-5', - tier: null, - decidedBy: 'fallback', - billableRoutingCost: 0, - }) - expect(mockGetMothershipBaseURL).not.toHaveBeenCalled() - expect(mockFetchGo).not.toHaveBeenCalled() + expect(result.model).toBe('fireworks/glm-5.2') + expect(result.tier).toBe('1') + expect(mockGetMothershipBaseURL).toHaveBeenCalled() + expect(mockFetchGo).toHaveBeenCalled() }) it('never crosses media kinds when walking down from a denied tier', async () => { diff --git a/apps/sim/lib/model-router/resolve.ts b/apps/sim/lib/model-router/resolve.ts index 18268179702..1e1fe8a2d1a 100644 --- a/apps/sim/lib/model-router/resolve.ts +++ b/apps/sim/lib/model-router/resolve.ts @@ -7,7 +7,6 @@ import { env } from '@/lib/core/config/env' import { getCostMultiplier, isHosted } from '@/lib/core/config/env-flags' import { validateModelProvider } from '@/ee/access-control/utils/permission-check' import type { ExecutionContext } from '@/executor/types' -import { projectResolvedSecretModelContent } from '@/executor/utils/resolved-secret-content-projection' import type { ModelCost } from '@/providers/cost-policy' import { getProviderFromModel } from '@/providers/utils' @@ -179,36 +178,6 @@ function writeDecisionCache(key: string, tier: AutoTierId): void { decisionCache.set(key, { tier, expires: Date.now() + DECISION_CACHE_TTL_MS }) } -function projectAutoRoutingSignals( - signals: AutoRoutingSignals, - ctx: ExecutionContext -): AutoRoutingSignals | null { - const projection = projectResolvedSecretModelContent( - [signals.systemPrompt, signals.lastMessage, signals.toolNames], - ctx.resolvedSecretTraceRegistry - ) - if (!projection.safe || !Array.isArray(projection.value) || projection.value.length !== 3) { - return null - } - - const [systemPrompt, lastMessage, toolNames] = projection.value - if ( - (systemPrompt !== undefined && typeof systemPrompt !== 'string') || - (lastMessage !== undefined && typeof lastMessage !== 'string') || - !Array.isArray(toolNames) || - !toolNames.every((name) => typeof name === 'string') - ) { - return null - } - - return { - ...signals, - systemPrompt, - lastMessage, - toolNames, - } -} - /** * Picks the first model of the chosen tier — then of each lower tier for the * same media kind — that is actually usable: its provider resolves and is not @@ -292,7 +261,8 @@ async function callModelRouter( * Resolves the sim-auto pseudo-model to a concrete model for one block * execution. Never throws and never fails the workflow: any error, timeout, * non-hosted deployment, or fully unavailable pool column falls back to - * `fallbackModel` (the block's standard default). + * `fallbackModel` (the block's standard default). Callers must supply the same already-projected + * model-facing signals they pass to their provider boundary; this router never rescans them. */ export async function resolveAutoModel(args: { ctx: ExecutionContext @@ -311,22 +281,14 @@ export async function resolveAutoModel(args: { if (!isHosted) return fallback try { - const projectedSignals = projectAutoRoutingSignals(signals, ctx) - if (!projectedSignals) { - logger.warn('sim-auto: routing signals could not be safely projected, using fallback model', { - blockId, - }) - return fallback - } - // Every execution is classified: a short prompt is not a simple task, and // a local size rule can only ever route DOWN, which is the expensive // mistake. The cache below replays a prior router decision, never a // locally derived one. - const key = cacheKey(projectedSignals) + const key = cacheKey(signals) const cachedTier = readDecisionCache(key) if (cachedTier) { - const model = await pickModelForTier(projectedSignals.mediaKind, cachedTier, ctx) + const model = await pickModelForTier(signals.mediaKind, cachedTier, ctx) if (!model) return fallback return { model, @@ -336,7 +298,7 @@ export async function resolveAutoModel(args: { } } - const response = await callModelRouter(projectedSignals, ctx, blockId) + const response = await callModelRouter(signals, ctx, blockId) const tier = TIERS.find((t) => t.id === response?.choice)?.id if (!tier) { logger.warn('sim-auto: router returned no usable choice, using fallback model', { @@ -347,7 +309,7 @@ export async function resolveAutoModel(args: { } writeDecisionCache(key, tier) - const model = await pickModelForTier(projectedSignals.mediaKind, tier, ctx) + const model = await pickModelForTier(signals.mediaKind, tier, ctx) if (!model) return fallback const billable = response?.billable === true && (response.usage?.cost ?? 0) > 0 diff --git a/apps/sim/lib/table/rows/secret-provenance.ts b/apps/sim/lib/table/rows/secret-provenance.ts index cb75cb6abec..126c3c8ea84 100644 --- a/apps/sim/lib/table/rows/secret-provenance.ts +++ b/apps/sim/lib/table/rows/secret-provenance.ts @@ -666,8 +666,7 @@ export async function isTableSnapshotSafeForModelMount(options: { eq(userTableRows.tableId, options.tableId), eq(userTableRows.workspaceId, options.workspaceId), sql`NOT ( - (${userTableRows.secretProvenanceVersion} IS NULL - AND ${userTableRowSecretProvenance.rowId} IS NULL) + ${userTableRows.secretProvenanceVersion} IS NULL OR (${userTableRows.secretProvenanceVersion} = ${TABLE_ROW_SECRET_PROVENANCE_VERSION} AND ${userTableRowSecretProvenance.status} = 'exact' diff --git a/apps/sim/lib/table/secret-provenance-selection.test.ts b/apps/sim/lib/table/secret-provenance-selection.test.ts index f54a59a835e..d68ceb67435 100644 --- a/apps/sim/lib/table/secret-provenance-selection.test.ts +++ b/apps/sim/lib/table/secret-provenance-selection.test.ts @@ -17,16 +17,19 @@ interface TableWriteRequestBody { describe('selectTableRowSecretProvenance', () => { it('omits undefined properties that JSON object serialization drops', () => { - const selections = selectTableRowSecretProvenance([ - { email: 'user@example.com', status: null, processed_at: undefined }, - { email: 'other@example.com', processed_at: '2026-08-06T10:00:00.000Z' }, - ]) + const selections = selectTableRowSecretProvenance( + [ + { email: 'user@example.com', status: null, processed_at: undefined }, + { email: 'other@example.com', processed_at: '2026-08-06T10:00:00.000Z' }, + ], + 'rows' + ) expect(selections).toEqual([ - { key: '[0,"email"]', value: 'user@example.com' }, - { key: '[0,"status"]', value: null }, - { key: '[1,"email"]', value: 'other@example.com' }, - { key: '[1,"processed_at"]', value: '2026-08-06T10:00:00.000Z' }, + { key: '[0,"email"]', inputPaths: [['rows', '0', 'email']] }, + { key: '[0,"status"]', inputPaths: [['rows', '0', 'status']] }, + { key: '[1,"email"]', inputPaths: [['rows', '1', 'email']] }, + { key: '[1,"processed_at"]', inputPaths: [['rows', '1', 'processed_at']] }, ]) }) diff --git a/apps/sim/lib/table/secret-provenance-selection.ts b/apps/sim/lib/table/secret-provenance-selection.ts index a9bb19f5a1f..17e247d36ae 100644 --- a/apps/sim/lib/table/secret-provenance-selection.ts +++ b/apps/sim/lib/table/secret-provenance-selection.ts @@ -3,14 +3,17 @@ import type { RowData } from '@/lib/table/types' /** Stable keyed selections shared by table tool descriptors and authenticated routes. */ export function selectTableRowSecretProvenance( - rows: readonly Partial[] + rows: readonly Partial[], + inputRoot: 'data' | 'rows' = 'data' ): PrivateSecretProvenanceSelection[] { return rows.flatMap((row, rowIndex) => Object.entries(row) .filter(([, value]) => value !== undefined) .map(([columnKey, value]) => ({ key: tableRowSecretProvenanceSelectionKey(rowIndex, columnKey), - value, + inputPaths: [ + inputRoot === 'rows' ? ['rows', String(rowIndex), columnKey] : ['data', columnKey], + ], })) ) } diff --git a/apps/sim/lib/uploads/archive.test.ts b/apps/sim/lib/uploads/archive.test.ts index 2f9ad86e98d..a04a42b4689 100644 --- a/apps/sim/lib/uploads/archive.test.ts +++ b/apps/sim/lib/uploads/archive.test.ts @@ -107,7 +107,7 @@ describe('decompressArchiveBufferToWorkspaceFiles', () => { ) }) - it('propagates the archive classification to every extracted byte stream', async () => { + it('marks extracted files unknown when an archive has secret provenance', async () => { const buffer = await buildZip({ 'one.txt': 'one', 'two.txt': 'two' }) const secretProvenance = { status: 'exact' as const, @@ -122,10 +122,31 @@ describe('decompressArchiveBufferToWorkspaceFiles', () => { expect(mockUpload).toHaveBeenCalledTimes(2) for (const call of mockUpload.mock.calls) { - expect(call[5]).toEqual(expect.objectContaining({ secretProvenance })) + expect(call[5]).toEqual(expect.objectContaining({ secretProvenance: { status: 'unknown' } })) } }) + it('preserves an exact-empty classification across extraction', async () => { + const buffer = await buildZip({ 'one.txt': 'one' }) + + await decompressArchiveBufferToWorkspaceFiles(buffer, { + workspaceId: 'ws', + userId: 'u', + secretProvenance: { status: 'exact', entries: [] }, + }) + + expect(mockUpload).toHaveBeenCalledWith( + 'ws', + 'u', + expect.any(Buffer), + 'one.txt', + 'text/plain', + expect.objectContaining({ + secretProvenance: { status: 'exact', entries: [] }, + }) + ) + }) + it('rejects an archive with more central-directory records than the cap, before parsing', async () => { // A structurally valid central directory (EOCD-anchored) with one record more // than the parse-graph cap. JSZip would build one entry per record in the diff --git a/apps/sim/lib/uploads/archive.ts b/apps/sim/lib/uploads/archive.ts index 89c118f41f7..0439fe4152e 100644 --- a/apps/sim/lib/uploads/archive.ts +++ b/apps/sim/lib/uploads/archive.ts @@ -255,7 +255,9 @@ function throwInflateCapError(reason: 'entry' | 'total', entryName: string): nev * * Filesystem-noise entries (`__MACOSX/`, `.DS_Store`, `Thumbs.db`) are extracted * verbatim unless `skipNoiseEntries` is set — the HTTP decompress route preserves - * them; the agent-facing extract path drops them. + * them; the agent-facing extract path drops them. Decompression is not byte-preserving, + * so only an exact-empty archive classification can remain exact on extracted files; + * every other classification becomes unknown without changing the extracted bytes. */ export async function decompressArchiveBufferToWorkspaceFiles( buffer: Buffer, @@ -274,6 +276,10 @@ export async function decompressArchiveBufferToWorkspaceFiles( skipNoiseEntries = false, secretProvenance = { status: 'unknown' }, } = opts + const extractedSecretProvenance: WorkspaceFileSecretProvenance = + secretProvenance.status === 'exact' && secretProvenance.entries.length === 0 + ? secretProvenance + : { status: 'unknown' } assertCentralDirWithinCaps(buffer) @@ -374,7 +380,7 @@ export async function decompressArchiveBufferToWorkspaceFiles( mimeType, { folderId, - secretProvenance, + secretProvenance: extractedSecretProvenance, } ) extracted.push(uploaded) diff --git a/apps/sim/lib/uploads/contexts/workspace/workspace-file-secret-provenance.test.ts b/apps/sim/lib/uploads/contexts/workspace/workspace-file-secret-provenance.test.ts index 3056670f59b..a565b08510b 100644 --- a/apps/sim/lib/uploads/contexts/workspace/workspace-file-secret-provenance.test.ts +++ b/apps/sim/lib/uploads/contexts/workspace/workspace-file-secret-provenance.test.ts @@ -9,9 +9,11 @@ import { areModelSafeWorkspaceFileKeys, copyWorkspaceFileSecretProvenanceInTx, filterModelSafeWorkspaceFileAttachments, + importWorkspaceFileSecretProvenanceForModelView, importWorkspaceFileSecretProvenanceForRuntime, initializeWorkspaceFileSecretProvenanceInTx, isModelSafeWorkspaceFileKey, + isOpaqueWorkspaceFileEgressSafe, mergeWorkspaceFileSecretProvenance, preserveWorkspaceFileSecretProvenanceInTx, replaceWorkspaceFileSecretProvenanceInTx, @@ -35,6 +37,7 @@ describe('workspace file secret provenance', () => { { status: 'exact', entries: [ + { encryptedValue: 'anonymous', sourceUserId: 'user-1' }, { name: 'z', encryptedValue: 'b', sourceUserId: 'user-1' }, { name: 'a', encryptedValue: 'z', sourceUserId: 'user-1' }, { name: 'a', encryptedValue: 'a', sourceUserId: 'user-1' }, @@ -49,6 +52,12 @@ describe('workspace file secret provenance', () => { contentUpdatedAt: CONTENT_UPDATED_AT, status: 'exact', entries: [ + { + name: 'MOUNTED_FILE_SECRET', + encryptedValue: 'anonymous', + sourceUserId: 'user-1', + anonymous: true, + }, { name: 'a', encryptedValue: 'a', sourceUserId: 'user-1' }, { name: 'a', encryptedValue: 'z', sourceUserId: 'user-1' }, { name: 'z', encryptedValue: 'b', sourceUserId: 'user-1' }, @@ -59,6 +68,56 @@ describe('workspace file secret provenance', () => { expect(dbChainMockFns.set).toHaveBeenCalledWith({ secretProvenanceVersion: 1 }) }) + it('keeps the legacy logical-byte budget when storing an anonymous entry', async () => { + await replaceWorkspaceFileSecretProvenanceInTx( + dbChainMock.db as unknown as DbTransaction, + 'file-1', + CONTENT_UPDATED_AT, + { + status: 'exact', + entries: [ + { + encryptedValue: 'x'.repeat(8 * 1024 * 1024 - 1), + sourceUserId: 'u', + }, + ], + } + ) + + expect(dbChainMockFns.values).toHaveBeenCalledWith( + expect.objectContaining({ + entries: [ + expect.objectContaining({ + name: 'MOUNTED_FILE_SECRET', + sourceUserId: 'u', + anonymous: true, + }), + ], + }) + ) + }) + + it('rejects entries beyond the legacy logical-byte budget', async () => { + await expect( + replaceWorkspaceFileSecretProvenanceInTx( + dbChainMock.db as unknown as DbTransaction, + 'file-1', + CONTENT_UPDATED_AT, + { + status: 'exact', + entries: [ + { + encryptedValue: 'x'.repeat(8 * 1024 * 1024), + sourceUserId: 'u', + }, + ], + } + ) + ).rejects.toThrow('exceeds its size limit') + + expect(dbChainMockFns.values).not.toHaveBeenCalled() + }) + it('initializes a missing classification without replacing an existing one', async () => { await initializeWorkspaceFileSecretProvenanceInTx( dbChainMock.db as unknown as DbTransaction, @@ -199,7 +258,10 @@ describe('workspace file secret provenance', () => { secretProvenanceVersion: 1, provenanceContentUpdatedAt: CONTENT_UPDATED_AT, status: 'exact', - entries: [{ name: 'API_KEY', encryptedValue: 'encrypted', sourceUserId: 'user-1' }], + entries: [ + { name: 'API_KEY', encryptedValue: 'encrypted-original', sourceUserId: 'user-1' }, + { name: 'API_KEY', encryptedValue: 'encrypted-representation', sourceUserId: 'user-1' }, + ], }, { id: 'unknown-id', @@ -312,6 +374,42 @@ describe('workspace file secret provenance', () => { await expect(isModelSafeWorkspaceFileKey('legacy-key')).resolves.toBe(true) }) + it('allows opaque egress only for exact-empty or legacy file provenance', async () => { + queueTableRows(workspaceFiles, [ + { + fileContentUpdatedAt: CONTENT_UPDATED_AT, + secretProvenanceVersion: null, + provenanceContentUpdatedAt: null, + status: null, + entries: null, + }, + ]) + await expect( + isOpaqueWorkspaceFileEgressSafe('workspace-1', { + fileId: 'legacy-file', + key: 'legacy-key', + context: 'workspace', + }) + ).resolves.toBe(true) + + queueTableRows(workspaceFiles, [ + { + fileContentUpdatedAt: CONTENT_UPDATED_AT, + secretProvenanceVersion: 1, + provenanceContentUpdatedAt: CONTENT_UPDATED_AT, + status: 'exact', + entries: [{ name: 'API_KEY', encryptedValue: 'encrypted', sourceUserId: 'user-1' }], + }, + ]) + await expect( + isOpaqueWorkspaceFileEgressSafe('workspace-1', { + fileId: 'tracked-file', + key: 'tracked-key', + context: 'workspace', + }) + ).resolves.toBe(false) + }) + it('imports exact mounted-file provenance and keeps legacy-null files compatible', async () => { const registry = { importProvenance: vi.fn().mockResolvedValue(true), @@ -323,7 +421,13 @@ describe('workspace file secret provenance', () => { secretProvenanceVersion: 1, provenanceContentUpdatedAt: CONTENT_UPDATED_AT, status: 'exact', - entries: [{ name: 'API_KEY', encryptedValue: 'encrypted', sourceUserId: 'user-1' }], + entries: [ + { + name: '__SIM_INTERNAL_ANONYMOUS_SECRET_PROVENANCE_V1__', + encryptedValue: 'encrypted', + sourceUserId: 'user-1', + }, + ], }, ]) @@ -338,7 +442,85 @@ describe('workspace file secret provenance', () => { { version: 1, complete: true, - entries: [{ name: 'API_KEY', encryptedValue: 'encrypted' }], + entries: [ + { + name: '__SIM_INTERNAL_ANONYMOUS_SECRET_PROVENANCE_V1__', + encryptedValue: 'encrypted', + }, + ], + scope: { userId: 'user-1' }, + }, + { trusted: true } + ) + + queueTableRows(workspaceFiles, [ + { + fileContentUpdatedAt: CONTENT_UPDATED_AT, + secretProvenanceVersion: 1, + provenanceContentUpdatedAt: CONTENT_UPDATED_AT, + status: 'exact', + entries: [ + { + name: ':SIM_INTERNAL_ANONYMOUS_SECRET_PROVENANCE_V1:', + encryptedValue: 'reserved-name-encrypted', + sourceUserId: 'user-1', + }, + ], + }, + ]) + vi.mocked(registry.importProvenance).mockClear() + + await expect( + importWorkspaceFileSecretProvenanceForRuntime({ + workspaceId: 'workspace-1', + identity: { fileId: 'reserved-name-file', key: 'reserved-name-key', context: 'workspace' }, + registry, + }) + ).resolves.toBe(true) + expect(registry.importProvenance).toHaveBeenCalledWith( + { + version: 1, + complete: true, + entries: [ + { + encryptedValue: 'reserved-name-encrypted', + }, + ], + scope: { userId: 'user-1' }, + }, + { trusted: true } + ) + + queueTableRows(workspaceFiles, [ + { + fileContentUpdatedAt: CONTENT_UPDATED_AT, + secretProvenanceVersion: 1, + provenanceContentUpdatedAt: CONTENT_UPDATED_AT, + status: 'exact', + entries: [ + { + name: ':SIM_INTERNAL_ANONYMOUS_SECRET_PROVENANCE_V1:', + anonymous: true, + encryptedValue: 'anonymous-encrypted', + sourceUserId: 'user-1', + }, + ], + }, + ]) + vi.mocked(registry.importProvenance).mockClear() + + await expect( + importWorkspaceFileSecretProvenanceForRuntime({ + workspaceId: 'workspace-1', + identity: { fileId: 'anonymous-file', key: 'anonymous-key', context: 'workspace' }, + registry, + }) + ).resolves.toBe(true) + expect(registry.importProvenance).toHaveBeenCalledWith( + { + version: 1, + complete: true, + entries: [{ encryptedValue: 'anonymous-encrypted' }], scope: { userId: 'user-1' }, }, { trusted: true } @@ -365,6 +547,132 @@ describe('workspace file secret provenance', () => { expect(registry.importProvenance).not.toHaveBeenCalled() }) + it('imports the complete sidecar for an exact model-visible file view', async () => { + const registry = { + importProvenance: vi.fn().mockResolvedValue(true), + isPermanentlyIncomplete: vi.fn().mockReturnValue(false), + } as unknown as ResolvedSecretTraceRegistry + queueTableRows(workspaceFiles, [ + { + fileContentUpdatedAt: CONTENT_UPDATED_AT, + secretProvenanceVersion: 1, + provenanceContentUpdatedAt: CONTENT_UPDATED_AT, + status: 'exact', + entries: [ + { name: 'API_KEY', encryptedValue: 'encrypted-original', sourceUserId: 'user-1' }, + { name: 'API_KEY', encryptedValue: 'encrypted-representation', sourceUserId: 'user-1' }, + ], + }, + ]) + + await expect( + importWorkspaceFileSecretProvenanceForModelView({ + workspaceId: 'workspace-1', + identity: { fileId: 'file-1', key: 'file-key', context: 'workspace' }, + registry, + view: 'complete', + }) + ).resolves.toBe(true) + expect(registry.importProvenance).toHaveBeenCalledWith( + { + version: 1, + complete: true, + entries: [ + { name: 'API_KEY', encryptedValue: 'encrypted-original' }, + { name: 'API_KEY', encryptedValue: 'encrypted-representation' }, + ], + scope: { userId: 'user-1' }, + }, + { trusted: true } + ) + }) + + it('rejects a contributor identity captured from an older file content version', async () => { + queueTableRows(workspaceFiles, [ + { + fileContentUpdatedAt: CONTENT_UPDATED_AT, + secretProvenanceVersion: null, + provenanceContentUpdatedAt: null, + status: null, + entries: null, + }, + ]) + + await expect( + importWorkspaceFileSecretProvenanceForModelView({ + workspaceId: 'workspace-1', + identity: { + fileId: 'file-1', + key: 'file-key', + context: 'workspace', + contentUpdatedAt: new Date(CONTENT_UPDATED_AT.getTime() - 1), + }, + view: 'opaque', + }) + ).resolves.toBe(false) + }) + + it('rejects derived content views of tracked files', async () => { + const registry = { + importProvenance: vi.fn().mockResolvedValue(true), + isPermanentlyIncomplete: vi.fn().mockReturnValue(false), + } as unknown as ResolvedSecretTraceRegistry + const trackedRow = { + fileContentUpdatedAt: CONTENT_UPDATED_AT, + secretProvenanceVersion: 1, + provenanceContentUpdatedAt: CONTENT_UPDATED_AT, + status: 'exact', + entries: [{ name: 'MULTILINE', encryptedValue: 'encrypted', sourceUserId: 'user-1' }], + } + queueTableRows(workspaceFiles, [trackedRow]) + + await expect( + importWorkspaceFileSecretProvenanceForModelView({ + workspaceId: 'workspace-1', + identity: { fileId: 'file-1', key: 'file-key', context: 'workspace' }, + registry, + view: 'derived', + }) + ).resolves.toBe(false) + expect(registry.importProvenance).not.toHaveBeenCalled() + }) + + it('filters tracked provenance against the exact derived model view', async () => { + const registry = { + importProvenanceForValue: vi.fn().mockResolvedValue(true), + isPermanentlyIncomplete: vi.fn().mockReturnValue(false), + } as unknown as ResolvedSecretTraceRegistry + queueTableRows(workspaceFiles, [ + { + fileContentUpdatedAt: CONTENT_UPDATED_AT, + secretProvenanceVersion: 1, + provenanceContentUpdatedAt: CONTENT_UPDATED_AT, + status: 'exact', + entries: [{ name: 'TOKEN', encryptedValue: 'encrypted', sourceUserId: 'user-1' }], + }, + ]) + + await expect( + importWorkspaceFileSecretProvenanceForModelView({ + workspaceId: 'workspace-1', + identity: { fileId: 'file-1', key: 'file-key', context: 'workspace' }, + registry, + view: 'derived', + value: 'derived text', + }) + ).resolves.toBe(true) + expect(registry.importProvenanceForValue).toHaveBeenCalledWith( + { + version: 1, + complete: true, + entries: [{ name: 'TOKEN', encryptedValue: 'encrypted' }], + scope: { userId: 'user-1' }, + }, + 'derived text', + { trusted: true } + ) + }) + it('rejects unavailable mounted-file provenance without importing it', async () => { const registry = { importProvenance: vi.fn(), @@ -713,6 +1021,63 @@ describe('workspace file secret provenance', () => { ) }) + it('preserves anonymous provenance across a byte-identical file copy', async () => { + const targetContentUpdatedAt = new Date('2026-08-04T00:00:01.000Z') + dbChainMockFns.limit + .mockResolvedValueOnce([ + { + userId: 'user-1', + workspaceId: 'workspace-1', + contentUpdatedAt: targetContentUpdatedAt, + }, + ]) + .mockResolvedValueOnce([ + { + key: 'source-key', + userId: 'user-1', + workspaceId: 'workspace-1', + secretProvenanceVersion: 1, + fileContentUpdatedAt: CONTENT_UPDATED_AT, + provenanceContentUpdatedAt: CONTENT_UPDATED_AT, + status: 'exact', + entries: [ + { + name: ':SIM_INTERNAL_ANONYMOUS_SECRET_PROVENANCE_V1:', + anonymous: true, + encryptedValue: 'anonymous-encrypted', + sourceUserId: 'user-1', + }, + ], + }, + ]) + + await copyWorkspaceFileSecretProvenanceInTx( + dbChainMock.db as unknown as DbTransaction, + { + fileId: 'source-file', + key: 'source-key', + contentUpdatedAtMs: CONTENT_UPDATED_AT.getTime(), + }, + 'target-file' + ) + + expect(dbChainMockFns.values).toHaveBeenCalledWith( + expect.objectContaining({ + fileId: 'target-file', + contentUpdatedAt: targetContentUpdatedAt, + status: 'exact', + entries: [ + { + name: 'MOUNTED_FILE_SECRET', + anonymous: true, + encryptedValue: 'anonymous-encrypted', + sourceUserId: 'user-1', + }, + ], + }) + ) + }) + it('keeps an untouched legacy file untracked when copied', async () => { const targetContentUpdatedAt = new Date('2026-08-04T00:00:01.000Z') dbChainMockFns.limit diff --git a/apps/sim/lib/uploads/contexts/workspace/workspace-file-secret-provenance.ts b/apps/sim/lib/uploads/contexts/workspace/workspace-file-secret-provenance.ts index a33c61d594a..dd13b94524e 100644 --- a/apps/sim/lib/uploads/contexts/workspace/workspace-file-secret-provenance.ts +++ b/apps/sim/lib/uploads/contexts/workspace/workspace-file-secret-provenance.ts @@ -1,16 +1,27 @@ import { db } from '@sim/db' import { + type StoredWorkspaceFileSecretProvenanceEntry, type WorkspaceFileSecretProvenanceEntry, workspaceFileSecretProvenance, workspaceFiles, } from '@sim/db/schema' import { and, eq, gte, inArray, isNull, lt, or } from 'drizzle-orm' +import { encryptSecret } from '@/lib/core/security/encryption' import type { DbTransaction } from '@/lib/db/types' -import { importDurableSecretProvenance } from '@/lib/execution/durable-secret-provenance' -import type { ResolvedSecretTraceRegistry } from '@/executor/utils/resolved-secret-trace-registry' +import { + importDurableSecretProvenance, + isPrivateSecretProvenanceScopeCompatible, +} from '@/lib/execution/durable-secret-provenance' +import type { + ResolvedSecretTraceProvenanceV1, + ResolvedSecretTraceRegistry, +} from '@/executor/utils/resolved-secret-trace-registry' const MAX_WORKSPACE_FILE_SECRET_PROVENANCE_ENTRIES = 10_000 const MAX_WORKSPACE_FILE_SECRET_PROVENANCE_BYTES = 8 * 1024 * 1024 +const ANONYMOUS_WORKSPACE_FILE_SECRET_STORAGE_NAME = 'MOUNTED_FILE_SECRET' +const LEGACY_ANONYMOUS_WORKSPACE_FILE_SECRET_STORAGE_NAME = + ':SIM_INTERNAL_ANONYMOUS_SECRET_PROVENANCE_V1:' export const MODEL_UNSAFE_WORKSPACE_FILE_ERROR_MESSAGE = 'File cannot be sent to a model because its secret provenance is unavailable' const MAX_MODEL_ATTACHMENT_PROVENANCE_LOOKUPS = 1_000 @@ -29,6 +40,15 @@ export type WorkspaceFileSecretProvenancePolicy = | { mode: 'replace'; provenance: WorkspaceFileSecretProvenance } | { mode: 'preserve' } +export type WorkspaceFileSecretProvenanceWriteDecision = + | { safe: true; provenance: WorkspaceFileSecretProvenance } + | { safe: false } + +export interface WorkspaceFileSecretProvenanceRepresentation { + sourceProvenance: ResolvedSecretTraceProvenanceV1 + persistedValue: string +} + interface WorkspaceFileAttachmentIdentity { id?: unknown key?: unknown @@ -38,6 +58,7 @@ export interface WorkspaceFileSecretProvenanceIdentity { fileId: string key: string context: 'workspace' | 'mothership' + contentUpdatedAt?: Date } interface WorkspaceFileSecretProvenanceCopySource { @@ -57,6 +78,8 @@ interface WorkspaceFileSecretProvenanceMetadataIdentity { export interface WorkspaceFileSecretProvenanceEnvelope { value: T file?: WorkspaceFileSecretProvenanceIdentity + contributingFiles?: readonly WorkspaceFileSecretProvenanceIdentity[] + view?: 'complete' | 'derived' } interface ModelSafeWorkspaceFileRow { @@ -90,6 +113,30 @@ function compareStrings(left: string, right: string): number { return left < right ? -1 : left > right ? 1 : 0 } +function exactEntryByteSize(entry: WorkspaceFileSecretProvenanceEntry): number { + return ( + Buffer.byteLength(entry.sourceUserId, 'utf8') + + Buffer.byteLength(entry.sourceWorkspaceId ?? '', 'utf8') + + Buffer.byteLength(entry.name ?? '', 'utf8') + + Buffer.byteLength(entry.encryptedValue, 'utf8') + ) +} + +function isAnonymousStoredEntry(entry: StoredWorkspaceFileSecretProvenanceEntry): boolean { + return ( + entry.anonymous === true || entry.name === LEGACY_ANONYMOUS_WORKSPACE_FILE_SECRET_STORAGE_NAME + ) +} + +function storedEntryLogicalByteSize(entry: StoredWorkspaceFileSecretProvenanceEntry): number { + return exactEntryByteSize({ + encryptedValue: entry.encryptedValue, + sourceUserId: entry.sourceUserId, + ...(isAnonymousStoredEntry(entry) ? {} : { name: entry.name }), + ...(entry.sourceWorkspaceId ? { sourceWorkspaceId: entry.sourceWorkspaceId } : {}), + }) +} + function normalizeExactEntries( entries: readonly WorkspaceFileSecretProvenanceEntry[] ): WorkspaceFileSecretProvenanceEntry[] { @@ -100,23 +147,23 @@ function normalizeExactEntries( const normalized = new Map() let bytes = 0 for (const entry of entries) { - if (!entry.name || !entry.encryptedValue || !entry.sourceUserId) { + if ( + !entry.encryptedValue || + !entry.sourceUserId || + (entry.name !== undefined && entry.name.length === 0) + ) { throw new Error('Workspace file secret provenance contains an invalid entry') } - const key = `${entry.sourceUserId}\u0000${entry.sourceWorkspaceId ?? ''}\u0000${entry.name}\u0000${entry.encryptedValue}` + const key = `${entry.sourceUserId}\u0000${entry.sourceWorkspaceId ?? ''}\u0000${entry.name ?? ''}\u0000${entry.encryptedValue}` if (normalized.has(key)) continue - bytes += - Buffer.byteLength(entry.sourceUserId, 'utf8') + - Buffer.byteLength(entry.sourceWorkspaceId ?? '', 'utf8') + - Buffer.byteLength(entry.name, 'utf8') + - Buffer.byteLength(entry.encryptedValue, 'utf8') + bytes += exactEntryByteSize(entry) if (bytes > MAX_WORKSPACE_FILE_SECRET_PROVENANCE_BYTES) { throw new Error('Workspace file secret provenance exceeds its size limit') } normalized.set(key, { - name: entry.name, encryptedValue: entry.encryptedValue, sourceUserId: entry.sourceUserId, + ...(entry.name ? { name: entry.name } : {}), ...(entry.sourceWorkspaceId ? { sourceWorkspaceId: entry.sourceWorkspaceId } : {}), }) } @@ -125,12 +172,147 @@ function normalizeExactEntries( (left, right) => compareStrings(left.sourceUserId, right.sourceUserId) || compareStrings(left.sourceWorkspaceId ?? '', right.sourceWorkspaceId ?? '') || - compareStrings(left.name, right.name) || + compareStrings(left.name ?? '', right.name ?? '') || compareStrings(left.encryptedValue, right.encryptedValue) ) } -function isValidStoredEntries(value: unknown): value is WorkspaceFileSecretProvenanceEntry[] { +function serializeExactEntriesForStorage( + entries: readonly WorkspaceFileSecretProvenanceEntry[] +): StoredWorkspaceFileSecretProvenanceEntry[] { + return normalizeExactEntries(entries).map( + (entry): StoredWorkspaceFileSecretProvenanceEntry => + entry.name + ? { ...entry, name: entry.name } + : { + ...entry, + name: ANONYMOUS_WORKSPACE_FILE_SECRET_STORAGE_NAME, + anonymous: true, + } + ) +} + +function deserializeExactEntriesFromStorage( + entries: readonly StoredWorkspaceFileSecretProvenanceEntry[] +): WorkspaceFileSecretProvenanceEntry[] { + return entries.map((entry) => ({ + encryptedValue: entry.encryptedValue, + sourceUserId: entry.sourceUserId, + ...(isAnonymousStoredEntry(entry) ? {} : { name: entry.name }), + ...(entry.sourceWorkspaceId ? { sourceWorkspaceId: entry.sourceWorkspaceId } : {}), + })) +} + +/** Captures committed provenance for the exact bytes produced inside one workspace execution. */ +export async function createWorkspaceFileSecretProvenanceFromRegistry( + registry: ResolvedSecretTraceRegistry | undefined, + persistedValue: unknown, + destinationScope: { userId: string; workspaceId: string }, + sourceValue: unknown = persistedValue, + representations: readonly WorkspaceFileSecretProvenanceRepresentation[] = [], + representationsComplete = true +): Promise { + if (!registry) return { safe: true, provenance: { status: 'unknown' } } + const sourceProvenance = registry.exportCommittedProvenanceForValue(sourceValue) + const persistedProvenance = Object.is(sourceValue, persistedValue) + ? sourceProvenance + : registry.exportCommittedProvenanceForValue(persistedValue) + if (!sourceProvenance.complete || !persistedProvenance.complete) return { safe: false } + if ( + (sourceProvenance.entries.length > 0 && + !isPrivateSecretProvenanceScopeCompatible(sourceProvenance.scope, destinationScope)) || + (persistedProvenance.entries.length > 0 && + !isPrivateSecretProvenanceScopeCompatible(persistedProvenance.scope, destinationScope)) + ) { + return { safe: false } + } + if (!representationsComplete) { + return { safe: false } + } + + const provenanceEntryKey = (entry: { name?: string; encryptedValue: string }): string => + `${entry.name ?? ''}\u0000${entry.encryptedValue}` + const sourceEntryKeys = new Set(sourceProvenance.entries.map(provenanceEntryKey)) + const persistedEntryKeys = new Set(persistedProvenance.entries.map(provenanceEntryKey)) + const representedSourceEntryKeys = new Set(persistedEntryKeys) + const derivedRepresentations = new Map() + for (const representation of representations) { + if (!representation.sourceProvenance.complete) return { safe: false } + if ( + representation.sourceProvenance.entries.length > 0 && + !isPrivateSecretProvenanceScopeCompatible( + representation.sourceProvenance.scope, + destinationScope + ) + ) { + return { safe: false } + } + for (const entry of representation.sourceProvenance.entries) { + const sourceEntryKey = provenanceEntryKey(entry) + if (!sourceEntryKeys.has(sourceEntryKey)) return { safe: false } + representedSourceEntryKeys.add(sourceEntryKey) + if (persistedEntryKeys.has(sourceEntryKey)) continue + if (representation.persistedValue.length === 0) { + return { safe: false } + } + derivedRepresentations.set(`${entry.name ?? ''}\u0000${representation.persistedValue}`, { + ...(entry.name ? { name: entry.name } : {}), + persistedValue: representation.persistedValue, + }) + } + } + if ( + sourceProvenance.entries.some( + (entry) => !representedSourceEntryKeys.has(provenanceEntryKey(entry)) + ) + ) { + return { safe: false } + } + if (persistedProvenance.entries.length === 0 && derivedRepresentations.size === 0) { + return { safe: true, provenance: EXACT_EMPTY_WORKSPACE_FILE_SECRET_PROVENANCE } + } + const sourceScope = sourceProvenance.scope + if (!sourceScope) return { safe: false } + + const entries: WorkspaceFileSecretProvenanceEntry[] = [] + for (const entry of sourceProvenance.entries) { + entries.push({ + encryptedValue: entry.encryptedValue, + sourceUserId: sourceScope.userId, + ...(entry.name ? { name: entry.name } : {}), + ...(sourceScope.workspaceId ? { sourceWorkspaceId: sourceScope.workspaceId } : {}), + }) + } + if (entries.length + derivedRepresentations.size > MAX_WORKSPACE_FILE_SECRET_PROVENANCE_ENTRIES) { + return { safe: false } + } + try { + for (const representation of derivedRepresentations.values()) { + const { encrypted } = await encryptSecret(representation.persistedValue) + entries.push({ + encryptedValue: encrypted, + sourceUserId: sourceScope.userId, + ...(representation.name ? { name: representation.name } : {}), + ...(sourceScope.workspaceId ? { sourceWorkspaceId: sourceScope.workspaceId } : {}), + }) + } + } catch { + return { safe: false } + } + try { + return { + safe: true, + provenance: { + status: 'exact', + entries: normalizeExactEntries(entries), + }, + } + } catch { + return { safe: false } + } +} + +function isValidStoredEntries(value: unknown): value is StoredWorkspaceFileSecretProvenanceEntry[] { if (!Array.isArray(value) || value.length > MAX_WORKSPACE_FILE_SECRET_PROVENANCE_ENTRIES) { return false } @@ -142,6 +324,12 @@ function isValidStoredEntries(value: unknown): value is WorkspaceFileSecretProve Array.isArray(entry) || typeof (entry as Record).name !== 'string' || !(entry as Record).name || + ((entry as Record).anonymous !== undefined && + (entry as Record).anonymous !== true) || + ((entry as Record).anonymous === true && + (entry as Record).name !== ANONYMOUS_WORKSPACE_FILE_SECRET_STORAGE_NAME && + (entry as Record).name !== + LEGACY_ANONYMOUS_WORKSPACE_FILE_SECRET_STORAGE_NAME) || typeof (entry as Record).encryptedValue !== 'string' || !(entry as Record).encryptedValue || typeof (entry as Record).sourceUserId !== 'string' || @@ -151,12 +339,7 @@ function isValidStoredEntries(value: unknown): value is WorkspaceFileSecretProve ) { return false } - const record = entry as WorkspaceFileSecretProvenanceEntry - bytes += - Buffer.byteLength(record.sourceUserId, 'utf8') + - Buffer.byteLength(record.sourceWorkspaceId ?? '', 'utf8') + - Buffer.byteLength(record.name, 'utf8') + - Buffer.byteLength(record.encryptedValue, 'utf8') + bytes += storedEntryLogicalByteSize(entry as StoredWorkspaceFileSecretProvenanceEntry) if (bytes > MAX_WORKSPACE_FILE_SECRET_PROVENANCE_BYTES) return false } return true @@ -197,7 +380,7 @@ export async function replaceWorkspaceFileSecretProvenanceInTx( provenance: WorkspaceFileSecretProvenance ): Promise { if (provenance.status === 'exact') { - const entries = normalizeExactEntries(provenance.entries) + const entries = serializeExactEntriesForStorage(provenance.entries) await tx .insert(workspaceFileSecretProvenance) .values({ fileId, contentUpdatedAt, status: 'exact', entries, updatedAt: new Date() }) @@ -226,7 +409,8 @@ export async function initializeWorkspaceFileSecretProvenanceInTx( contentUpdatedAt: Date, provenance: WorkspaceFileSecretProvenance ): Promise { - const entries = provenance.status === 'exact' ? normalizeExactEntries(provenance.entries) : [] + const entries = + provenance.status === 'exact' ? serializeExactEntriesForStorage(provenance.entries) : [] await tx .insert(workspaceFileSecretProvenance) .values({ @@ -365,7 +549,7 @@ export async function copyWorkspaceFileSecretProvenanceInTx( } await replaceWorkspaceFileSecretProvenanceInTx(tx, targetFileId, target.contentUpdatedAt, { status: 'exact', - entries: source.entries, + entries: deserializeExactEntriesFromStorage(source.entries), }) } @@ -433,6 +617,12 @@ export async function getBoundWorkspaceFileSecretProvenance( .limit(1) if (!row) return { status: 'unknown' } + if ( + identity.contentUpdatedAt && + identity.contentUpdatedAt.getTime() !== row.fileContentUpdatedAt.getTime() + ) { + return { status: 'unknown' } + } if (row.secretProvenanceVersion === null) return EXACT_EMPTY_WORKSPACE_FILE_SECRET_PROVENANCE if (row.secretProvenanceVersion !== 1) return { status: 'unknown' } if ( @@ -442,7 +632,7 @@ export async function getBoundWorkspaceFileSecretProvenance( ) { return { status: 'unknown' } } - return { status: 'exact', entries: row.entries } + return { status: 'exact', entries: deserializeExactEntriesFromStorage(row.entries) } } /** Batch-loads classifications already bound to exact, authorized file metadata snapshots. */ @@ -496,7 +686,10 @@ export async function getBoundWorkspaceFileSecretProvenanceByMetadata( result.set(row.id, { status: 'unknown' }) continue } - result.set(row.id, { status: 'exact', entries: row.entries }) + result.set(row.id, { + status: 'exact', + entries: deserializeExactEntriesFromStorage(row.entries), + }) } } for (const id of ids) { @@ -506,22 +699,39 @@ export async function getBoundWorkspaceFileSecretProvenanceByMetadata( } /** - * Activates only stored secret values that occur in this exact model-visible result. Opaque - * attachments cannot be semantically scanned, so any nonempty or unknown provenance rejects them. + * Authorizes one model-facing view of an exact workspace-file version. Complete text views import + * the entire sidecar so representation-changing consumers retain the original lineage. Derived + * text views import only entries present in the returned value; opaque bytes cannot be inspected + * and therefore require an exact-empty sidecar. */ -export async function importWorkspaceFileSecretProvenanceForValue(args: { +export async function importWorkspaceFileSecretProvenanceForModelView(args: { workspaceId: string identity: WorkspaceFileSecretProvenanceIdentity - value: unknown registry?: ResolvedSecretTraceRegistry - opaqueAttachment?: boolean + view: 'complete' | 'derived' | 'opaque' + value?: unknown }): Promise { const provenance = await getBoundWorkspaceFileSecretProvenance(args.workspaceId, args.identity) if (provenance.status === 'unknown') return false if (provenance.entries.length === 0) return true - if (args.opaqueAttachment || !args.registry) return false + if (args.view === 'opaque' || !args.registry) return false + + if (args.view === 'derived' && args.value === undefined) return false + + return importDurableSecretProvenance( + args.registry, + provenance, + args.view === 'derived' ? args.value : undefined + ) +} - return importDurableSecretProvenance(args.registry, provenance, args.value) +/** Allows opaque bytes to leave private storage only when their exact sidecar is provably empty. */ +export async function isOpaqueWorkspaceFileEgressSafe( + workspaceId: string, + identity: WorkspaceFileSecretProvenanceIdentity +): Promise { + const provenance = await getBoundWorkspaceFileSecretProvenance(workspaceId, identity) + return provenance.status === 'exact' && provenance.entries.length === 0 } /** diff --git a/apps/sim/lib/uploads/server/metadata.test.ts b/apps/sim/lib/uploads/server/metadata.test.ts index 4a9ed144a15..19512aa03f9 100644 --- a/apps/sim/lib/uploads/server/metadata.test.ts +++ b/apps/sim/lib/uploads/server/metadata.test.ts @@ -6,6 +6,7 @@ import { dbChainMockFns, queueTableRows, resetDbChainMock } from '@sim/testing' import type { SQL } from 'drizzle-orm' import { PgDialect } from 'drizzle-orm/pg-core' import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { DbTransaction } from '@/lib/db/types' vi.unmock('@sim/db/schema') vi.unmock('drizzle-orm') @@ -16,8 +17,99 @@ import { insertFileMetadata, insertFileMetadataMany, insertImmutableFileMetadata, + recordKnowledgeBaseFileOwnership, } from '@/lib/uploads/server/metadata' +describe('recordKnowledgeBaseFileOwnership', () => { + beforeEach(() => { + vi.clearAllMocks() + resetDbChainMock() + }) + + it('uses the supplied transaction executor for the immutable ownership binding', async () => { + const ownership = { + key: 'kb/fork-document-1', + userId: 'user-1', + workspaceId: 'workspace-1', + originalName: 'document.pdf', + contentType: 'application/pdf', + size: 321, + } + const returning = vi.fn().mockResolvedValue([{ id: 'file-1', ...ownership }]) + const select = vi.fn() + const onConflictDoNothing = vi.fn(() => ({ returning })) + const insert = vi.fn(() => ({ + values: vi.fn(() => ({ onConflictDoNothing })), + })) + const executor = { select, insert } as unknown as DbTransaction + + await expect(recordKnowledgeBaseFileOwnership(ownership, executor)).resolves.toBeUndefined() + + expect(onConflictDoNothing).toHaveBeenCalledTimes(1) + expect(select).not.toHaveBeenCalled() + expect(insert).toHaveBeenCalledTimes(1) + expect(dbChainMockFns.select).not.toHaveBeenCalled() + expect(dbChainMockFns.insert).not.toHaveBeenCalled() + }) + + it('retains the existing non-transactional retry behavior when no executor is supplied', async () => { + const ownership = { + key: 'kb/manual-document.pdf', + userId: 'user-1', + workspaceId: 'workspace-1', + originalName: 'document.pdf', + contentType: 'application/pdf', + size: 321, + } + dbChainMockFns.limit.mockResolvedValueOnce([ + { + id: 'file-1', + ...ownership, + folderId: null, + context: 'knowledge-base', + deletedAt: null, + }, + ]) + + await expect(recordKnowledgeBaseFileOwnership(ownership)).resolves.toBeUndefined() + + expect(dbChainMockFns.select).toHaveBeenCalledTimes(1) + expect(dbChainMockFns.insert).not.toHaveBeenCalled() + }) + + it('validates an exact active binding after a conflict without aborting the executor', async () => { + const ownership = { + key: 'kb/fork-document-1', + userId: 'user-1', + workspaceId: 'workspace-1', + originalName: 'document.pdf', + contentType: 'application/pdf', + size: 321, + } + const active = { + id: 'file-1', + ...ownership, + folderId: null, + context: 'knowledge-base', + deletedAt: null, + } + const limit = vi.fn().mockResolvedValue([active]) + const select = vi.fn(() => ({ + from: vi.fn(() => ({ where: vi.fn(() => ({ limit })) })), + })) + const returning = vi.fn().mockResolvedValue([]) + const insert = vi.fn(() => ({ + values: vi.fn(() => ({ onConflictDoNothing: vi.fn(() => ({ returning })) })), + })) + const executor = { select, insert } as unknown as DbTransaction + + await expect(recordKnowledgeBaseFileOwnership(ownership, executor)).resolves.toBeUndefined() + + expect(insert).toHaveBeenCalledTimes(1) + expect(select).toHaveBeenCalledTimes(1) + }) +}) + describe('deleteFileMetadataByIdentity', () => { beforeEach(() => { vi.clearAllMocks() diff --git a/apps/sim/lib/uploads/server/metadata.ts b/apps/sim/lib/uploads/server/metadata.ts index a14914805fc..9d0ef600213 100644 --- a/apps/sim/lib/uploads/server/metadata.ts +++ b/apps/sim/lib/uploads/server/metadata.ts @@ -3,7 +3,7 @@ import { workspaceFiles } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { generateId } from '@sim/utils/id' import { and, eq, inArray, isNotNull, isNull, sql } from 'drizzle-orm' -import type { DbOrTx } from '@/lib/db/types' +import type { DbOrTx, DbTransaction } from '@/lib/db/types' import type { StorageContext } from '../shared/types' const logger = createLogger('FileMetadata') @@ -172,6 +172,38 @@ async function insertFileMetadataWithExecutor( } } +async function insertImmutableFileMetadataWithExecutor( + executor: DbOrTx, + options: FileMetadataInsertOptions +): Promise { + const { key, userId, workspaceId, context, originalName, contentType, size, folderId, id } = + options + const [inserted] = await executor + .insert(workspaceFiles) + .values({ + id: id || generateId(), + key, + userId, + workspaceId: workspaceId || null, + folderId: folderId ?? null, + context, + originalName, + displayName: originalName, + contentType, + size, + deletedAt: null, + uploadedAt: new Date(), + }) + .onConflictDoNothing() + .returning() + + if (inserted) return inserted + + const active = await findActiveFileMetadataByKey(executor, key) + if (!active) throw new ActiveFileMetadataKeyConflictError(key) + return resolveExistingFileMetadata(active, options) +} + /** * Inserts file metadata while retaining the legacy active-key reuse behavior. * Internal replacement flows use deterministic storage keys and may write new @@ -405,9 +437,17 @@ export interface KnowledgeBaseFileOwnership { * paths — keep all callers routed through here so they cannot drift. */ export async function recordKnowledgeBaseFileOwnership( - ownership: KnowledgeBaseFileOwnership + ownership: KnowledgeBaseFileOwnership, + executor?: DbTransaction ): Promise { - await insertImmutableFileMetadata({ ...ownership, context: 'knowledge-base' }) + if (!executor) { + await insertImmutableFileMetadata({ ...ownership, context: 'knowledge-base' }) + return + } + await insertImmutableFileMetadataWithExecutor(executor, { + ...ownership, + context: 'knowledge-base', + }) } /** diff --git a/apps/sim/lib/uploads/utils/file-utils.server.ts b/apps/sim/lib/uploads/utils/file-utils.server.ts index bae6d27c4a5..ca8fa11bcb1 100644 --- a/apps/sim/lib/uploads/utils/file-utils.server.ts +++ b/apps/sim/lib/uploads/utils/file-utils.server.ts @@ -15,6 +15,7 @@ import { isExecutionFile } from '@/lib/uploads/contexts/execution/utils' import { isModelSafeWorkspaceFileKey, MODEL_UNSAFE_WORKSPACE_FILE_ERROR_MESSAGE, + type WorkspaceFileSecretProvenanceIdentity, } from '@/lib/uploads/contexts/workspace/workspace-file-secret-provenance' import { extractStorageKey, @@ -379,6 +380,7 @@ export async function downloadFileFromStorage( export interface ServableFile { buffer: Buffer contentType: string + contributingFiles?: readonly WorkspaceFileSecretProvenanceIdentity[] } /** diff --git a/apps/sim/lib/uploads/utils/model-input.test.ts b/apps/sim/lib/uploads/utils/model-input.test.ts index 46c2f3f9ccc..716a0d8ccd7 100644 --- a/apps/sim/lib/uploads/utils/model-input.test.ts +++ b/apps/sim/lib/uploads/utils/model-input.test.ts @@ -3,26 +3,29 @@ */ import { describe, expect, it } from 'vitest' import { - selectModelBoundFileInput, - selectPreferredModelBoundFileInput, + selectModelBoundFileInputPaths, + selectPreferredModelBoundFileInputPaths, } from '@/lib/uploads/utils/model-input' describe('model-bound file input selection', () => { it('omits internal storage keys and unrelated file metadata', () => { expect( - selectModelBoundFileInput({ - key: 'effective-key', - path: 'unused-path', - url: 'unused-url', - name: 'unused-name', - metadata: { secret: 'unused-secret' }, - }) - ).toBeUndefined() + selectModelBoundFileInputPaths( + { + key: 'effective-key', + path: 'unused-path', + url: 'unused-url', + name: 'unused-name', + metadata: { secret: 'unused-secret' }, + }, + ['file'] + ) + ).toEqual([]) }) it('selects an inline payload instead of its unused locator when the route uses base64', () => { expect( - selectModelBoundFileInput( + selectModelBoundFileInputPaths( { base64: 'effective-bytes', key: 'unused-key', @@ -30,34 +33,39 @@ describe('model-bound file input selection', () => { type: 'image/png', metadata: 'unused-secret', }, + ['file'], { includeInlineBase64: true } ) - ).toEqual({ base64: 'effective-bytes' }) + ).toEqual([['file', 'base64']]) }) it('mirrors path-first request precedence without selecting the unused upload', () => { expect( - selectPreferredModelBoundFileInput({ + selectPreferredModelBoundFileInputPaths({ file: { key: 'unused-key', metadata: 'unused-secret' }, filePath: ' https://example.com/effective.pdf ', + fileInputPath: ['file'], + filePathInputPath: ['filePath'], prefer: 'path', }) - ).toBe('https://example.com/effective.pdf') + ).toEqual([['filePath']]) }) it('mirrors file-first request precedence without selecting the unused path', () => { expect( - selectPreferredModelBoundFileInput({ + selectPreferredModelBoundFileInputPaths({ file: { key: 'effective-key', metadata: 'unused-secret' }, filePath: 'https://example.com/unused.pdf', + fileInputPath: ['file'], + filePathInputPath: ['filePath'], prefer: 'file', }) - ).toBeUndefined() + ).toEqual([]) }) it('keeps only explicitly model-visible attachment metadata', () => { expect( - selectModelBoundFileInput( + selectModelBoundFileInputPaths( [ { key: 'file-key', @@ -66,25 +74,29 @@ describe('model-bound file input selection', () => { metadata: 'unused-secret', }, ], + ['files'], { includeName: true } ) - ).toEqual([{ name: 'report.pdf' }]) + ).toEqual([['files', '0', 'name']]) }) it('normalizes legacy serialized file objects without selecting unrelated metadata', () => { expect( - selectModelBoundFileInput( + selectModelBoundFileInputPaths( JSON.stringify({ key: 'effective-key', path: 'unused-path', metadata: 'unused-secret', }), + ['file'], { parseSerializedFile: true } ) - ).toBeUndefined() + ).toEqual([]) expect( - selectModelBoundFileInput('https://example.com/image.png', { parseSerializedFile: true }) - ).toBe('https://example.com/image.png') + selectModelBoundFileInputPaths('https://example.com/image.png', ['file'], { + parseSerializedFile: true, + }) + ).toEqual([['file']]) }) }) diff --git a/apps/sim/lib/uploads/utils/model-input.ts b/apps/sim/lib/uploads/utils/model-input.ts index 56e4d2e1802..b75116ac431 100644 --- a/apps/sim/lib/uploads/utils/model-input.ts +++ b/apps/sim/lib/uploads/utils/model-input.ts @@ -1,4 +1,5 @@ import { isPlainRecord } from '@sim/utils/object' +import type { ResolvedSecretInputPath } from '@/executor/utils/resolved-secret-trace-registry' interface ModelBoundFileInputOptions { includeInlineBase64?: boolean @@ -6,60 +7,67 @@ interface ModelBoundFileInputOptions { parseSerializedFile?: boolean } -interface PreferredModelBoundFileInputOptions extends ModelBoundFileInputOptions { +interface PreferredModelBoundFileInputPathOptions extends ModelBoundFileInputOptions { file: unknown filePath: unknown + fileInputPath: ResolvedSecretInputPath + filePathInputPath: ResolvedSecretInputPath prefer: 'file' | 'path' } -function selectFileRecord( +function selectFileRecordInputPaths( input: Record, + rootPath: ResolvedSecretInputPath, options: ModelBoundFileInputOptions -): Record | undefined { - const selected: Record = {} +): ResolvedSecretInputPath[] { + const paths: ResolvedSecretInputPath[] = [] let hasSource = false if (options.includeInlineBase64 && input.base64) { hasSource = true - selected.base64 = input.base64 + paths.push([...rootPath, 'base64']) } else if (input.key) { hasSource = true } else if (input.path) { hasSource = true - selected.path = input.path + paths.push([...rootPath, 'path']) } else if (input.url) { hasSource = true - selected.url = input.url + paths.push([...rootPath, 'url']) } - if (!hasSource) return undefined - if (options.includeName && input.name !== undefined) selected.name = input.name - return Object.keys(selected).length > 0 ? selected : undefined + if (hasSource && options.includeName && input.name !== undefined) { + paths.push([...rootPath, 'name']) + } + return paths } -/** Selects only the source-bearing fields that can contribute to one opaque model input. */ -export function selectModelBoundFileInput( +/** Selects resolver input paths for only the source-bearing fields consumed by a model. */ +export function selectModelBoundFileInputPaths( input: unknown, + rootPath: ResolvedSecretInputPath, options: ModelBoundFileInputOptions = {} -): unknown { +): ResolvedSecretInputPath[] { if (Array.isArray(input)) { - return input - .map((entry) => selectModelBoundFileInput(entry, options)) - .filter((entry) => entry !== undefined) + return input.flatMap((entry, index) => + selectModelBoundFileInputPaths(entry, [...rootPath, String(index)], options) + ) } if (typeof input === 'string') { if (options.parseSerializedFile) { try { const parsed = JSON.parse(input) - if (isPlainRecord(parsed)) return selectFileRecord(parsed, options) + if (isPlainRecord(parsed)) { + return selectFileRecordInputPaths(parsed, rootPath, options).length > 0 ? [rootPath] : [] + } } catch { - return input + return [rootPath] } } - return input + return [rootPath] } - if (!isPlainRecord(input)) return undefined - return selectFileRecord(input, options) + if (!isPlainRecord(input)) return [] + return selectFileRecordInputPaths(input, rootPath, options) } function selectFilePath(input: unknown): string | undefined { @@ -67,19 +75,19 @@ function selectFilePath(input: unknown): string | undefined { return input.trim() } -/** Mirrors a request body's file-vs-path precedence without selecting the unused alternative. */ -export function selectPreferredModelBoundFileInput( - options: PreferredModelBoundFileInputOptions -): unknown { +/** Mirrors file-vs-path precedence while selecting exact resolver input paths. */ +export function selectPreferredModelBoundFileInputPaths( + options: PreferredModelBoundFileInputPathOptions +): ResolvedSecretInputPath[] { const hasFile = isPlainRecord(options.file) const filePath = selectFilePath(options.filePath) if (options.prefer === 'file' && hasFile) { - return selectModelBoundFileInput(options.file, options) + return selectModelBoundFileInputPaths(options.file, options.fileInputPath, options) } - if (filePath !== undefined) return filePath + if (filePath !== undefined) return [options.filePathInputPath] if (options.prefer === 'path' && hasFile) { - return selectModelBoundFileInput(options.file, options) + return selectModelBoundFileInputPaths(options.file, options.fileInputPath, options) } - return undefined + return [] } diff --git a/apps/sim/lib/uploads/utils/user-file-base64.server.test.ts b/apps/sim/lib/uploads/utils/user-file-base64.server.test.ts index 38338975be0..df053676271 100644 --- a/apps/sim/lib/uploads/utils/user-file-base64.server.test.ts +++ b/apps/sim/lib/uploads/utils/user-file-base64.server.test.ts @@ -168,6 +168,45 @@ describe('hydrateUserFilesWithBase64', () => { expect(mockDownloadServableFileFromStorage).not.toHaveBeenCalled() }) + it('bypasses a byte-only cache when model hydration must verify document contributors', async () => { + mockGetRedisClient.mockReturnValue(mockRedis) + mockRedis.get.mockResolvedValue(Buffer.from('%PDF-cached').toString('base64')) + const contentUpdatedAt = new Date('2026-08-06T00:00:00.000Z') + const contributor = { + fileId: 'image-1', + key: 'workspace/workspace-1/image-1.png', + context: 'workspace' as const, + contentUpdatedAt, + } + mockDownloadServableFileFromStorage.mockResolvedValueOnce({ + buffer: Buffer.from('%PDF-current'), + contentType: 'application/pdf', + contributingFiles: [contributor], + }) + const onServableFileContributors = vi.fn().mockResolvedValue(undefined) + const file: UserFile = { + id: 'file-1', + name: 'report.pdf', + key: 'workspace/workspace-1/report.pdf', + url: '', + size: 1, + type: 'text/x-python-pdf', + } + + const hydrated = await hydrateUserFilesWithBase64( + { file }, + { + maxBytes: 100, + userId: 'user-1', + onServableFileContributors, + } + ) + + expect(mockRedis.get).not.toHaveBeenCalled() + expect(hydrated.file.base64).toBe(Buffer.from('%PDF-current').toString('base64')) + expect(onServableFileContributors).toHaveBeenCalledWith(file, [contributor]) + }) + it('propagates generated documents that are still compiling', async () => { const notReady = new Error('Document is still being generated') notReady.name = 'DocCompileUserError' diff --git a/apps/sim/lib/uploads/utils/user-file-base64.server.ts b/apps/sim/lib/uploads/utils/user-file-base64.server.ts index 9ef2c2243ba..cf2103bbb0e 100644 --- a/apps/sim/lib/uploads/utils/user-file-base64.server.ts +++ b/apps/sim/lib/uploads/utils/user-file-base64.server.ts @@ -15,7 +15,7 @@ import { } from '@/lib/execution/payloads/large-value-ref' import { assertUserFileContentAccess, - readUserFileContent, + readUserFileContentWithContributors, } from '@/lib/execution/payloads/materialization.server' import { materializeLargeValueRef } from '@/lib/execution/payloads/store' import { @@ -24,6 +24,7 @@ import { getExecutionRedisBudgetLimits, } from '@/lib/execution/redis-budget.server' import { ExecutionResourceLimitError } from '@/lib/execution/resource-errors' +import type { WorkspaceFileSecretProvenanceIdentity } from '@/lib/uploads/contexts/workspace/workspace-file-secret-provenance' import { isGeneratedDocumentSourceType } from '@/lib/uploads/utils/file-utils' import type { UserFile } from '@/executor/types' @@ -168,6 +169,10 @@ export interface Base64HydrationOptions { timeoutMs?: number cacheTtlSeconds?: number preserveLargeValueMetadata?: boolean + onServableFileContributors?: ( + file: UserFile, + contributors: readonly WorkspaceFileSecretProvenanceIdentity[] + ) => Promise } class InMemoryBase64Cache implements Base64Cache { @@ -398,7 +403,10 @@ async function resolveBase64( file: UserFile, options: Base64HydrationOptions, logger: Logger -): Promise { +): Promise<{ + base64: string | null + contributingFiles?: readonly WorkspaceFileSecretProvenanceIdentity[] +}> { const maxBytes = options.maxBytes ?? DEFAULT_MAX_BASE64_BYTES if (file.base64) { @@ -407,9 +415,9 @@ async function resolveBase64( logger.warn( `[${options.requestId}] Skipping existing base64 for ${file.name} (decoded ${base64Bytes} exceeds ${maxBytes})` ) - return null + return { base64: null } } - return file.base64 + return { base64: file.base64 } } const allowUnknownSize = options.allowUnknownSize ?? false @@ -423,7 +431,7 @@ async function resolveBase64( logger.warn( `[${options.requestId}] Skipping base64 for ${file.name} (size ${file.size} exceeds ${maxBytes})` ) - return null + return { base64: null } } if ( @@ -432,12 +440,12 @@ async function resolveBase64( !hasStableStorageKey ) { logger.warn(`[${options.requestId}] Skipping base64 for ${file.name} (unknown file size)`) - return null + return { base64: null } } const requestId = options.requestId ?? 'unknown' try { - return await readUserFileContent(file, { + const result = await readUserFileContentWithContributors(file, { requestId, workspaceId: options.workspaceId, workflowId: options.workflowId, @@ -449,12 +457,16 @@ async function resolveBase64( encoding: 'base64', maxBytes, }) + return { + base64: result.content, + ...(result.contributingFiles ? { contributingFiles: result.contributingFiles } : {}), + } } catch (error) { if (error instanceof Error && error.name === 'DocCompileUserError') { throw error } logger.warn(`[${requestId}] Failed to hydrate base64 for ${file.name}`, error) - return null + return { base64: null } } } @@ -483,7 +495,9 @@ async function hydrateUserFile( } } - const cached = await state.cache.get(file) + const needsContributorVerification = + Boolean(options.onServableFileContributors) && isGeneratedDocumentSourceType(file.type) + const cached = needsContributorVerification ? null : await state.cache.get(file) if (cached) { const maxBytes = options.maxBytes ?? DEFAULT_MAX_BASE64_BYTES const cachedBytes = Buffer.byteLength(cached, 'base64') @@ -496,11 +510,15 @@ async function hydrateUserFile( return { ...file, base64: cached } } - const base64 = await resolveBase64(file, options, logger) + const { base64, contributingFiles } = await resolveBase64(file, options, logger) if (!base64) { return stripBase64(file) } + if (contributingFiles && contributingFiles.length > 0) { + await options.onServableFileContributors?.(file, contributingFiles) + } + await state.cache.set(file, base64, state.cacheTtlSeconds) return { ...file, base64 } } diff --git a/apps/sim/lib/workflows/executor/execution-core.test.ts b/apps/sim/lib/workflows/executor/execution-core.test.ts index 2be8bdd0fd9..753f632109c 100644 --- a/apps/sim/lib/workflows/executor/execution-core.test.ts +++ b/apps/sim/lib/workflows/executor/execution-core.test.ts @@ -632,7 +632,7 @@ describe('executeWorkflowCore terminal finalization sequencing', () => { ]) }) - it('reconstructs current catalog provenance for a trusted legacy resume', async () => { + it('keeps configured secrets inert for a trusted legacy resume without a provenance checkpoint', async () => { getPersonalAndWorkspaceEnvMock.mockResolvedValue({ personalEncrypted: {}, workspaceEncrypted: { LEGACY_SECRET: 'old-secret-ciphertext' }, @@ -672,9 +672,7 @@ describe('executeWorkflowCore terminal finalization sequencing', () => { const registry = setResolvedSecretTraceRegistryMock.mock.calls[0]?.[0] expect(registry.isComplete()).toBe(true) - expect(registry.getActiveMatches()).toEqual([ - { plaintext: 'old-secret-value', replacement: '{{LEGACY_SECRET}}' }, - ]) + expect(registry.getActiveMatches()).toEqual([]) }) it('accepts an empty trusted legacy resume after bounded reconstruction', async () => { @@ -1311,7 +1309,13 @@ describe('executeWorkflowCore terminal finalization sequencing', () => { } Object.assign(error, { executionResult }) - executorExecuteMock.mockRejectedValue(error) + executorExecuteMock.mockImplementation(async () => { + const registry = + executorConstructorMock.mock.calls.at(-1)?.[0]?.contextExtensions + ?.resolvedSecretTraceRegistry + expect(registry.recordResolved('API_KEY', secret)).toBe(true) + throw error + }) await expect( executeWorkflowCore({ diff --git a/apps/sim/lib/workflows/executor/execution-core.ts b/apps/sim/lib/workflows/executor/execution-core.ts index 4af75820f9d..90af6185620 100644 --- a/apps/sim/lib/workflows/executor/execution-core.ts +++ b/apps/sim/lib/workflows/executor/execution-core.ts @@ -540,14 +540,6 @@ async function executeWorkflowCoreImpl( }) if (restoredState && !restoreTrusted) { resolvedSecretTraceRegistry.markIncomplete() - } else if ( - restoredState && - restoredState.resolvedSecretTraceProvenance === undefined && - restoredState.resolvedSecretTraceCheckpointVersion === undefined - ) { - const legacyProvenance = - resolvedSecretTraceRegistry.exportCatalogProvenanceForValue(restoredState) - await resolvedSecretTraceRegistry.importProvenance(legacyProvenance, { trusted: true }) } if (options.trustedInitialResolvedSecretTraceProvenance !== undefined) { await resolvedSecretTraceRegistry.importProvenance( diff --git a/apps/sim/lib/workflows/executor/input-secret-provenance.test.ts b/apps/sim/lib/workflows/executor/input-secret-provenance.test.ts index f517c1cb4e6..679b8c110f5 100644 --- a/apps/sim/lib/workflows/executor/input-secret-provenance.test.ts +++ b/apps/sim/lib/workflows/executor/input-secret-provenance.test.ts @@ -74,6 +74,49 @@ describe('resolveWorkflowInputSecretProvenance', () => { ).resolves.toEqual({ success: true }) }) + it('propagates an authenticated incomplete bundle without rejecting the workflow input', async () => { + await expect( + resolveWorkflowInputSecretProvenance({ + headers: createHeaders(), + payload: { + input: { token: 'secret-value' }, + [PRIVATE_SECRET_PROVENANCE_FIELD]: { + version: 1, + complete: false, + selections: [], + }, + }, + input: { input: { token: 'secret-value' } }, + isInternalJwt: true, + workspaceId: 'workspace-1', + }) + ).resolves.toEqual({ + success: true, + provenance: { version: 1, complete: false, entries: [] }, + }) + }) + + it('propagates an authenticated incomplete input selection without decrypting secrets', async () => { + await expect( + resolveWorkflowInputSecretProvenance({ + headers: createHeaders(), + payload: createPayload({ + version: 1, + complete: false, + entries: [], + scope: { userId: 'parent-owner', workspaceId: 'workspace-1' }, + }), + input: { input: { token: 'secret-value' } }, + isInternalJwt: true, + workspaceId: 'workspace-1', + }) + ).resolves.toEqual({ + success: true, + provenance: { version: 1, complete: false, entries: [] }, + }) + expect(encryptionMockFns.mockDecryptSecret).not.toHaveBeenCalled() + }) + it.each([ { name: 'marker without a sidecar', @@ -123,21 +166,6 @@ describe('resolveWorkflowInputSecretProvenance', () => { isInternalJwt: true, workspaceId: '', }, - { - name: 'incomplete bundle', - headers: createHeaders(), - payload: { - input: { token: 'secret-value' }, - [PRIVATE_SECRET_PROVENANCE_FIELD]: { - version: 1, - complete: false, - selections: [], - }, - }, - input: { input: { token: 'secret-value' } }, - isInternalJwt: true, - workspaceId: 'workspace-1', - }, { name: 'provenance not present in the selected input', headers: createHeaders(), diff --git a/apps/sim/lib/workflows/executor/input-secret-provenance.ts b/apps/sim/lib/workflows/executor/input-secret-provenance.ts index dcb4c80a71d..bc93c6f4ec4 100644 --- a/apps/sim/lib/workflows/executor/input-secret-provenance.ts +++ b/apps/sim/lib/workflows/executor/input-secret-provenance.ts @@ -60,20 +60,27 @@ export async function resolveWorkflowInputSecretProvenance(options: { inspection.status !== 'verified' || !options.isInternalJwt || !isPrivateSecretProvenanceBundleV1(inspection.value) || - !inspection.value.complete || - inspection.value.selections.length !== 1 + !options.workspaceId ) { return { success: false, error: INVALID_WORKFLOW_INPUT_PROVENANCE_ERROR } } + if (!inspection.value.complete) { + return { success: true, provenance: { version: 1, complete: false, entries: [] } } + } + if (inspection.value.selections.length !== 1) { + return { success: false, error: INVALID_WORKFLOW_INPUT_PROVENANCE_ERROR } + } + const selection = inspection.value.selections[0] const provenance = selection?.provenance - if ( - selection?.key !== WORKFLOW_EXECUTOR_INPUT_PROVENANCE_KEY || - !provenance?.complete || - !options.workspaceId || - provenance.scope?.workspaceId !== options.workspaceId - ) { + if (selection?.key !== WORKFLOW_EXECUTOR_INPUT_PROVENANCE_KEY || !provenance) { + return { success: false, error: INVALID_WORKFLOW_INPUT_PROVENANCE_ERROR } + } + if (!provenance.complete) { + return { success: true, provenance: { version: 1, complete: false, entries: [] } } + } + if (provenance.scope?.workspaceId !== options.workspaceId) { return { success: false, error: INVALID_WORKFLOW_INPUT_PROVENANCE_ERROR } } diff --git a/apps/sim/providers/anthropic/core.ts b/apps/sim/providers/anthropic/core.ts index abb9e81ac0b..163b988bb6b 100644 --- a/apps/sim/providers/anthropic/core.ts +++ b/apps/sim/providers/anthropic/core.ts @@ -662,7 +662,6 @@ export async function executeAnthropicProviderRequest( executionParams, { signal: request.abortSignal, - toolInput: toolParams, } ) const toolCallEndTime = Date.now() diff --git a/apps/sim/providers/anthropic/request-history.ts b/apps/sim/providers/anthropic/request-history.ts index 1872378e287..6909bda8747 100644 --- a/apps/sim/providers/anthropic/request-history.ts +++ b/apps/sim/providers/anthropic/request-history.ts @@ -1,6 +1,5 @@ import type Anthropic from '@anthropic-ai/sdk' import { buildAnthropicMessageContent } from '@/providers/attachments' -import { projectProviderAttachmentFilenameForModel } from '@/providers/runtime-context' import { parseToolArguments } from '@/providers/streaming-tool-loop-shared' import type { Message } from '@/providers/types' @@ -111,12 +110,7 @@ export function convertAnthropicRequestHistory({ assertNoPendingToolCalls() - const content = buildAnthropicMessageContent( - message.content, - message.files, - providerId, - projectProviderAttachmentFilenameForModel - ) + const content = buildAnthropicMessageContent(message.content, message.files, providerId) if (message.role === 'assistant' && message.tool_calls?.length) { const toolUseBlocks = message.tool_calls.map((toolCall) => { const block: Anthropic.Messages.ToolUseBlockParam = { diff --git a/apps/sim/providers/anthropic/streaming-tool-loop.test.ts b/apps/sim/providers/anthropic/streaming-tool-loop.test.ts index a6942d81750..fa583999968 100644 --- a/apps/sim/providers/anthropic/streaming-tool-loop.test.ts +++ b/apps/sim/providers/anthropic/streaming-tool-loop.test.ts @@ -14,6 +14,7 @@ import { createAnthropicStreamingToolLoopStream } from '@/providers/anthropic/st import type { AnthropicUsageLike } from '@/providers/anthropic/usage' import { runWithProviderRuntimeContext } from '@/providers/runtime-context' import type { AgentStreamEvent } from '@/providers/stream-events' +import { registerPreparedProviderToolInputProvenance } from '@/providers/tool-input-provenance' import type { TimeSegment } from '@/providers/types' const { mockExecuteTool, mockPrepareToolExecution } = vi.hoisted(() => ({ @@ -260,10 +261,23 @@ describe('createAnthropicStreamingToolLoopStream', () => { const registry = new ResolvedSecretTraceRegistry([ { name: 'TOKEN', plaintext: secret, encryptedValue: 'ciphertext' }, ]) - registry.recordResolved('TOKEN', secret) + const sourcePath = ['tools', '0', 'params', 'token'] as const + registry.recordResolvedAtInputPath('TOKEN', secret, sourcePath) + registry.recordResolvedInputProjection(sourcePath, secret, '{{TOKEN}}') + const executionParams = { token: secret } + const inputRegistry = registry.forkForInputPaths([['tools', '0', 'params']]) + inputRegistry.recordTransformedInputProjection( + { params: executionParams }, + { params: { token: '{{TOKEN}}' } } + ) + registerPreparedProviderToolInputProvenance(executionParams, { + parentRegistry: registry, + registry: inputRegistry, + inputPaths: [['params']], + }) mockPrepareToolExecution.mockReturnValue({ - toolParams: { token: secret }, - executionParams: { token: secret }, + toolParams: executionParams, + executionParams, }) mockExecuteTool.mockResolvedValue({ success: true, diff --git a/apps/sim/providers/anthropic/streaming-tool-loop.ts b/apps/sim/providers/anthropic/streaming-tool-loop.ts index b3942e9c5cf..ff644eb0838 100644 --- a/apps/sim/providers/anthropic/streaming-tool-loop.ts +++ b/apps/sim/providers/anthropic/streaming-tool-loop.ts @@ -377,7 +377,6 @@ export function createAnthropicStreamingToolLoopStream( executionParams, { signal: loopAbortController.signal, - toolInput: toolParams, } ) const toolCallEndTime = Date.now() diff --git a/apps/sim/providers/azure-openai/index.ts b/apps/sim/providers/azure-openai/index.ts index 9162d6a1fe4..a2cfa9a2efd 100644 --- a/apps/sim/providers/azure-openai/index.ts +++ b/apps/sim/providers/azure-openai/index.ts @@ -337,7 +337,6 @@ async function executeChatCompletionsRequest( executionParams, { signal: request.abortSignal, - toolInput: toolParams, } ) const toolCallEndTime = Date.now() diff --git a/apps/sim/providers/baseten/index.ts b/apps/sim/providers/baseten/index.ts index 47a5d65a7d0..3163e9eee4c 100644 --- a/apps/sim/providers/baseten/index.ts +++ b/apps/sim/providers/baseten/index.ts @@ -297,7 +297,6 @@ export const basetenProvider: ProviderConfig = { executionParams, { signal: request.abortSignal, - toolInput: toolParams, } ) const toolCallEndTime = Date.now() diff --git a/apps/sim/providers/bedrock/index.ts b/apps/sim/providers/bedrock/index.ts index 6b1ede39b98..abd36b4c9cd 100644 --- a/apps/sim/providers/bedrock/index.ts +++ b/apps/sim/providers/bedrock/index.ts @@ -35,10 +35,7 @@ import { getProviderModels, supportsNativeStructuredOutputs, } from '@/providers/models' -import { - executeProviderTool, - projectProviderAttachmentFilenameForModel, -} from '@/providers/runtime-context' +import { executeProviderTool } from '@/providers/runtime-context' import { createSettledAgentEventStream } from '@/providers/stream-events' import { createStreamingExecution } from '@/providers/streaming-execution' import { isAbortError, parseToolArguments } from '@/providers/streaming-tool-loop-shared' @@ -213,12 +210,7 @@ export const bedrockProvider: ProviderConfig = { } } else { const role: ConversationRole = msg.role === 'assistant' ? 'assistant' : 'user' - const content = buildBedrockMessageContent( - msg.content, - msg.files, - 'bedrock', - projectProviderAttachmentFilenameForModel - ) + const content = buildBedrockMessageContent(msg.content, msg.files, 'bedrock') messages.push({ role, // double-cast-allowed: shared attachment builder emits Bedrock Converse content blocks while keeping provider-neutral attachment types @@ -677,7 +669,6 @@ export const bedrockProvider: ProviderConfig = { executionParams, { signal: request.abortSignal, - toolInput: toolParams, } ) const toolCallEndTime = Date.now() diff --git a/apps/sim/providers/bedrock/streaming-tool-loop.ts b/apps/sim/providers/bedrock/streaming-tool-loop.ts index c2e61c13544..d79e4277b29 100644 --- a/apps/sim/providers/bedrock/streaming-tool-loop.ts +++ b/apps/sim/providers/bedrock/streaming-tool-loop.ts @@ -422,7 +422,6 @@ export function createBedrockStreamingToolLoopStream( executionParams, { signal: loopAbortController.signal, - toolInput: toolParams, } ) const toolCallEndTime = Date.now() diff --git a/apps/sim/providers/cerebras/index.ts b/apps/sim/providers/cerebras/index.ts index f4e9085f3f7..6505a974759 100644 --- a/apps/sim/providers/cerebras/index.ts +++ b/apps/sim/providers/cerebras/index.ts @@ -264,7 +264,6 @@ export const cerebrasProvider: ProviderConfig = { executionParams, { signal: request.abortSignal, - toolInput: toolParams, } ) const toolCallEndTime = Date.now() diff --git a/apps/sim/providers/deepseek/index.ts b/apps/sim/providers/deepseek/index.ts index 4a348003669..fb46ba9398a 100644 --- a/apps/sim/providers/deepseek/index.ts +++ b/apps/sim/providers/deepseek/index.ts @@ -376,7 +376,6 @@ export const deepseekProvider: ProviderConfig = { executionParams, { signal: request.abortSignal, - toolInput: toolParams, } ) const toolCallEndTime = Date.now() diff --git a/apps/sim/providers/file-attachments.server.test.ts b/apps/sim/providers/file-attachments.server.test.ts index f95c295c649..f88a10bf5f3 100644 --- a/apps/sim/providers/file-attachments.server.test.ts +++ b/apps/sim/providers/file-attachments.server.test.ts @@ -124,12 +124,11 @@ describe('OpenAI large-file attachment lifecycle', () => { ]) }) - it('projects the multipart filename without mutating upload preparation metadata', async () => { + it('preserves a multipart filename that collides with a configured secret', async () => { const request = makeRequest(CSV_BYTES) const registry = new ResolvedSecretTraceRegistry([ { name: 'FILE_NAME', plaintext: 'data_10mb.csv', encryptedValue: 'ciphertext' }, ]) - registry.recordResolved('FILE_NAME', 'data_10mb.csv') await runWithProviderRuntimeContext({ resolvedSecretTraceRegistry: registry }, async () => { await attachLargeFileRemoteUrls(request, 'openai') @@ -138,7 +137,7 @@ describe('OpenAI large-file attachment lifecycle', () => { const [, init] = (fetch as unknown as ReturnType).mock.calls[0] const uploaded = (init.body as FormData).get('file') as File - expect(uploaded.name).toBe('{{FILE_NAME}}.csv') + expect(uploaded.name).toBe('data_10mb.csv') expect(request.messages?.[0].files?.[0].name).toBe('data_10mb.csv') }) diff --git a/apps/sim/providers/file-attachments.server.ts b/apps/sim/providers/file-attachments.server.ts index 4af4a471b2c..39fcbf9267e 100644 --- a/apps/sim/providers/file-attachments.server.ts +++ b/apps/sim/providers/file-attachments.server.ts @@ -3,7 +3,7 @@ import { createLogger } from '@sim/logger' import { getErrorMessage } from '@sim/utils/errors' import { sleep } from '@sim/utils/helpers' import { StorageService } from '@/lib/uploads' -import { getFileExtension, resolveTrustedFileContext } from '@/lib/uploads/utils/file-utils' +import { resolveTrustedFileContext } from '@/lib/uploads/utils/file-utils' import { downloadServableFileFromStorage } from '@/lib/uploads/utils/file-utils.server' import { verifyFileAccess } from '@/app/api/files/authorization' import type { UserFile } from '@/executor/types' @@ -16,7 +16,6 @@ import { LARGE_FILE_PATH_THRESHOLD_BYTES, shouldUseLargeFilePath, } from '@/providers/attachments' -import { projectProviderAttachmentFilenameForModel } from '@/providers/runtime-context' import type { Message, ProviderId, ProviderRequest } from '@/providers/types' const logger = createLogger('ProviderFileAttachments') @@ -231,11 +230,7 @@ async function uploadOpenAIFile( form.append('purpose', mimeType.startsWith('image/') ? 'vision' : 'user_data') form.append('expires_after[anchor]', 'created_at') form.append('expires_after[seconds]', String(OPENAI_FILE_EXPIRY_SECONDS)) - form.append( - 'file', - blob, - projectProviderAttachmentFilenameForModel(file.name, getFileExtension(file.name)) - ) + form.append('file', blob, file.name) const response = await fetch(OPENAI_FILES_ENDPOINT, { method: 'POST', diff --git a/apps/sim/providers/fireworks/index.ts b/apps/sim/providers/fireworks/index.ts index e19be0f2940..057b5b34a72 100644 --- a/apps/sim/providers/fireworks/index.ts +++ b/apps/sim/providers/fireworks/index.ts @@ -303,7 +303,6 @@ export const fireworksProvider: ProviderConfig = { executionParams, { signal: request.abortSignal, - toolInput: toolParams, } ) const toolCallEndTime = Date.now() diff --git a/apps/sim/providers/gemini/core.ts b/apps/sim/providers/gemini/core.ts index 948c33ed1c2..59dd3b6ae6e 100644 --- a/apps/sim/providers/gemini/core.ts +++ b/apps/sim/providers/gemini/core.ts @@ -135,7 +135,6 @@ async function executeToolCallsBatch( const { toolParams, executionParams } = prepareToolExecution(tool, args, request) const { rawResponse, modelResponse } = await executeProviderTool(toolName, executionParams, { signal: request.abortSignal, - toolInput: toolParams, }) const toolCallEndTime = Date.now() const duration = toolCallEndTime - toolCallStartTime diff --git a/apps/sim/providers/gemini/streaming-tool-loop.ts b/apps/sim/providers/gemini/streaming-tool-loop.ts index 62b5179c591..cead2dae0f3 100644 --- a/apps/sim/providers/gemini/streaming-tool-loop.ts +++ b/apps/sim/providers/gemini/streaming-tool-loop.ts @@ -470,7 +470,6 @@ export function createGeminiStreamingToolLoopStream( executionParams, { signal: loopAbortController.signal, - toolInput: toolParams, } ) const toolCallEndTime = Date.now() diff --git a/apps/sim/providers/groq/index.ts b/apps/sim/providers/groq/index.ts index e80e1f0a1a6..31bcd181b14 100644 --- a/apps/sim/providers/groq/index.ts +++ b/apps/sim/providers/groq/index.ts @@ -351,7 +351,6 @@ export const groqProvider: ProviderConfig = { executionParams, { signal: request.abortSignal, - toolInput: toolParams, } ) const toolCallEndTime = Date.now() diff --git a/apps/sim/providers/index.test.ts b/apps/sim/providers/index.test.ts index a5a72e386d5..73944552d9d 100644 --- a/apps/sim/providers/index.test.ts +++ b/apps/sim/providers/index.test.ts @@ -441,7 +441,7 @@ describe('executeProviderRequest — streaming cost policy', () => { }) }) -describe('executeProviderRequest — model secret projection', () => { +describe('executeProviderRequest — caller-prepared model input', () => { beforeEach(() => { vi.clearAllMocks() mockExecuteRequest.mockResolvedValue({ @@ -451,7 +451,7 @@ describe('executeProviderRequest — model secret projection', () => { } as ProviderResponse) }) - it('projects only model-visible request content before provider execution', async () => { + it('does not rescan or rewrite a caller-prepared provider request', async () => { const secret = 'quoted"secret\\with\nnewline' const registry = new ResolvedSecretTraceRegistry([ { name: 'TOKEN', plaintext: secret, encryptedValue: 'ciphertext' }, @@ -529,9 +529,9 @@ describe('executeProviderRequest — model secret projection', () => { ) const sent = mockExecuteRequest.mock.calls[0][0] - expect(sent.systemPrompt).not.toContain(secret) - expect(sent.context).toBe('context {{TOKEN}}') - expect(sent.messages[0].content).toBe('message {{TOKEN}} {{TOKEN}}') + expect(sent.systemPrompt).toBe(`system ${secret}`) + expect(sent.context).toBe(`context ${secret}`) + expect(sent.messages[0].content).toBe(`message ${secret} __var_TOKEN`) expect(sent.messages[0].files[0]).toMatchObject({ name: `${secret}.txt`, base64: 'c2FmZQ==', @@ -540,14 +540,14 @@ describe('executeProviderRequest — model secret projection', () => { name: 'assistant-safe', function_call: { name: 'legacy-safe', - arguments: JSON.stringify({ value: '{{TOKEN}}' }), + arguments: JSON.stringify({ value: secret }), }, tool_calls: [ { id: `call-${secret}`, function: { name: 'tool-safe', - arguments: JSON.stringify({ value: '{{TOKEN}}' }), + arguments: JSON.stringify({ value: secret }), }, }, ], @@ -555,22 +555,22 @@ describe('executeProviderRequest — model secret projection', () => { }) expect(sent.tools[0]).toMatchObject({ name: 'Safe Tool', - description: 'Description {{TOKEN}}', + description: `Description ${secret}`, params: { runtimeSecret: secret }, parameters: { - properties: { value: { description: '{{TOKEN}}' } }, + properties: { value: { description: secret } }, }, }) expect(sent.responseFormat).toMatchObject({ name: 'safe_result', schema: { - properties: { value: { description: '{{TOKEN}}' } }, + properties: { value: { description: secret } }, }, }) expect(sent.apiKey).toBe(secret) expect(sent.environmentVariables).toEqual({ TOKEN: secret }) expect(sent.workflowVariables).toEqual({ raw: secret }) - expect(JSON.stringify(sent)).not.toContain('__var_') + expect(JSON.stringify(sent)).toContain('__var_TOKEN') }) it('does not infer provenance from a dormant request environment map', async () => { @@ -666,7 +666,7 @@ describe('executeProviderRequest — model secret projection', () => { }) }) - it('omits only the response format when an active secret collides with its semantic keys', async () => { + it('preserves public prompt and schema text that equals an active secret', async () => { const registry = new ResolvedSecretTraceRegistry([ { name: 'SCHEMA_KEY', plaintext: 'messages', encryptedValue: 'encrypted-schema-key' }, ]) @@ -693,13 +693,18 @@ describe('executeProviderRequest — model secret projection', () => { const sent = mockExecuteRequest.mock.calls.at(-1)?.[0] expect(sent).toMatchObject({ - systemPrompt: 'Choose loading {{SCHEMA_KEY}}', - messages: [{ role: 'user', content: 'Select {{SCHEMA_KEY}} for this request' }], - responseFormat: undefined, + systemPrompt: 'Choose loading messages', + messages: [{ role: 'user', content: 'Select messages for this request' }], + responseFormat: { + name: 'loading_messages', + schema: { + properties: { messages: { type: 'array', items: { type: 'string' } } }, + }, + }, }) }) - it('omits a safe schema when no deterministic response-format name avoids an active secret', async () => { + it('preserves response-format control text without inventing replacement names', async () => { const registry = new ResolvedSecretTraceRegistry([ { name: 'UNDERSCORE', plaintext: '_', encryptedValue: 'encrypted-underscore' }, ]) @@ -720,11 +725,14 @@ describe('executeProviderRequest — model secret projection', () => { expect(mockExecuteRequest.mock.calls.at(-1)?.[0]).toMatchObject({ messages: [{ role: 'user', content: 'Continue safely' }], - responseFormat: undefined, + responseFormat: { + name: 'unsafe_name', + schema: { type: 'object', properties: {} }, + }, }) }) - it('omits malformed and oversized optional schemas without failing the model call', async () => { + it('leaves provider schema validation to the provider adapter', async () => { const registry = new ResolvedSecretTraceRegistry() const oversizedSchema = { allOf: new Array(100_001) } @@ -751,8 +759,8 @@ describe('executeProviderRequest — model secret projection', () => { expect(mockExecuteRequest.mock.calls.at(-1)?.[0]).toMatchObject({ messages: [{ role: 'user', content: 'Continue safely' }], - tools: [], - responseFormat: undefined, + tools: [expect.objectContaining({ id: 'unsafe_tool', parameters: schema })], + responseFormat: { name: 'unsafe_response', schema }, }) } }) @@ -863,7 +871,7 @@ describe('executeProviderRequest — model secret projection', () => { expect(mockExecuteRequest).not.toHaveBeenCalled() }) - it('projects JSON arguments without mutating attachment metadata before serialization', async () => { + it('preserves provider-generated JSON arguments and attachment metadata byte-for-byte', async () => { const registry = new ResolvedSecretTraceRegistry([ { name: 'TOKEN', plaintext: 'TOKEN', encryptedValue: 'ciphertext' }, ]) @@ -910,11 +918,11 @@ describe('executeProviderRequest — model secret projection', () => { const sent = mockExecuteRequest.mock.calls.at(-1)?.[0] expect(sent.messages[0]).toMatchObject({ - content: '{{TOKEN}}', - function_call: { arguments: JSON.stringify({ value: '{{TOKEN}}' }) }, + content: 'TOKEN', + function_call: { arguments: JSON.stringify({ value: 'TOKEN' }) }, tool_calls: [ { - function: { arguments: JSON.stringify({ value: '{{TOKEN}}' }) }, + function: { arguments: JSON.stringify({ value: 'TOKEN' }) }, }, ], files: [ @@ -924,17 +932,14 @@ describe('executeProviderRequest — model secret projection', () => { }, ], }) - const modelVisible = JSON.stringify({ - content: sent.messages[0].content, - function_call: sent.messages[0].function_call, - tool_calls: sent.messages[0].tool_calls, - }) - expect(modelVisible.replaceAll('{{TOKEN}}', '')).not.toContain('TOKEN') - expect(modelVisible).not.toContain('{{{{TOKEN}}}}') + expect(sent.messages[0].function_call.arguments).toBe(JSON.stringify({ value: 'TOKEN' })) + expect(sent.messages[0].tool_calls[0].function.arguments).toBe( + JSON.stringify({ value: 'TOKEN' }) + ) }) it.each(['123', 'true'])( - 'keeps low-entropy JSON valid, projects typed conversions, and preserves transport IDs (%s)', + 'never infers provenance from low-entropy values in provider protocol fields (%s)', async (secret) => { const registry = new ResolvedSecretTraceRegistry([ { name: 'TOKEN', plaintext: secret, encryptedValue: 'ciphertext' }, @@ -1042,7 +1047,7 @@ describe('executeProviderRequest — model secret projection', () => { expect(sent.messages[0]).toMatchObject({ role: 'assistant', name: 'assistant-safe', - content: '{{TOKEN}}', + content: secret, function_call: { name: 'legacy-safe', }, @@ -1057,12 +1062,12 @@ describe('executeProviderRequest — model secret projection', () => { tool_call_id: secret, }) expect(JSON.parse(sent.messages[0].function_call.arguments)).toEqual({ - value: '{{TOKEN}}', - converted: '{{TOKEN}}', + value: secret, + converted, }) expect(JSON.parse(sent.messages[0].tool_calls[0].function.arguments)).toEqual({ - value: '{{TOKEN}}', - converted: '{{TOKEN}}', + value: secret, + converted, }) expect(sent.messages[0].files[0]).toEqual({ id: secret, @@ -1076,29 +1081,29 @@ describe('executeProviderRequest — model secret projection', () => { providerFileUri: `provider://${secret}`, remoteUrl: `https://remote.example/${secret}`, }) - expect(sent.tools).toHaveLength(1) + expect(sent.tools).toHaveLength(3) expect(sent.tools[0]).toMatchObject({ id: 'safe_tool', name: 'Safe Tool', - description: 'Description {{TOKEN}}', + description: `Description ${secret}`, params: { runtimeControl: secret }, parameters: { properties: { value: { - title: 'Title {{TOKEN}}', - description: 'Field {{TOKEN}}', + title: `Title ${secret}`, + description: `Field ${secret}`, enum: ['public'], }, }, required: ['value'], }, }) - expect(sent.responseFormat.name).not.toBe(secret) + expect(sent.responseFormat.name).toBe(secret) expect(sent.responseFormat).toMatchObject({ schema: { properties: { value: { - description: 'Result {{TOKEN}}', + description: `Result ${secret}`, enum: ['public'], }, }, @@ -1109,7 +1114,7 @@ describe('executeProviderRequest — model secret projection', () => { ) it.each(ARBITRARY_SCHEMA_CONTROL_KEYS)( - 'guards arbitrary %s schema controls while omitting only the unsafe model capability', + 'preserves caller-prepared %s schema controls without plaintext inference', async (controlKey) => { const secret = `schema-control-secret-${controlKey}` const registry = new ResolvedSecretTraceRegistry([ @@ -1147,6 +1152,7 @@ describe('executeProviderRequest — model secret projection', () => { ) expect(mockExecuteRequest.mock.calls.at(-1)?.[0].tools).toEqual([ + expect.objectContaining({ id: 'unsafe_tool', parameters: unsafeSchema }), expect.objectContaining({ id: 'safe_tool' }), ]) @@ -1163,10 +1169,10 @@ describe('executeProviderRequest — model secret projection', () => { expect(mockExecuteRequest).toHaveBeenCalledWith( expect.objectContaining({ messages: [{ role: 'user', content: 'Continue safely' }], - responseFormat: undefined, + responseFormat: { name: 'unsafe_response', schema: unsafeSchema }, }) ) - expect(JSON.stringify(mockExecuteRequest.mock.calls.at(-1)?.[0])).not.toContain(secret) + expect(JSON.stringify(mockExecuteRequest.mock.calls.at(-1)?.[0])).toContain(secret) } ) @@ -1247,7 +1253,7 @@ describe('executeProviderRequest — model secret projection', () => { }) it.each(['123', 'true'])( - 'omits a response schema whose semantic value equals a secret (%s)', + 'preserves a response schema whose semantic value equals an active secret (%s)', async (secret) => { const registry = new ResolvedSecretTraceRegistry([ { name: 'TOKEN', plaintext: secret, encryptedValue: 'ciphertext' }, @@ -1270,34 +1276,31 @@ describe('executeProviderRequest — model secret projection', () => { expect(mockExecuteRequest).toHaveBeenCalledWith( expect.objectContaining({ messages: [{ role: 'user', content: 'Continue safely' }], - responseFormat: undefined, + responseFormat: { + name: 'safe_response', + schema: { type: 'object', properties: {}, enum: [semanticValue] }, + }, }) ) expect(mockExecuteRequest.mock.calls.at(-1)?.[0].systemPrompt).toBeUndefined() } ) - it('fails before invoking a provider when an expected registry is incomplete or missing', async () => { + it('does not make provider execution depend on registry completeness', async () => { const incomplete = new ResolvedSecretTraceRegistry() incomplete.markIncomplete() - await expect( - executeProviderRequest( - 'anthropic', - { model: 'test-model', messages: [{ role: 'user', content: 'possibly secret' }] }, - { resolvedSecretTraceRegistry: incomplete } - ) - ).rejects.toThrow('Model input could not be safely projected') - await expect( - executeProviderRequest( - 'anthropic', - { model: 'test-model', messages: [{ role: 'user', content: 'possibly secret' }] }, - {} - ) - ).rejects.toThrow('Model input could not be safely projected') - expect(mockAttachLargeFileRemoteUrls).not.toHaveBeenCalled() - expect(mockUploadLargeFilesToProvider).not.toHaveBeenCalled() - expect(mockExecuteRequest).not.toHaveBeenCalled() + await executeProviderRequest( + 'anthropic', + { model: 'test-model', messages: [{ role: 'user', content: 'possibly secret' }] }, + { resolvedSecretTraceRegistry: incomplete } + ) + await executeProviderRequest( + 'anthropic', + { model: 'test-model', messages: [{ role: 'user', content: 'possibly secret' }] }, + {} + ) + expect(mockExecuteRequest).toHaveBeenCalledTimes(2) }) it('leaves non-workflow provider callers unchanged when no runtime context is supplied', async () => { diff --git a/apps/sim/providers/index.ts b/apps/sim/providers/index.ts index c36438be7ef..4d20d166c6a 100644 --- a/apps/sim/providers/index.ts +++ b/apps/sim/providers/index.ts @@ -1,18 +1,8 @@ import { createLogger } from '@sim/logger' import { toError } from '@sim/utils/errors' import { getApiKeyWithBYOK } from '@/lib/api-key/byok' -import { - collectModelVisibleSchemaContent, - restoreModelVisibleSchemaValues as restoreSchemaDisplayValues, -} from '@/lib/copilot/model-visible-schema' import { filterModelSafeWorkspaceFileAttachments } from '@/lib/uploads/contexts/workspace/workspace-file-secret-provenance' import type { StreamingExecution } from '@/executor/types' -import { - isResolvedSecretModelContentUnchanged, - projectResolvedSecretModelContent, - projectResolvedSecretModelJsonStrings, -} from '@/executor/utils/resolved-secret-content-projection' -import type { ResolvedSecretTraceRegistry } from '@/executor/utils/resolved-secret-trace-registry' import { applyModelCostPolicy, applySegmentCostPolicy, @@ -26,20 +16,13 @@ import { attachLargeFileRemoteUrls, uploadLargeFilesToProvider, } from '@/providers/file-attachments.server' -import { collectProviderModelInputProvenanceValues } from '@/providers/model-input-provenance' import { isKnownModelId } from '@/providers/models' import { getProviderExecutor } from '@/providers/registry' import { type ProviderRuntimeContext, runWithProviderRuntimeContext, } from '@/providers/runtime-context' -import type { - Message, - ProviderId, - ProviderRequest, - ProviderResponse, - ProviderToolConfig, -} from '@/providers/types' +import type { ProviderId, ProviderRequest, ProviderResponse } from '@/providers/types' import { generateStructuredOutputInstructions, sumToolCosts, @@ -52,240 +35,6 @@ import { const logger = createLogger('Providers') -class ModelContentProjectionError extends Error { - constructor() { - super('Model input could not be safely projected') - this.name = 'ModelContentProjectionError' - } -} - -function restoreProjectedOptionalString( - original: string | undefined, - candidate: unknown -): string | undefined { - if (original === undefined && candidate === undefined) return undefined - if (original === undefined || typeof candidate !== 'string') { - throw new ModelContentProjectionError() - } - return candidate -} - -function modelVisibleMessageText(message: Message): unknown { - return message.content -} - -function projectMessageJsonArguments( - messages: Message[] | undefined, - registry: ResolvedSecretTraceRegistry | undefined -): Message[] | undefined { - if (!messages) return undefined - - const argumentsToProject = messages.flatMap((message) => [ - message.function_call?.arguments, - ...(message.tool_calls?.map((toolCall) => toolCall.function.arguments) ?? []), - ]) - const projection = projectResolvedSecretModelJsonStrings(argumentsToProject, registry) - if (!projection.safe || !Array.isArray(projection.value)) { - throw new ModelContentProjectionError() - } - const projectedArguments = projection.value - - let cursor = 0 - return messages.map((message) => { - const functionArguments = projectedArguments[cursor] - cursor += 1 - const toolCalls = message.tool_calls?.map((toolCall) => { - const toolArguments = projectedArguments[cursor] - cursor += 1 - if (typeof toolArguments !== 'string') throw new ModelContentProjectionError() - return { - ...toolCall, - function: { ...toolCall.function, arguments: toolArguments }, - } - }) - if (message.function_call && typeof functionArguments !== 'string') { - throw new ModelContentProjectionError() - } - return { - ...message, - ...(message.function_call - ? { - function_call: { - ...message.function_call, - arguments: functionArguments as string, - }, - } - : {}), - ...(toolCalls ? { tool_calls: toolCalls } : {}), - } - }) -} - -function modelMessageProtocolHandles(messages: Message[] | undefined): unknown[] { - return (messages ?? []).flatMap((message) => [ - message.name, - message.function_call?.name, - ...(message.tool_calls?.map((toolCall) => toolCall.function.name) ?? []), - ]) -} - -function hasModelSafeSchema( - schema: unknown, - registry: ResolvedSecretTraceRegistry | undefined -): boolean { - try { - return isResolvedSecretModelContentUnchanged( - collectModelVisibleSchemaContent(schema).guardedValues, - registry - ) - } catch { - return false - } -} - -function modelSafeResponseFormatName( - name: string, - registry: ResolvedSecretTraceRegistry | undefined -): string | undefined { - if (isResolvedSecretModelContentUnchanged(name, registry)) return name - - const prefixes = ['response', 'structured_output', 'model_output'] as const - for (const prefix of prefixes) { - for (let suffix = 0; suffix < 100; suffix += 1) { - const candidate = `${prefix}_${suffix}` - if (isResolvedSecretModelContentUnchanged(candidate, registry)) return candidate - } - } - return undefined -} - -function restoreProjectedMessages( - original: Message[] | undefined, - projected: unknown -): Message[] | undefined { - if (original === undefined && projected === undefined) return undefined - if (!original || !Array.isArray(projected) || original.length !== projected.length) { - throw new ModelContentProjectionError() - } - - return projected.map((candidate, index) => { - const originalMessage = original[index] - if ( - (originalMessage.content === null && candidate !== null) || - (typeof originalMessage.content === 'string' && typeof candidate !== 'string') - ) { - throw new ModelContentProjectionError() - } - - return { - ...originalMessage, - content: candidate as Message['content'], - } - }) -} - -function modelVisibleToolContent(tool: ProviderToolConfig): unknown[] { - return [tool.description, collectModelVisibleSchemaContent(tool.parameters).projectedValues] -} - -function restoreProjectedTools( - original: ProviderToolConfig[] | undefined, - projected: unknown -): ProviderToolConfig[] | undefined { - if (original === undefined && projected === undefined) return undefined - if (!original || !Array.isArray(projected) || original.length !== projected.length) { - throw new ModelContentProjectionError() - } - - return projected.map((candidate, index) => { - if (!Array.isArray(candidate) || candidate.length !== 2 || typeof candidate[0] !== 'string') { - throw new ModelContentProjectionError() - } - return { - ...original[index], - description: candidate[0], - parameters: restoreSchemaDisplayValues( - original[index].parameters, - candidate[1] - ) as ProviderToolConfig['parameters'], - } - }) -} - -function projectProviderModelContent( - request: ProviderRequest, - runtimeContext: ProviderRuntimeContext -): ProviderRequest { - const registry = runtimeContext.resolvedSecretTraceRegistry - if ( - !isResolvedSecretModelContentUnchanged(modelMessageProtocolHandles(request.messages), registry) - ) { - throw new ModelContentProjectionError() - } - - const sourceMessages = projectMessageJsonArguments(request.messages, registry) - const sourceTools = request.tools?.filter( - (tool) => - isResolvedSecretModelContentUnchanged(tool.id, registry) && - isResolvedSecretModelContentUnchanged(tool.name, registry) && - hasModelSafeSchema(tool.parameters, registry) - ) - let sourceResponseFormat = request.responseFormat - if (request.responseFormat) { - if (!hasModelSafeSchema(request.responseFormat.schema, registry)) { - logger.warn('Omitting a response format with unsafe model-input provenance') - sourceResponseFormat = undefined - } else { - const name = modelSafeResponseFormatName(request.responseFormat.name, registry) - if (!name) { - logger.warn('Omitting a response format whose name could not be safely projected') - sourceResponseFormat = undefined - } else { - sourceResponseFormat = { ...request.responseFormat, name } - } - } - } - - const projection = projectResolvedSecretModelContent( - [ - request.systemPrompt, - request.context, - sourceMessages?.map(modelVisibleMessageText), - sourceTools?.map(modelVisibleToolContent), - sourceResponseFormat - ? collectModelVisibleSchemaContent(sourceResponseFormat.schema).projectedValues - : undefined, - ], - registry - ) - if (!projection.safe || !Array.isArray(projection.value) || projection.value.length !== 5) { - throw new ModelContentProjectionError() - } - - const [systemPrompt, context, projectedMessages, projectedTools, responseSchemaDisplay] = - projection.value - const projectedSystemPrompt = restoreProjectedOptionalString(request.systemPrompt, systemPrompt) - const projectedContext = restoreProjectedOptionalString(request.context, context) - let responseFormat = sourceResponseFormat - if (sourceResponseFormat) { - responseFormat = { - ...sourceResponseFormat, - schema: restoreSchemaDisplayValues(sourceResponseFormat.schema, responseSchemaDisplay), - } - } else if (responseSchemaDisplay !== undefined) { - throw new ModelContentProjectionError() - } - - return { - ...request, - systemPrompt: projectedSystemPrompt, - context: projectedContext, - messages: restoreProjectedMessages(sourceMessages, projectedMessages), - tools: restoreProjectedTools(sourceTools, projectedTools), - responseFormat, - } -} - async function omitUnsafeProviderFileAttachments( request: ProviderRequest ): Promise { @@ -417,15 +166,6 @@ export async function executeProviderRequest( request: ProviderRequest, runtimeContext?: ProviderRuntimeContext ): Promise { - const projectionRuntimeContext = runtimeContext - ? { - ...runtimeContext, - resolvedSecretTraceRegistry: - runtimeContext.resolvedSecretTraceRegistry?.forkForToolInputValues( - collectProviderModelInputProvenanceValues(request, providerId) - ), - } - : undefined const provider = await getProviderExecutor(providerId as ProviderId) if (!provider) { throw new Error(`Provider not found: ${providerId}`) @@ -476,9 +216,7 @@ export async function executeProviderRequest( } const provenanceSafeRequest = await omitUnsafeProviderFileAttachments(sanitizedRequest) - const modelSafeRequest = projectionRuntimeContext - ? projectProviderModelContent(provenanceSafeRequest, projectionRuntimeContext) - : provenanceSafeRequest + const modelSafeRequest = provenanceSafeRequest if (modelSafeRequest.responseFormat) { const structuredOutputInstructions = generateStructuredOutputInstructions( @@ -491,7 +229,7 @@ export async function executeProviderRequest( } } - const response = await runWithProviderRuntimeContext(projectionRuntimeContext, async () => { + const response = await runWithProviderRuntimeContext(runtimeContext, async () => { await attachLargeFileRemoteUrls(modelSafeRequest, providerId) await uploadLargeFilesToProvider(modelSafeRequest, providerId) return provider.executeRequest(modelSafeRequest) diff --git a/apps/sim/providers/kimi/index.ts b/apps/sim/providers/kimi/index.ts index 6e1d91e9d99..1cdad1730aa 100644 --- a/apps/sim/providers/kimi/index.ts +++ b/apps/sim/providers/kimi/index.ts @@ -344,7 +344,6 @@ export const kimiProvider: ProviderConfig = { executionParams, { signal: request.abortSignal, - toolInput: toolParams, } ) const toolCallEndTime = Date.now() diff --git a/apps/sim/providers/litellm/index.ts b/apps/sim/providers/litellm/index.ts index f3cd0d10b44..682201c9c08 100644 --- a/apps/sim/providers/litellm/index.ts +++ b/apps/sim/providers/litellm/index.ts @@ -386,7 +386,6 @@ export const litellmProvider: ProviderConfig = { executionParams, { signal: request.abortSignal, - toolInput: toolParams, } ) const toolCallEndTime = Date.now() diff --git a/apps/sim/providers/meta/index.ts b/apps/sim/providers/meta/index.ts index dacce56641b..f5d3ac9b691 100644 --- a/apps/sim/providers/meta/index.ts +++ b/apps/sim/providers/meta/index.ts @@ -297,7 +297,6 @@ export const metaProvider: ProviderConfig = { executionParams, { signal: request.abortSignal, - toolInput: toolParams, } ) const toolCallEndTime = Date.now() diff --git a/apps/sim/providers/mistral/index.ts b/apps/sim/providers/mistral/index.ts index d63a6021d5e..7ec8aa6b9e3 100644 --- a/apps/sim/providers/mistral/index.ts +++ b/apps/sim/providers/mistral/index.ts @@ -312,7 +312,6 @@ export const mistralProvider: ProviderConfig = { executionParams, { signal: request.abortSignal, - toolInput: toolParams, } ) const toolCallEndTime = Date.now() diff --git a/apps/sim/providers/model-input-provenance.test.ts b/apps/sim/providers/model-input-provenance.test.ts deleted file mode 100644 index 00e4b8c3f07..00000000000 --- a/apps/sim/providers/model-input-provenance.test.ts +++ /dev/null @@ -1,131 +0,0 @@ -/** - * @vitest-environment node - */ -import { describe, expect, it } from 'vitest' -import { collectProviderModelInputProvenanceValues } from '@/providers/model-input-provenance' -import type { ProviderRequest } from '@/providers/types' - -function request(overrides: Partial = {}): ProviderRequest { - return { - model: 'test-model', - systemPrompt: 'public prompt', - ...overrides, - } -} - -describe('provider model input provenance', () => { - it('selects dynamic model content without transport context or validated schema grammar', () => { - const selected = collectProviderModelInputProvenanceValues( - request({ - apiKey: 'transport-key', - environmentVariables: { UNUSED: 'unused-secret' }, - tools: [ - { - id: 'dynamic-id', - name: 'dynamic-name', - description: 'dynamic description', - params: {}, - parameters: { - type: 'object', - properties: { - dynamicField: { type: 'string', description: 'dynamic field description' }, - }, - required: ['dynamicField'], - }, - }, - ], - }), - 'openai' - ) - - expect(JSON.stringify(selected)).toContain('dynamic description') - expect(JSON.stringify(selected)).toContain('dynamicField') - expect(JSON.stringify(selected)).toContain('dynamic field description') - expect(JSON.stringify(selected)).not.toContain('transport-key') - expect(JSON.stringify(selected)).not.toContain('unused-secret') - }) - - it('selects only attachment names that the target provider transmits', () => { - const documentRequest = request({ - messages: [ - { - role: 'user', - content: 'read this', - files: [ - { - id: 'file-1', - name: 'model-visible-name.txt', - url: '/file', - size: 1, - type: 'text/plain', - key: 'workspace/file-1', - context: 'storage-only-context', - }, - ], - }, - ], - }) - const openAISelected = collectProviderModelInputProvenanceValues(documentRequest, 'openai') - const geminiSelected = collectProviderModelInputProvenanceValues(documentRequest, 'google') - const imageSelected = collectProviderModelInputProvenanceValues( - request({ - messages: [ - { - role: 'user', - content: 'view this', - files: [ - { - id: 'image-1', - name: 'non-model-image-name.png', - url: '/image', - size: 1, - type: 'image/png', - }, - ], - }, - ], - }), - 'openai' - ) - - expect(openAISelected).toContain('model-visible-name.txt') - expect(openAISelected).not.toContain('storage-only-context') - expect(geminiSelected).not.toContain('model-visible-name.txt') - expect(imageSelected).not.toContain('non-model-image-name.png') - }) - - it('omits every candidate from optional schemas the provider boundary will drop', () => { - const selected = collectProviderModelInputProvenanceValues( - request({ - tools: [ - { - id: 'malformed-tool-id', - name: 'malformed-tool-name', - description: 'malformed-tool-description', - params: {}, - parameters: { properties: { field: 'not-a-schema' } }, - }, - ], - responseFormat: { - name: 'malformed-response-name', - schema: { allOf: ['not-a-schema'] }, - }, - }), - 'openai' - ) - - expect(JSON.stringify(selected)).not.toContain('malformed-tool') - expect(JSON.stringify(selected)).not.toContain('malformed-response') - }) - - it('rejects an oversized model-input selection before building an unbounded projection', () => { - const messages = Array.from({ length: 50_001 }, () => ({ - role: 'user' as const, - content: 'public', - })) - - expect(() => - collectProviderModelInputProvenanceValues(request({ messages }), 'openai') - ).toThrow('Provider model input provenance selection exceeds its safe limit') - }) -}) diff --git a/apps/sim/providers/model-input-provenance.ts b/apps/sim/providers/model-input-provenance.ts deleted file mode 100644 index 9fc38d5e908..00000000000 --- a/apps/sim/providers/model-input-provenance.ts +++ /dev/null @@ -1,62 +0,0 @@ -import { collectModelVisibleSchemaContent } from '@/lib/copilot/model-visible-schema' -import { isProviderAttachmentFilenameModelBound } from '@/providers/attachments' -import { canUseProviderLargeFilePath } from '@/providers/file-attachments.server' -import type { ProviderId, ProviderRequest } from '@/providers/types' - -const MAX_PROVIDER_MODEL_INPUT_PROVENANCE_VALUES = 50_000 - -function schemaProvenanceValues(schema: unknown): unknown[] | undefined { - try { - const content = collectModelVisibleSchemaContent(schema) - return [content.projectedValues, content.guardedValues] - } catch { - return undefined - } -} - -/** - * Selects the dynamic ProviderRequest values that can reach a model. Transport credentials, - * execution context, opaque file bytes/locations, and validated schema grammar are excluded. - */ -export function collectProviderModelInputProvenanceValues( - request: ProviderRequest, - providerId: ProviderId | string -): unknown[] { - const values: unknown[] = [] - const largeFilePathAvailable = providerId === 'openai' && canUseProviderLargeFilePath(providerId) - const append = (...candidates: unknown[]): void => { - for (const candidate of candidates) { - if (candidate === undefined) continue - if (values.length >= MAX_PROVIDER_MODEL_INPUT_PROVENANCE_VALUES) { - throw new Error('Provider model input provenance selection exceeds its safe limit') - } - values.push(candidate) - } - } - - append(request.systemPrompt, request.context) - for (const message of request.messages ?? []) { - append(message.content, message.name) - if (message.function_call) { - append(message.function_call.name, message.function_call.arguments) - } - for (const toolCall of message.tool_calls ?? []) { - append(toolCall.function.name, toolCall.function.arguments) - } - for (const file of message.files ?? []) { - if (isProviderAttachmentFilenameModelBound(file, providerId, { largeFilePathAvailable })) { - append(file.name) - } - } - } - for (const tool of request.tools ?? []) { - const schemaValues = schemaProvenanceValues(tool.parameters) - if (schemaValues) append(tool.id, tool.name, tool.description, ...schemaValues) - } - if (request.responseFormat) { - const schemaValues = schemaProvenanceValues(request.responseFormat.schema) - if (schemaValues) append(request.responseFormat.name, ...schemaValues) - } - - return values -} diff --git a/apps/sim/providers/nvidia/index.ts b/apps/sim/providers/nvidia/index.ts index f35d08042b1..f95daed93b2 100644 --- a/apps/sim/providers/nvidia/index.ts +++ b/apps/sim/providers/nvidia/index.ts @@ -295,7 +295,6 @@ export const nvidiaProvider: ProviderConfig = { executionParams, { signal: request.abortSignal, - toolInput: toolParams, } ) const toolCallEndTime = Date.now() diff --git a/apps/sim/providers/ollama/core.ts b/apps/sim/providers/ollama/core.ts index 2754ee98913..77f3e4bda08 100644 --- a/apps/sim/providers/ollama/core.ts +++ b/apps/sim/providers/ollama/core.ts @@ -320,7 +320,6 @@ export async function executeOllamaProviderRequest( executionParams, { signal: request.abortSignal, - toolInput: toolParams, } ) const toolCallEndTime = Date.now() diff --git a/apps/sim/providers/openai-compat/streaming-tool-loop.ts b/apps/sim/providers/openai-compat/streaming-tool-loop.ts index 1af6acfbec4..522d912056e 100644 --- a/apps/sim/providers/openai-compat/streaming-tool-loop.ts +++ b/apps/sim/providers/openai-compat/streaming-tool-loop.ts @@ -433,7 +433,6 @@ export function createOpenAICompatStreamingToolLoopStream( executionParams, { signal: loopAbortController.signal, - toolInput: toolParams, } ) const toolCallEndTime = Date.now() diff --git a/apps/sim/providers/openai/core.response-status.test.ts b/apps/sim/providers/openai/core.response-status.test.ts index 5c3e5aab6b4..3c82708d4f9 100644 --- a/apps/sim/providers/openai/core.response-status.test.ts +++ b/apps/sim/providers/openai/core.response-status.test.ts @@ -32,7 +32,6 @@ const { mockExecuteProviderTool } = vi.hoisted(() => ({ vi.mock('@/providers/runtime-context', () => ({ executeProviderTool: mockExecuteProviderTool, - projectProviderAttachmentFilenameForModel: (filename: string) => filename, })) function jsonResponse(body: unknown) { diff --git a/apps/sim/providers/openai/core.ts b/apps/sim/providers/openai/core.ts index d0143a102ce..bcb4201a396 100644 --- a/apps/sim/providers/openai/core.ts +++ b/apps/sim/providers/openai/core.ts @@ -710,7 +710,6 @@ export async function executeResponsesProviderRequest( executionParams, { signal: request.abortSignal, - toolInput: toolParams, } ) const toolCallEndTime = Date.now() diff --git a/apps/sim/providers/openai/streaming-tool-loop.ts b/apps/sim/providers/openai/streaming-tool-loop.ts index 1d1d02d3665..73e2cc338c4 100644 --- a/apps/sim/providers/openai/streaming-tool-loop.ts +++ b/apps/sim/providers/openai/streaming-tool-loop.ts @@ -258,7 +258,6 @@ async function executeOpenAIToolCall(options: { executionParams, { signal: request.abortSignal, - toolInput: toolParams, } ) return completeToolExecution( diff --git a/apps/sim/providers/openai/utils.test.ts b/apps/sim/providers/openai/utils.test.ts index b6e787feea4..ae976233930 100644 --- a/apps/sim/providers/openai/utils.test.ts +++ b/apps/sim/providers/openai/utils.test.ts @@ -115,11 +115,10 @@ describe('buildResponsesInputFromMessages', () => { ]) }) - it('projects a document filename at the Responses serialization boundary', () => { + it('preserves an ordinary document filename that collides with a configured secret', () => { const registry = new ResolvedSecretTraceRegistry([ { name: 'FILE_NAME', plaintext: 'report.pdf', encryptedValue: 'ciphertext' }, ]) - registry.recordResolved('FILE_NAME', 'report.pdf') const input = runWithProviderRuntimeContext({ resolvedSecretTraceRegistry: registry }, () => buildResponsesInputFromMessages([ @@ -148,7 +147,7 @@ describe('buildResponsesInputFromMessages', () => { { type: 'input_text', text: 'Analyze this document' }, { type: 'input_file', - filename: '{{FILE_NAME}}.pdf', + filename: 'report.pdf', file_data: 'data:application/pdf;base64,cGRm', }, ], diff --git a/apps/sim/providers/openai/utils.ts b/apps/sim/providers/openai/utils.ts index 2e39cd37e11..93d05f6b9c8 100644 --- a/apps/sim/providers/openai/utils.ts +++ b/apps/sim/providers/openai/utils.ts @@ -2,7 +2,6 @@ import type OpenAI from 'openai' import { Stream } from 'openai/streaming' import { buildOpenAIMessageContent } from '@/providers/attachments' import type { ModelUsage } from '@/providers/cost-policy' -import { projectProviderAttachmentFilenameForModel } from '@/providers/runtime-context' import type { AgentStreamEvent } from '@/providers/stream-events' import type { Message } from '@/providers/types' @@ -169,12 +168,7 @@ export function buildResponsesInputFromMessages( if (message.role === 'system' || message.role === 'user' || message.role === 'assistant') { const content = message.role === 'user' - ? buildOpenAIMessageContent( - message.content, - message.files, - providerId, - projectProviderAttachmentFilenameForModel - ) + ? buildOpenAIMessageContent(message.content, message.files, providerId) : (message.content ?? '') if ( (typeof content === 'string' && !content) || diff --git a/apps/sim/providers/openrouter/index.ts b/apps/sim/providers/openrouter/index.ts index 42173e83c55..7807fbb15cc 100644 --- a/apps/sim/providers/openrouter/index.ts +++ b/apps/sim/providers/openrouter/index.ts @@ -20,10 +20,7 @@ import { createReadableStreamFromOpenAIStream, supportsNativeStructuredOutputs, } from '@/providers/openrouter/utils' -import { - executeProviderTool, - projectProviderAttachmentFilenameForModel, -} from '@/providers/runtime-context' +import { executeProviderTool } from '@/providers/runtime-context' import { createSettledAgentEventStream } from '@/providers/stream-events' import { createStreamingExecution } from '@/providers/streaming-execution' import { isAbortError, parseToolArguments } from '@/providers/streaming-tool-loop-shared' @@ -135,11 +132,7 @@ export const openRouterProvider: ProviderConfig = { if (request.messages) { allMessages.push(...request.messages) } - const formattedMessages = formatMessagesForProvider( - allMessages, - 'openrouter', - projectProviderAttachmentFilenameForModel - ) as Message[] + const formattedMessages = formatMessagesForProvider(allMessages, 'openrouter') as Message[] const tools = request.tools?.length ? request.tools.map((tool) => adaptOpenAIChatToolSchema(tool)) @@ -319,7 +312,6 @@ export const openRouterProvider: ProviderConfig = { executionParams, { signal: request.abortSignal, - toolInput: toolParams, } ) const toolCallEndTime = Date.now() diff --git a/apps/sim/providers/runtime-context.test.ts b/apps/sim/providers/runtime-context.test.ts index ea5d305a528..f46870f47d9 100644 --- a/apps/sim/providers/runtime-context.test.ts +++ b/apps/sim/providers/runtime-context.test.ts @@ -15,22 +15,17 @@ import { ResolvedSecretTraceRegistry } from '@/executor/utils/resolved-secret-tr import { type ExecuteProviderToolOptions, executeProviderTool as executeProviderToolWithInput, - projectProviderAttachmentFilenameForModel, runWithProviderRuntimeContext, } from '@/providers/runtime-context' +import { registerProviderToolInputProvenance } from '@/providers/tool-input-provenance' import { prepareToolExecution } from '@/providers/utils' async function executeProviderTool( toolId: string, params: Parameters[1], - options: Omit & { - toolInput?: Record - } = {} + options: ExecuteProviderToolOptions = {} ) { - const execution = await executeProviderToolWithInput(toolId, params, { - toolInput: params, - ...options, - }) + const execution = await executeProviderToolWithInput(toolId, params, options) return execution.modelResponse } @@ -39,20 +34,6 @@ describe('provider runtime context', () => { vi.clearAllMocks() }) - it('projects a provider-bound filename while preserving its inferred extension', () => { - const registry = new ResolvedSecretTraceRegistry([ - { name: 'FILE_NAME', plaintext: 'report.pdf', encryptedValue: 'ciphertext' }, - ]) - registry.recordResolved('FILE_NAME', 'report.pdf') - - const projected = runWithProviderRuntimeContext({ resolvedSecretTraceRegistry: registry }, () => - projectProviderAttachmentFilenameForModel('report.pdf', 'pdf') - ) - - expect(projected).toBe('{{FILE_NAME}}.pdf') - expect(projectProviderAttachmentFilenameForModel('report.pdf', 'pdf')).toBe('report.pdf') - }) - it('isolates concurrent tool executions without adding registry data to params', async () => { const registryA = new ResolvedSecretTraceRegistry() const registryB = new ResolvedSecretTraceRegistry() @@ -142,7 +123,7 @@ describe('provider runtime context', () => { output: { text: 'Test', boolean: true, booleanText: 'true', number: 123, numberText: '123' }, } mockExecuteTool.mockResolvedValueOnce(rawResult) - const { toolParams, executionParams } = prepareToolExecution( + const { executionParams } = prepareToolExecution( { params: { visible: 'unrelated' } }, {}, { @@ -155,7 +136,7 @@ describe('provider runtime context', () => { const result = await runWithProviderRuntimeContext( { resolvedSecretTraceRegistry: registry }, - () => executeProviderTool('custom-tool', executionParams, { toolInput: toolParams }) + () => executeProviderTool('custom-tool', executionParams) ) expect(result).toEqual(rawResult) @@ -190,7 +171,7 @@ describe('provider runtime context', () => { expect(registry.isComplete()).toBe(true) }) - it('projects a secret inherited through the exact current tool input', async () => { + it('does not infer output provenance from a secret in the current tool input', async () => { const registry = new ResolvedSecretTraceRegistry([ { name: 'TOKEN', plaintext: 'secret-value', encryptedValue: 'ciphertext' }, ]) @@ -209,23 +190,144 @@ describe('provider runtime context', () => { const execution = await runWithProviderRuntimeContext( { resolvedSecretTraceRegistry: registry }, () => - executeProviderToolWithInput( - 'custom-tool', - { token: 'secret-value', envVars: { unrelated: 'public' } }, - { toolInput: { token: 'secret-value' } } - ) + executeProviderToolWithInput('custom-tool', { + token: 'secret-value', + envVars: { unrelated: 'public' }, + }) ) expect(execution.rawResponse).toBe(rawResult) expect(execution.rawResponse.output).toEqual({ authorization: 'Bearer secret-value' }) - expect(execution.modelResponse).toEqual({ - ...rawResult, - output: { authorization: 'Bearer {{TOKEN}}' }, - }) + expect(execution.modelResponse).toEqual(rawResult) expect(execution.modelResponse.resources).toBe(rawResult.resources) expect(registry.isComplete()).toBe(true) }) + it('projects only the active preset secret for the exact configured tool instance', async () => { + const registry = new ResolvedSecretTraceRegistry([ + { name: 'ACTIVE', plaintext: 'x', encryptedValue: 'encrypted-active' }, + { name: 'UNUSED', plaintext: 'true', encryptedValue: 'encrypted-unused' }, + ]) + const sourcePath = ['tools', '0', 'params', 'apiKey'] as const + registry.recordResolvedAtInputPath('ACTIVE', 'x', sourcePath) + registry.recordResolvedInputProjection(sourcePath, 'x', '{{ACTIVE}}') + const tool = { + id: 'duplicate-tool', + params: { apiKey: 'x' }, + parameters: { type: 'object', properties: {}, required: [] }, + paramsTransform: (params: Record) => ({ token: params.apiKey }), + } + registerProviderToolInputProvenance(tool, { + registry, + sourcePath: ['tools', '0', 'params'], + projectedParams: { apiKey: '{{ACTIVE}}' }, + }) + const rawResult = { success: true, output: { reflected: 'x', ordinary: 'true' } } + mockExecuteTool.mockResolvedValueOnce(rawResult) + + const execution = await runWithProviderRuntimeContext( + { resolvedSecretTraceRegistry: registry }, + () => { + const { executionParams } = prepareToolExecution(tool, {}, {}) + return executeProviderToolWithInput(tool.id, executionParams) + } + ) + + expect(execution.rawResponse).toBe(rawResult) + expect(execution.modelResponse.output).toEqual({ + reflected: '{{ACTIVE}}', + ordinary: 'true', + }) + expect(mockExecuteTool.mock.calls.at(-1)?.[1]).toEqual(expect.objectContaining({ token: 'x' })) + expect(mockExecuteTool.mock.calls.at(-1)?.[1]).not.toHaveProperty( + '__resolvedSecretTraceProvenance' + ) + }) + + it('does not carry a prior low-entropy preset into a later duplicate tool instance', async () => { + const registry = new ResolvedSecretTraceRegistry([ + { name: 'FIRST', plaintext: 'x', encryptedValue: 'encrypted-first' }, + ]) + const firstPath = ['tools', '0', 'params', 'apiKey'] as const + registry.recordResolvedAtInputPath('FIRST', 'x', firstPath) + registry.recordResolvedInputProjection(firstPath, 'x', '{{FIRST}}') + const firstTool = { + id: 'duplicate-tool', + params: { apiKey: 'x' }, + parameters: { type: 'object', properties: {}, required: [] }, + } + const secondTool = { + id: 'duplicate-tool', + params: { query: 'safe' }, + parameters: { type: 'object', properties: {}, required: [] }, + } + registerProviderToolInputProvenance(firstTool, { + registry, + sourcePath: ['tools', '0', 'params'], + projectedParams: { apiKey: '{{FIRST}}' }, + }) + registerProviderToolInputProvenance(secondTool, { + registry, + sourcePath: ['tools', '1', 'params'], + projectedParams: { query: 'safe' }, + }) + mockExecuteTool + .mockResolvedValueOnce({ success: true, output: { value: 'x' } }) + .mockResolvedValueOnce({ success: true, output: { value: 'Box' } }) + + const executions = await runWithProviderRuntimeContext( + { resolvedSecretTraceRegistry: registry }, + async () => { + const firstParams = prepareToolExecution(firstTool, {}, {}).executionParams + const first = await executeProviderToolWithInput(firstTool.id, firstParams) + const secondParams = prepareToolExecution(secondTool, {}, {}).executionParams + const second = await executeProviderToolWithInput(secondTool.id, secondParams) + return { first, second } + } + ) + + expect(executions.first.modelResponse.output).toEqual({ value: '{{FIRST}}' }) + expect(executions.second.rawResponse.output).toEqual({ value: 'Box' }) + expect(executions.second.modelResponse.output).toEqual({ value: 'Box' }) + }) + + it('does not activate a configured preset that the deterministic transform drops', async () => { + const registry = new ResolvedSecretTraceRegistry([ + { name: 'DROPPED', plaintext: 'x', encryptedValue: 'encrypted-dropped' }, + ]) + const sourcePath = ['tools', '0', 'params', 'inactive'] as const + registry.recordResolvedAtInputPath('DROPPED', 'x', sourcePath) + registry.recordResolvedInputProjection(sourcePath, 'x', '{{DROPPED}}') + const tool = { + id: 'conditional-tool', + params: { inactive: 'x', query: 'safe' }, + parameters: { type: 'object', properties: {}, required: [] }, + paramsTransform: (params: Record) => ({ query: params.query }), + } + registerProviderToolInputProvenance(tool, { + registry, + sourcePath: ['tools', '0', 'params'], + projectedParams: { inactive: '{{DROPPED}}', query: 'safe' }, + }) + const rawResult = { success: true, output: { value: 'Box' } } + mockExecuteTool.mockResolvedValueOnce(rawResult) + + const execution = await runWithProviderRuntimeContext( + { resolvedSecretTraceRegistry: registry }, + () => { + const { executionParams } = prepareToolExecution(tool, {}, {}) + return executeProviderToolWithInput(tool.id, executionParams) + } + ) + + expect(mockExecuteTool.mock.calls.at(-1)?.[1]).toEqual( + expect.objectContaining({ query: 'safe' }) + ) + expect(mockExecuteTool.mock.calls.at(-1)?.[1]).not.toHaveProperty('inactive') + expect(execution.rawResponse).toBe(rawResult) + expect(execution.modelResponse.output).toEqual({ value: 'Box' }) + }) + it('serializes dates for provider continuations while preserving the raw tool response', async () => { const registry = new ResolvedSecretTraceRegistry() const createdAt = new Date('2026-08-05T12:34:56.789Z') @@ -237,7 +339,7 @@ describe('provider runtime context', () => { const execution = await runWithProviderRuntimeContext( { resolvedSecretTraceRegistry: registry }, - () => executeProviderToolWithInput('custom-tool', {}, { toolInput: {} }) + () => executeProviderToolWithInput('custom-tool', {}) ) expect(execution.rawResponse).toBe(rawResult) @@ -273,7 +375,9 @@ describe('provider runtime context', () => { { name: 'TOKEN', plaintext: 'secret-value', encryptedValue: 'ciphertext' }, ]) mockExecuteTool.mockImplementationOnce(async (_toolId, _params, options) => { - options.resolvedSecretTraceRegistry?.recordResolved('TOKEN', 'secret-value') + options.resolvedSecretTraceRegistry?.recordResolved('TOKEN', 'secret-value', { + propagated: true, + }) return { success: true, output: 'secret-value' } }) @@ -292,7 +396,7 @@ describe('provider runtime context', () => { { name: 'TOKEN', plaintext: secret, encryptedValue: 'ciphertext' }, ]) mockExecuteTool.mockImplementationOnce(async (_toolId, _params, options) => { - options.resolvedSecretTraceRegistry?.recordResolved('TOKEN', secret) + options.resolvedSecretTraceRegistry?.recordResolved('TOKEN', secret, { propagated: true }) return { success: true, output: { @@ -393,7 +497,7 @@ describe('provider runtime context', () => { if (toolId === 'sibling') await siblingGate const name = toolId === 'sibling' ? 'SIBLING' : 'COMPLETED' const plaintext = toolId === 'sibling' ? 'sibling-secret' : 'completed-secret' - toolCallRegistry.recordResolved(name, plaintext) + toolCallRegistry.recordResolved(name, plaintext, { propagated: true }) finish() return { success: true, output: { value: plaintext } } }) @@ -451,7 +555,12 @@ describe('provider runtime context', () => { () => executeProviderTool('custom-tool', {}) ) - expect(result).toEqual({ success: false, output: {} }) + expect(result).toEqual({ + success: false, + output: {}, + error: + 'Tool execution settled, but its result could not be returned safely. Do not retry a mutation automatically.', + }) expect(registry.isComplete()).toBe(false) expect(registry.getActiveMatches()).toEqual([]) }) @@ -471,7 +580,7 @@ describe('provider runtime context', () => { const execution = await runWithProviderRuntimeContext( { resolvedSecretTraceRegistry: registry }, - () => executeProviderToolWithInput('custom-tool', {}, { toolInput: {} }) + () => executeProviderToolWithInput('custom-tool', {}) ) expect(execution.rawResponse.output).toHaveProperty('value', 'secret-value') @@ -493,7 +602,7 @@ describe('provider runtime context', () => { const execution = await runWithProviderRuntimeContext( { resolvedSecretTraceRegistry: registry }, - () => executeProviderToolWithInput('custom-tool', {}, { toolInput: {} }) + () => executeProviderToolWithInput('custom-tool', {}) ) expect(execution.rawResponse).toEqual({ @@ -501,7 +610,12 @@ describe('provider runtime context', () => { output: {}, error: 'secret-value', }) - expect(execution.modelResponse).toEqual({ success: false, output: {} }) + expect(execution.modelResponse).toEqual({ + success: false, + output: {}, + error: + 'Tool execution settled, but its result could not be returned safely. Do not retry a mutation automatically.', + }) expect(registry.isComplete()).toBe(false) expect(registry.getActiveMatches()).toEqual([]) }) diff --git a/apps/sim/providers/runtime-context.ts b/apps/sim/providers/runtime-context.ts index 7a07e153c56..704bc7817c6 100644 --- a/apps/sim/providers/runtime-context.ts +++ b/apps/sim/providers/runtime-context.ts @@ -1,12 +1,9 @@ import { AsyncLocalStorage } from 'node:async_hooks' import { getErrorMessage } from '@sim/utils/errors' -import { isPlainRecord } from '@sim/utils/object' -import { - projectResolvedSecretModelContent, - projectResolvedSecretModelControlMessage, - projectResolvedSecretModelJsonContent, -} from '@/executor/utils/resolved-secret-content-projection' +import { projectToolResultForCopilot } from '@/lib/copilot/request/tools/resolved-secret-result' +import type { ToolExecutionResult } from '@/lib/copilot/tool-executor/types' import type { ResolvedSecretTraceRegistry } from '@/executor/utils/resolved-secret-trace-registry' +import { getPreparedProviderToolInputProvenance } from '@/providers/tool-input-provenance' import { type ExecuteToolOptions, executeTool } from '@/tools' import type { ToolResponse } from '@/tools/types' @@ -14,10 +11,7 @@ export interface ProviderRuntimeContext { resolvedSecretTraceRegistry?: ResolvedSecretTraceRegistry } -export interface ExecuteProviderToolOptions extends ExecuteToolOptions { - /** Exact merged tool arguments, excluding execution-only ambient context. */ - toolInput: Record -} +export type ExecuteProviderToolOptions = ExecuteToolOptions export interface ProviderToolExecutionResult { /** Original tool response retained for workflow outputs, traces, costs, files, and resources. */ @@ -35,83 +29,26 @@ export function runWithProviderRuntimeContext( return providerRuntimeContext.run(context, callback) } -/** Projects a filename only when a provider adapter is about to serialize it upstream. */ -export function projectProviderAttachmentFilenameForModel( - filename: string, - extension: string -): string { - const registry = providerRuntimeContext.getStore()?.resolvedSecretTraceRegistry - if (!registry) return filename - - const projection = projectResolvedSecretModelContent(filename, registry) - if (!projection.safe || typeof projection.value !== 'string') { - throw new Error('Model input could not be safely projected') - } - - const projected = projection.value - const suffix = extension ? `.${extension}` : '' - const hadSuffix = suffix !== '' && filename.toLowerCase().endsWith(suffix.toLowerCase()) - const retainedSuffix = suffix !== '' && projected.toLowerCase().endsWith(suffix.toLowerCase()) - return hadSuffix && !retainedSuffix ? `${projected}${suffix}` : projected -} - -function omittedProviderToolResponse( - result: ToolResponse, - registry: ResolvedSecretTraceRegistry +function toProviderModelResponse( + rawResponse: ToolResponse, + projectedResponse: ToolExecutionResult ): ToolResponse { - const error = result.success - ? undefined - : projectResolvedSecretModelControlMessage('Tool result omitted', registry) - const { output: _output, error: _error, ...functionalFields } = result + const { output: _output, error: _error, ...functionalFields } = rawResponse return { ...functionalFields, - output: {}, - ...(error ? { error } : {}), - } -} - -type ProviderToolResponseProjection = - | { safe: true; response: ToolResponse } - | { safe: false; response: ToolResponse } - -function projectProviderToolResponse( - result: ToolResponse, - registry: ResolvedSecretTraceRegistry -): ProviderToolResponseProjection { - const content: Record = { output: result.output } - if (Object.hasOwn(result, 'error')) content.error = result.error - const projection = projectResolvedSecretModelJsonContent(content, registry) - if ( - !projection.safe || - !isPlainRecord(projection.value) || - !Object.hasOwn(projection.value, 'output') - ) { - return { safe: false, response: omittedProviderToolResponse(result, registry) } - } - - const { output, error } = projection.value - if (error !== undefined && typeof error !== 'string') { - return { safe: false, response: omittedProviderToolResponse(result, registry) } - } - const { output: _output, error: _error, ...functionalFields } = result - - return { - safe: true, - response: { - ...functionalFields, - output: output as ToolResponse['output'], - ...(error !== undefined ? { error } : {}), - }, + output: Object.hasOwn(projectedResponse, 'output') + ? (projectedResponse.output as ToolResponse['output']) + : {}, + ...(projectedResponse.error !== undefined ? { error: projectedResponse.error } : {}), } } export async function executeProviderTool( toolId: string, params: Parameters[1], - options: ExecuteProviderToolOptions + options: ExecuteProviderToolOptions = {} ): Promise { const runtimeContext = providerRuntimeContext.getStore() - const { toolInput, ...executeToolOptions } = options const registry = options.resolvedSecretTraceRegistry ?? runtimeContext?.resolvedSecretTraceRegistry @@ -119,29 +56,37 @@ export async function executeProviderTool( const response: ToolResponse = { success: false, output: {} } return { rawResponse: response, modelResponse: response } } - const toolCallRegistry = registry?.forkForToolInputValues(Object.values(toolInput)) + const preparedInputProvenance = getPreparedProviderToolInputProvenance(params) + const toolCallRegistry = registry + ? preparedInputProvenance?.parentRegistry === registry + ? preparedInputProvenance.registry.forkForInputPaths(preparedInputProvenance.inputPaths, { + propagated: true, + }) + : runtimeContext + ? registry.forkForInputPaths([]) + : registry.forkForToolCall() + : undefined try { const result = await executeTool(toolId, params, { - ...executeToolOptions, + ...options, resolvedSecretTraceRegistry: toolCallRegistry, }) if (!registry || !toolCallRegistry) { return { rawResponse: result, modelResponse: result } } - const projection = projectProviderToolResponse(result, toolCallRegistry) + const modelResponse = toProviderModelResponse( + result, + projectToolResultForCopilot(result, toolCallRegistry) + ) registry.mergeToolCallRegistry(toolCallRegistry) - return { rawResponse: result, modelResponse: projection.response } + return { rawResponse: result, modelResponse } } catch (error) { if (!registry || !toolCallRegistry) throw error - const projectedMessage = projectResolvedSecretModelControlMessage( - getErrorMessage(error), - toolCallRegistry - ) - registry.mergeToolCallRegistry(toolCallRegistry) const errorName = error && typeof error === 'object' && 'name' in error ? String(error.name) : undefined + registry.mergeToolCallRegistry(toolCallRegistry) if (errorName === 'AbortError' || errorName === 'APIUserAbortError') { throw error } @@ -150,11 +95,10 @@ export async function executeProviderTool( output: {}, error: getErrorMessage(error), } - const modelResponse: ToolResponse = { - success: false, - output: {}, - ...(projectedMessage !== undefined ? { error: projectedMessage } : {}), - } + const modelResponse = toProviderModelResponse( + rawResponse, + projectToolResultForCopilot(rawResponse, toolCallRegistry) + ) return { rawResponse, modelResponse } } } diff --git a/apps/sim/providers/sakana/index.ts b/apps/sim/providers/sakana/index.ts index b85bbbfe964..176456afd2d 100644 --- a/apps/sim/providers/sakana/index.ts +++ b/apps/sim/providers/sakana/index.ts @@ -295,7 +295,6 @@ export const sakanaProvider: ProviderConfig = { executionParams, { signal: request.abortSignal, - toolInput: toolParams, } ) const toolCallEndTime = Date.now() diff --git a/apps/sim/providers/together/index.ts b/apps/sim/providers/together/index.ts index 6c5718c0299..90074c785bf 100644 --- a/apps/sim/providers/together/index.ts +++ b/apps/sim/providers/together/index.ts @@ -297,7 +297,6 @@ export const togetherProvider: ProviderConfig = { executionParams, { signal: request.abortSignal, - toolInput: toolParams, } ) const toolCallEndTime = Date.now() diff --git a/apps/sim/providers/tool-input-provenance.ts b/apps/sim/providers/tool-input-provenance.ts new file mode 100644 index 00000000000..83d4449a2ad --- /dev/null +++ b/apps/sim/providers/tool-input-provenance.ts @@ -0,0 +1,49 @@ +import type { + ResolvedSecretInputPath, + ResolvedSecretTraceRegistry, +} from '@/executor/utils/resolved-secret-trace-registry' + +export interface ProviderToolInputProvenance { + registry: ResolvedSecretTraceRegistry + sourcePath: ResolvedSecretInputPath + projectedParams: Record +} + +export interface PreparedProviderToolInputProvenance { + parentRegistry: ResolvedSecretTraceRegistry + registry: ResolvedSecretTraceRegistry + inputPaths: readonly ResolvedSecretInputPath[] +} + +const configuredToolInputProvenance = new WeakMap() +const preparedToolInputProvenance = new WeakMap() + +/** Associates one provider tool object with its exact resolver-recorded preset input. */ +export function registerProviderToolInputProvenance( + tool: object, + provenance: ProviderToolInputProvenance +): void { + configuredToolInputProvenance.set(tool, provenance) +} + +/** Reads provenance for the exact configured tool instance, never by tool id or name. */ +export function getProviderToolInputProvenance( + tool: object +): ProviderToolInputProvenance | undefined { + return configuredToolInputProvenance.get(tool) +} + +/** Associates one prepared execution object with its isolated transformed-input registry. */ +export function registerPreparedProviderToolInputProvenance( + executionParams: object, + provenance: PreparedProviderToolInputProvenance +): void { + preparedToolInputProvenance.set(executionParams, provenance) +} + +/** Reads provenance for the exact prepared execution object passed to the tool adapter. */ +export function getPreparedProviderToolInputProvenance( + executionParams: object +): PreparedProviderToolInputProvenance | undefined { + return preparedToolInputProvenance.get(executionParams) +} diff --git a/apps/sim/providers/utils.test.ts b/apps/sim/providers/utils.test.ts index af00af7cc34..2db2837287f 100644 --- a/apps/sim/providers/utils.test.ts +++ b/apps/sim/providers/utils.test.ts @@ -1275,6 +1275,21 @@ describe('prepareToolExecution', () => { expect(toolParams.channel).toBe('#llm-channel') expect(toolParams.message).toBe('Hello') }) + + it('runs the legacy parameter transform once when no secret provenance is attached', () => { + const paramsTransform = vi.fn((params: Record) => ({ + token: params.apiKey, + })) + + const { toolParams } = prepareToolExecution( + { params: { apiKey: 'ordinary-key' }, paramsTransform }, + {}, + {} + ) + + expect(toolParams).toEqual({ token: 'ordinary-key' }) + expect(paramsTransform).toHaveBeenCalledTimes(1) + }) }) describe('_context propagation', () => { diff --git a/apps/sim/providers/utils.ts b/apps/sim/providers/utils.ts index 3563ced59ed..9310e8d38fa 100644 --- a/apps/sim/providers/utils.ts +++ b/apps/sim/providers/utils.ts @@ -50,6 +50,10 @@ import { supportsToolUsageControl as supportsToolUsageControlFromDefinitions, updateOllamaModels as updateOllamaModelsInDefinitions, } from '@/providers/models' +import { + getProviderToolInputProvenance, + registerPreparedProviderToolInputProvenance, +} from '@/providers/tool-input-provenance' import type { ProviderId, ProviderToolConfig } from '@/providers/types' import { useProvidersStore } from '@/stores/providers/store' import { mergeToolParameters } from '@/tools/merge-params' @@ -1545,17 +1549,31 @@ export function prepareToolExecution( // empty. That is a privilege escalation for `user-only` params: a Function tool // scoped to "Selected secrets" with an empty list is an explicit deny, and a // model emitting `mountedSecrets: ['STRIPE_KEY']` would otherwise mount it. - let toolParams = mergeToolParameters( - tool.params || {}, - stripModelBlockedParams(tool.modelBlockedParams, llmArgs) - ) as Record + const modelParams = stripModelBlockedParams(tool.modelBlockedParams, llmArgs) + let toolParams = mergeToolParameters(tool.params || {}, modelParams) as Record + const inputProvenance = getProviderToolInputProvenance(tool) + const inputRegistry = inputProvenance?.registry.forkForInputPaths([inputProvenance.sourcePath]) + let projectedToolParams = inputProvenance + ? (mergeToolParameters(inputProvenance.projectedParams, modelParams) as Record) + : undefined if (tool.paramsTransform) { + let transformed = false try { toolParams = tool.paramsTransform(toolParams) + transformed = true } catch (err) { logger.warn('paramsTransform failed, using raw params', { error: err }) } + + if (transformed && projectedToolParams && inputRegistry) { + try { + projectedToolParams = tool.paramsTransform(projectedToolParams) + } catch { + inputRegistry.markIncomplete() + projectedToolParams = undefined + } + } } const executionParams = { @@ -1591,6 +1609,21 @@ export function prepareToolExecution( ...(tool.parameters ? { _toolSchema: tool.parameters } : {}), } + if (inputProvenance && inputRegistry) { + const inputPaths = [['params']] as const + if (projectedToolParams) { + inputRegistry.recordTransformedInputProjection( + { params: toolParams }, + { params: projectedToolParams } + ) + } + registerPreparedProviderToolInputProvenance(executionParams, { + parentRegistry: inputProvenance.registry, + registry: inputRegistry, + inputPaths, + }) + } + return { toolParams, executionParams } } diff --git a/apps/sim/providers/vllm/index.ts b/apps/sim/providers/vllm/index.ts index 751535eded5..e6fa61edde2 100644 --- a/apps/sim/providers/vllm/index.ts +++ b/apps/sim/providers/vllm/index.ts @@ -393,7 +393,6 @@ export const vllmProvider: ProviderConfig = { executionParams, { signal: request.abortSignal, - toolInput: toolParams, } ) const toolCallEndTime = Date.now() diff --git a/apps/sim/providers/xai/index.ts b/apps/sim/providers/xai/index.ts index 4f1685fb3c5..a89643c6674 100644 --- a/apps/sim/providers/xai/index.ts +++ b/apps/sim/providers/xai/index.ts @@ -280,7 +280,6 @@ export const xAIProvider: ProviderConfig = { executionParams, { signal: request.abortSignal, - toolInput: toolParams, } ) const toolCallEndTime = Date.now() diff --git a/apps/sim/providers/zai/index.ts b/apps/sim/providers/zai/index.ts index 56a3f3a25a8..5434ba37cbb 100644 --- a/apps/sim/providers/zai/index.ts +++ b/apps/sim/providers/zai/index.ts @@ -306,7 +306,6 @@ export const zaiProvider: ProviderConfig = { executionParams, { signal: request.abortSignal, - toolInput: toolParams, } ) const toolCallEndTime = Date.now() diff --git a/apps/sim/sandbox-tasks/docx-generate.ts b/apps/sim/sandbox-tasks/docx-generate.ts index d93954d923c..541b6d5052c 100644 --- a/apps/sim/sandbox-tasks/docx-generate.ts +++ b/apps/sim/sandbox-tasks/docx-generate.ts @@ -1,3 +1,4 @@ +import { MAX_SANDBOX_IMAGE_DATA_URI_CHARS } from '@/lib/execution/isolated-vm-limits' import { workspaceFileBroker } from '@/lib/execution/sandbox/brokers/workspace-file' import { defineSandboxTask } from '@/lib/execution/sandbox/define-task' import type { SandboxTaskInput } from '@/lib/execution/sandbox/types' @@ -26,7 +27,7 @@ export const docxGenerateTask = defineSandboxTask({ globalThis.CONTENT_W = 9360; // PAGE_W - 2 * MARGIN // 6 MB raw ≈ 8 MB base64; reject above this to avoid sandbox OOM. - const _MAX_IMG_B64 = 8 * 1024 * 1024; + const _MAX_IMG_B64 = ${MAX_SANDBOX_IMAGE_DATA_URI_CHARS}; /** * getFileBase64(fileId) — load a workspace file as a full data URI string. diff --git a/apps/sim/sandbox-tasks/pdf-generate.ts b/apps/sim/sandbox-tasks/pdf-generate.ts index a7f23e710f1..1e48a02f301 100644 --- a/apps/sim/sandbox-tasks/pdf-generate.ts +++ b/apps/sim/sandbox-tasks/pdf-generate.ts @@ -1,3 +1,4 @@ +import { MAX_SANDBOX_IMAGE_DATA_URI_CHARS } from '@/lib/execution/isolated-vm-limits' import { workspaceFileBroker } from '@/lib/execution/sandbox/brokers/workspace-file' import { defineSandboxTask } from '@/lib/execution/sandbox/define-task' import type { SandboxTaskInput } from '@/lib/execution/sandbox/types' @@ -22,7 +23,7 @@ export const pdfGenerateTask = defineSandboxTask({ globalThis.A4 = [595.28, 841.89]; // 210mm × 297mm // 6 MB raw ≈ 8 MB base64; reject above this to avoid sandbox OOM. - const _MAX_IMG_B64 = 8 * 1024 * 1024; + const _MAX_IMG_B64 = ${MAX_SANDBOX_IMAGE_DATA_URI_CHARS}; /** * embedImage(dataUri) — embed a data-URI image into the active PDF document. diff --git a/apps/sim/sandbox-tasks/pptx-generate.ts b/apps/sim/sandbox-tasks/pptx-generate.ts index f31fcb9a1f1..2ef2f8ce5c9 100644 --- a/apps/sim/sandbox-tasks/pptx-generate.ts +++ b/apps/sim/sandbox-tasks/pptx-generate.ts @@ -1,3 +1,4 @@ +import { MAX_SANDBOX_IMAGE_DATA_URI_CHARS } from '@/lib/execution/isolated-vm-limits' import { workspaceFileBroker } from '@/lib/execution/sandbox/brokers/workspace-file' import { defineSandboxTask } from '@/lib/execution/sandbox/define-task' import type { SandboxTaskInput } from '@/lib/execution/sandbox/types' @@ -22,7 +23,7 @@ export const pptxGenerateTask = defineSandboxTask({ // ── Image helpers ────────────────────────────────────────────────────────── // 6 MB raw ≈ 8 MB base64; reject above this to avoid sandbox OOM. - const _MAX_IMG_B64 = 8 * 1024 * 1024; + const _MAX_IMG_B64 = ${MAX_SANDBOX_IMAGE_DATA_URI_CHARS}; /** * getFileBase64(fileId) — load a workspace file as a data URI string. diff --git a/apps/sim/tools/a2a/send_message.ts b/apps/sim/tools/a2a/send_message.ts index 3e3f4be1872..4a3c57568a3 100644 --- a/apps/sim/tools/a2a/send_message.ts +++ b/apps/sim/tools/a2a/send_message.ts @@ -1,4 +1,4 @@ -import { selectModelBoundFileInput } from '@/lib/uploads/utils/model-input' +import { selectModelBoundFileInputPaths } from '@/lib/uploads/utils/model-input' import { A2A_TASK_OUTPUTS, type A2ASendMessageParams, @@ -61,7 +61,8 @@ export const a2aSendMessageTool: ToolConfig ({ message: params.message, data: params.data }), - privateProvenance: (params) => selectModelBoundFileInput(params.files, { includeName: true }), + privateInputPaths: (params) => + selectModelBoundFileInputPaths(params.files, ['files'], { includeName: true }), }, url: '/api/tools/a2a/send-message', method: 'POST', diff --git a/apps/sim/tools/browser_use/run_task.ts b/apps/sim/tools/browser_use/run_task.ts index 9e9d43165a8..d20eb7ac9e2 100644 --- a/apps/sim/tools/browser_use/run_task.ts +++ b/apps/sim/tools/browser_use/run_task.ts @@ -417,7 +417,7 @@ export const runTaskTool: ToolConfig params.startUrl, + inputPaths: () => [['startUrl']], }, }, diff --git a/apps/sim/tools/context_dev/extract.ts b/apps/sim/tools/context_dev/extract.ts index d592177353b..64c46e7c856 100644 --- a/apps/sim/tools/context_dev/extract.ts +++ b/apps/sim/tools/context_dev/extract.ts @@ -94,7 +94,7 @@ export const contextDevExtractTool: ToolConfig params.url, + inputPaths: () => [['url']], }, method: 'POST', url: () => `${CONTEXT_DEV_BASE_URL}/web/extract`, diff --git a/apps/sim/tools/context_dev/extract_product.ts b/apps/sim/tools/context_dev/extract_product.ts index 2184fe3ac6b..3dbd4c3e71e 100644 --- a/apps/sim/tools/context_dev/extract_product.ts +++ b/apps/sim/tools/context_dev/extract_product.ts @@ -54,7 +54,7 @@ export const contextDevExtractProductTool: ToolConfig< request: { opaqueModelInput: { mode: 'reject-resolved-secrets', - select: (params) => params.url, + inputPaths: () => [['url']], }, method: 'POST', url: () => `${CONTEXT_DEV_BASE_URL}/brand/ai/product`, diff --git a/apps/sim/tools/context_dev/extract_products.ts b/apps/sim/tools/context_dev/extract_products.ts index e3bb4ed00e3..b65a138a56b 100644 --- a/apps/sim/tools/context_dev/extract_products.ts +++ b/apps/sim/tools/context_dev/extract_products.ts @@ -60,7 +60,7 @@ export const contextDevExtractProductsTool: ToolConfig< request: { opaqueModelInput: { mode: 'reject-resolved-secrets', - select: (params) => params.domain, + inputPaths: () => [['domain']], }, method: 'POST', url: () => `${CONTEXT_DEV_BASE_URL}/brand/ai/products`, diff --git a/apps/sim/tools/cursor/add_followup.ts b/apps/sim/tools/cursor/add_followup.ts index 244d4cd6695..934fa37ea65 100644 --- a/apps/sim/tools/cursor/add_followup.ts +++ b/apps/sim/tools/cursor/add_followup.ts @@ -1,6 +1,6 @@ import { selectCursorPromptModelInput, - selectCursorPromptOpaqueModelInput, + selectCursorPromptOpaqueModelInputPaths, } from '@/tools/cursor/model-input' import type { AddFollowupParams, AddFollowupResponse } from '@/tools/cursor/types' import type { ToolConfig } from '@/tools/types' @@ -40,7 +40,7 @@ const addFollowupBase = { }, opaqueModelInput: { mode: 'reject-resolved-secrets', - select: selectCursorPromptOpaqueModelInput, + inputPaths: selectCursorPromptOpaqueModelInputPaths, }, url: (params: AddFollowupParams) => `https://api.cursor.com/v0/agents/${params.agentId.trim()}/followup`, diff --git a/apps/sim/tools/cursor/launch_agent.ts b/apps/sim/tools/cursor/launch_agent.ts index 3ec9f5720d5..49953b62f6b 100644 --- a/apps/sim/tools/cursor/launch_agent.ts +++ b/apps/sim/tools/cursor/launch_agent.ts @@ -1,6 +1,6 @@ import { selectCursorPromptModelInput, - selectCursorPromptOpaqueModelInput, + selectCursorPromptOpaqueModelInputPaths, } from '@/tools/cursor/model-input' import type { LaunchAgentParams, LaunchAgentResponse } from '@/tools/cursor/types' import type { ToolConfig } from '@/tools/types' @@ -75,7 +75,7 @@ const launchAgentBase = { }, opaqueModelInput: { mode: 'reject-resolved-secrets', - select: selectCursorPromptOpaqueModelInput, + inputPaths: selectCursorPromptOpaqueModelInputPaths, }, url: () => 'https://api.cursor.com/v0/agents', method: 'POST', diff --git a/apps/sim/tools/cursor/model-input.ts b/apps/sim/tools/cursor/model-input.ts index 8858636f013..44ace6af8e9 100644 --- a/apps/sim/tools/cursor/model-input.ts +++ b/apps/sim/tools/cursor/model-input.ts @@ -24,6 +24,10 @@ export function selectCursorPromptModelInput( } /** Selects the effective image payload exactly as Cursor's request formatter will send it. */ -export function selectCursorPromptOpaqueModelInput(params: CursorPromptModelInputParams): unknown { - return parseCursorPromptImages(params.promptImages) +export function selectCursorPromptOpaqueModelInputPaths( + params: CursorPromptModelInputParams +): readonly (readonly string[])[] { + const parsed = parseCursorPromptImages(params.promptImages) + if (parsed === undefined || (Array.isArray(parsed) && parsed.length === 0)) return [] + return [['promptImages']] } diff --git a/apps/sim/tools/elevenlabs/audio-isolation.ts b/apps/sim/tools/elevenlabs/audio-isolation.ts index 1119f4e1ab4..0337a27b842 100644 --- a/apps/sim/tools/elevenlabs/audio-isolation.ts +++ b/apps/sim/tools/elevenlabs/audio-isolation.ts @@ -1,4 +1,4 @@ -import { selectElevenLabsAudioModelInput } from '@/tools/elevenlabs/model-input' +import { selectElevenLabsAudioModelInputPaths } from '@/tools/elevenlabs/model-input' import type { ElevenLabsAudioIsolationParams, ElevenLabsAudioResponse, @@ -32,7 +32,7 @@ export const elevenLabsAudioIsolationTool: ToolConfig< request: { modelInput: { mode: 'private-provenance', - select: selectElevenLabsAudioModelInput, + inputPaths: selectElevenLabsAudioModelInputPaths, }, url: '/api/tools/elevenlabs/audio', method: 'POST', diff --git a/apps/sim/tools/elevenlabs/model-input.ts b/apps/sim/tools/elevenlabs/model-input.ts index ee18de446f9..41e87af0803 100644 --- a/apps/sim/tools/elevenlabs/model-input.ts +++ b/apps/sim/tools/elevenlabs/model-input.ts @@ -1,8 +1,11 @@ -import { selectModelBoundFileInput } from '@/lib/uploads/utils/model-input' +import { selectModelBoundFileInputPaths } from '@/lib/uploads/utils/model-input' +import type { ResolvedSecretInputPath } from '@/executor/utils/resolved-secret-trace-registry' -/** Selects the audio source consumed by ElevenLabs speech transforms. */ -export function selectElevenLabsAudioModelInput(params: { audioFile?: unknown }): unknown { - return selectModelBoundFileInput(params.audioFile, { +/** Selects exact resolver paths for audio content consumed by ElevenLabs transforms. */ +export function selectElevenLabsAudioModelInputPaths(params: { + audioFile?: unknown +}): readonly ResolvedSecretInputPath[] { + return selectModelBoundFileInputPaths(params.audioFile, ['audioFile'], { includeName: true, }) } diff --git a/apps/sim/tools/elevenlabs/speech-to-speech.ts b/apps/sim/tools/elevenlabs/speech-to-speech.ts index 4d93d2c6ca7..aa040c43b12 100644 --- a/apps/sim/tools/elevenlabs/speech-to-speech.ts +++ b/apps/sim/tools/elevenlabs/speech-to-speech.ts @@ -1,4 +1,4 @@ -import { selectElevenLabsAudioModelInput } from '@/tools/elevenlabs/model-input' +import { selectElevenLabsAudioModelInputPaths } from '@/tools/elevenlabs/model-input' import type { ElevenLabsAudioResponse, ElevenLabsSpeechToSpeechParams, @@ -50,7 +50,7 @@ export const elevenLabsSpeechToSpeechTool: ToolConfig< request: { modelInput: { mode: 'private-provenance', - select: selectElevenLabsAudioModelInput, + inputPaths: selectElevenLabsAudioModelInputPaths, }, url: '/api/tools/elevenlabs/audio', method: 'POST', diff --git a/apps/sim/tools/exa/find_similar_links.ts b/apps/sim/tools/exa/find_similar_links.ts index e95f6e004dc..9b7b42a2f9d 100644 --- a/apps/sim/tools/exa/find_similar_links.ts +++ b/apps/sim/tools/exa/find_similar_links.ts @@ -127,7 +127,7 @@ export const findSimilarLinksTool: ToolConfig< request: { opaqueModelInput: { mode: 'reject-resolved-secrets', - select: (params) => params.url, + inputPaths: () => [['url']], }, url: 'https://api.exa.ai/findSimilar', method: 'POST', diff --git a/apps/sim/tools/exa/get_contents.ts b/apps/sim/tools/exa/get_contents.ts index 4db1af5728b..35c797810e6 100644 --- a/apps/sim/tools/exa/get_contents.ts +++ b/apps/sim/tools/exa/get_contents.ts @@ -125,8 +125,7 @@ export const getContentsTool: ToolConfig - params.summaryQuery || params.summary === true ? params.urls : undefined, + inputPaths: (params) => (params.summaryQuery || params.summary === true ? [['urls']] : []), }, url: 'https://api.exa.ai/contents', method: 'POST', diff --git a/apps/sim/tools/extend/parser.ts b/apps/sim/tools/extend/parser.ts index e94d85bf733..c4aed624a46 100644 --- a/apps/sim/tools/extend/parser.ts +++ b/apps/sim/tools/extend/parser.ts @@ -1,8 +1,8 @@ import { toError } from '@sim/utils/errors' import { isInternalFileUrl } from '@/lib/uploads/utils/file-utils' import { - selectModelBoundFileInput, - selectPreferredModelBoundFileInput, + selectModelBoundFileInputPaths, + selectPreferredModelBoundFileInputPaths, } from '@/lib/uploads/utils/model-input' import type { ExtendParserInput, @@ -66,10 +66,12 @@ export const extendParserTool: ToolConfig request: { modelInput: { mode: 'private-provenance', - select: (params) => - selectPreferredModelBoundFileInput({ + inputPaths: (params) => + selectPreferredModelBoundFileInputPaths({ file: params.file && typeof params.file === 'object' ? params.file : params.fileUpload, filePath: params.filePath, + fileInputPath: params.file && typeof params.file === 'object' ? ['file'] : ['fileUpload'], + filePathInputPath: ['filePath'], prefer: 'path', }), }, @@ -223,7 +225,7 @@ export const extendParserV2Tool: ToolConfig selectModelBoundFileInput(params.file), + inputPaths: (params) => selectModelBoundFileInputPaths(params.file, ['file']), }, url: '/api/tools/extend/parse', method: 'POST', diff --git a/apps/sim/tools/file/append.ts b/apps/sim/tools/file/append.ts index f97df89b58b..30bfdb77241 100644 --- a/apps/sim/tools/file/append.ts +++ b/apps/sim/tools/file/append.ts @@ -40,7 +40,7 @@ export const fileAppendTool: ToolConfig = { workspaceId: params.workspaceId || params._context?.workspaceId, }), secretProvenance: { - request: (params) => [{ key: 'content', value: params.content }], + request: () => [{ key: 'content', inputPaths: [['content']] }], }, }, diff --git a/apps/sim/tools/file/provenance.test.ts b/apps/sim/tools/file/provenance.test.ts index 2749656f7e2..65b4b339ab8 100644 --- a/apps/sim/tools/file/provenance.test.ts +++ b/apps/sim/tools/file/provenance.test.ts @@ -15,7 +15,7 @@ describe('workspace file mutation provenance', () => { content: 'causal-content', workspaceId: 'workspace-id', }) - ).toEqual([{ key: 'content', value: 'causal-content' }]) + ).toEqual([{ key: 'content', inputPaths: [['content']] }]) }) it('tracks file-write content without changing existing filename behavior', () => { @@ -27,6 +27,6 @@ describe('workspace file mutation provenance', () => { content: 'causal-content', workspaceId: 'workspace-id', }) - ).toEqual([{ key: 'content', value: 'causal-content' }]) + ).toEqual([{ key: 'content', inputPaths: [['content']] }]) }) }) diff --git a/apps/sim/tools/file/write.ts b/apps/sim/tools/file/write.ts index 4d7031d57df..e5f345122c4 100644 --- a/apps/sim/tools/file/write.ts +++ b/apps/sim/tools/file/write.ts @@ -50,7 +50,7 @@ export const fileWriteTool: ToolConfig = { workspaceId: params.workspaceId || params._context?.workspaceId, }), secretProvenance: { - request: (params) => [{ key: 'content', value: params.content }], + request: () => [{ key: 'content', inputPaths: [['content']] }], }, }, diff --git a/apps/sim/tools/firecrawl/agent.ts b/apps/sim/tools/firecrawl/agent.ts index 8a5a54e6413..6865407cbdc 100644 --- a/apps/sim/tools/firecrawl/agent.ts +++ b/apps/sim/tools/firecrawl/agent.ts @@ -63,7 +63,7 @@ export const agentTool: ToolConfig = { }, opaqueModelInput: { mode: 'reject-resolved-secrets', - select: (params) => params.urls, + inputPaths: () => [['urls']], }, method: 'POST', url: 'https://api.firecrawl.dev/v2/agent', diff --git a/apps/sim/tools/firecrawl/batch-scrape.ts b/apps/sim/tools/firecrawl/batch-scrape.ts index b2540e2b769..fdee7f96990 100644 --- a/apps/sim/tools/firecrawl/batch-scrape.ts +++ b/apps/sim/tools/firecrawl/batch-scrape.ts @@ -137,10 +137,10 @@ export const batchScrapeTool: ToolConfig + inputPaths: (params) => hasFirecrawlModelInputFormat(params.formats ?? params.scrapeOptions?.formats) - ? params.urls - : undefined, + ? [['urls']] + : [], }, method: 'POST', url: 'https://api.firecrawl.dev/v2/batch/scrape', diff --git a/apps/sim/tools/firecrawl/crawl.ts b/apps/sim/tools/firecrawl/crawl.ts index 1969641c6cc..73df61666ae 100644 --- a/apps/sim/tools/firecrawl/crawl.ts +++ b/apps/sim/tools/firecrawl/crawl.ts @@ -126,13 +126,13 @@ export const crawlTool: ToolConfig }, opaqueModelInput: { mode: 'reject-resolved-secrets', - select: (params) => + inputPaths: (params) => params.prompt || hasFirecrawlModelInputFormat( params.scrapeOptions ? params.scrapeOptions.formats : params.formats ) - ? params.url - : undefined, + ? [['url']] + : [], }, url: 'https://api.firecrawl.dev/v2/crawl', method: 'POST', diff --git a/apps/sim/tools/firecrawl/extract.ts b/apps/sim/tools/firecrawl/extract.ts index 4f3cfd1eda2..5cb915bba20 100644 --- a/apps/sim/tools/firecrawl/extract.ts +++ b/apps/sim/tools/firecrawl/extract.ts @@ -106,7 +106,7 @@ export const extractTool: ToolConfig = { }, opaqueModelInput: { mode: 'reject-resolved-secrets', - select: (params) => params.urls, + inputPaths: () => [['urls']], }, method: 'POST', url: 'https://api.firecrawl.dev/v2/extract', diff --git a/apps/sim/tools/firecrawl/parse.ts b/apps/sim/tools/firecrawl/parse.ts index 759a6e42dbf..12cebc5fa90 100644 --- a/apps/sim/tools/firecrawl/parse.ts +++ b/apps/sim/tools/firecrawl/parse.ts @@ -1,4 +1,4 @@ -import { selectModelBoundFileInput } from '@/lib/uploads/utils/model-input' +import { selectModelBoundFileInputPaths } from '@/lib/uploads/utils/model-input' import { firecrawlHosting } from '@/tools/firecrawl/hosting' import { applyFirecrawlFormatModelInput, @@ -99,10 +99,10 @@ export const parseTool: ToolConfig = { applyProjected: (selectedParams, projectedSelection) => ({ formats: applyFirecrawlFormatModelInput(selectedParams.formats, projectedSelection.formats), }), - privateProvenance: (params) => + privateInputPaths: (params) => hasFirecrawlParseModelInput(params) - ? selectModelBoundFileInput(params.file, { includeName: true }) - : undefined, + ? selectModelBoundFileInputPaths(params.file, ['file'], { includeName: true }) + : [], }, method: 'POST', url: '/api/tools/firecrawl/parse', diff --git a/apps/sim/tools/firecrawl/scrape.ts b/apps/sim/tools/firecrawl/scrape.ts index 81710a4bba1..1412f5d73b1 100644 --- a/apps/sim/tools/firecrawl/scrape.ts +++ b/apps/sim/tools/firecrawl/scrape.ts @@ -73,10 +73,10 @@ export const scrapeTool: ToolConfig = { }, opaqueModelInput: { mode: 'reject-resolved-secrets', - select: (params) => + inputPaths: (params) => hasFirecrawlModelInputFormat(params.scrapeOptions?.formats ?? params.formats) - ? params.url - : undefined, + ? [['url']] + : [], }, method: 'POST', url: 'https://api.firecrawl.dev/v2/scrape', diff --git a/apps/sim/tools/fireflies/upload_audio.ts b/apps/sim/tools/fireflies/upload_audio.ts index ee31465f188..43b90a1027e 100644 --- a/apps/sim/tools/fireflies/upload_audio.ts +++ b/apps/sim/tools/fireflies/upload_audio.ts @@ -70,13 +70,13 @@ export const firefliesUploadAudioTool: ToolConfig< modelInput: { mode: 'project', select: (params) => ({ language: params.language }), - privateProvenance: (params) => { + privateInputPaths: (params) => { if (isPlainRecord(params.audioFile)) { - if (params.audioFile.key) return undefined - if (params.audioFile.url) return { url: params.audioFile.url } - if (params.audioFile.path) return { path: params.audioFile.path } + if (params.audioFile.key) return [] + if (params.audioFile.url) return [['audioFile', 'url']] + if (params.audioFile.path) return [['audioFile', 'path']] } - return params.audioUrl || undefined + return params.audioUrl ? [['audioUrl']] : [] }, }, url: '/api/tools/fireflies/upload-audio', diff --git a/apps/sim/tools/guardrails/validate.test.ts b/apps/sim/tools/guardrails/validate.test.ts index 81f30abaea7..9c610b87d80 100644 --- a/apps/sim/tools/guardrails/validate.test.ts +++ b/apps/sim/tools/guardrails/validate.test.ts @@ -67,7 +67,9 @@ describe('guardrailsValidateTool.request.modelInput', () => { expect(modelInput?.mode).toBe('private-provenance') if (modelInput?.mode !== 'private-provenance') throw new Error('Unexpected model input mode') - expect(modelInput.select({ input: 'claim', validationType: 'hallucination' })).toBe('claim') - expect(modelInput.select({ input: 'private text', validationType: 'pii' })).toBeUndefined() + expect(modelInput.inputPaths({ input: 'claim', validationType: 'hallucination' })).toEqual([ + ['input'], + ]) + expect(modelInput.inputPaths({ input: 'private text', validationType: 'pii' })).toEqual([]) }) }) diff --git a/apps/sim/tools/guardrails/validate.ts b/apps/sim/tools/guardrails/validate.ts index b5d8ee9fce1..ff4f02f09a5 100644 --- a/apps/sim/tools/guardrails/validate.ts +++ b/apps/sim/tools/guardrails/validate.ts @@ -199,8 +199,8 @@ export const guardrailsValidateTool: ToolConfig - params.validationType === 'hallucination' ? params.input : undefined, + inputPaths: (params: GuardrailsValidateInput) => + params.validationType === 'hallucination' ? [['input']] : [], }, body: (params: GuardrailsValidateInput) => ({ input: params.input, diff --git a/apps/sim/tools/index.test.ts b/apps/sim/tools/index.test.ts index a0e4f2d90cc..3e1b28d1fed 100644 --- a/apps/sim/tools/index.test.ts +++ b/apps/sim/tools/index.test.ts @@ -782,7 +782,7 @@ describe('executeTool Function', () => { }, }, ])( - 'preserves an unverified error status for the $name without exposing its body or headers', + 'preserves the raw $name error without logging unverifiable details', async ({ toolId, status, params }) => { const registry = new ResolvedSecretTraceRegistry([], { userId: 'user-1', @@ -804,14 +804,11 @@ describe('executeTool Function', () => { ) as typeof fetch const result = await executeTool(toolId, params, { resolvedSecretTraceRegistry: registry }) - const error = `Internal tool request failed (HTTP ${status})` - expect(result).toMatchObject({ success: false, - output: { status, data: { success: false, error } }, - error, + output: { status, data: { error: untrustedDetail } }, + error: untrustedDetail, }) - expect(JSON.stringify(result)).not.toContain(untrustedDetail) expect(JSON.stringify(result)).not.toContain(untrustedHeader) expect(JSON.stringify(mockToolsLogger.error.mock.calls)).not.toContain(untrustedDetail) expect(JSON.stringify(mockToolsLogger.error.mock.calls)).not.toContain(untrustedHeader) @@ -819,7 +816,7 @@ describe('executeTool Function', () => { } ) - it('maps an unverified non-error HTTP status to a metadata failure', async () => { + it('preserves a headerless legacy HTTP status without poisoning later calls', async () => { const registry = new ResolvedSecretTraceRegistry() global.fetch = Object.assign(vi.fn().mockResolvedValue(new Response(null, { status: 304 })), { preconnect: vi.fn(), @@ -831,21 +828,11 @@ describe('executeTool Function', () => { { resolvedSecretTraceRegistry: registry } ) - expect(result).toMatchObject({ - success: false, - output: { - status: 502, - data: { - success: false, - error: 'Internal tool response metadata could not be verified', - }, - }, - error: 'Internal tool response metadata could not be verified', - }) + expect(result).toMatchObject({ success: false, output: { status: 304 } }) expect(registry.isComplete()).toBe(true) }) - it('contains incomplete File Get Content provenance within that tool call', async () => { + it('preserves File Get Content with authenticated incomplete lineage', async () => { const registry = new ResolvedSecretTraceRegistry([], { userId: 'user-1', workspaceId: 'workspace-1', @@ -885,16 +872,13 @@ describe('executeTool Function', () => { { resolvedSecretTraceRegistry: registry } ) - expect(result).toMatchObject({ - success: false, - output: {}, - error: 'Internal tool response metadata could not be verified', - }) - expect(JSON.stringify(result)).not.toContain('untrusted file content') - expect(registry.isComplete()).toBe(true) + expect(result.success).toBe(true) + expect(JSON.stringify(result)).toContain('untrusted file content') + expect(JSON.stringify(result)).not.toContain('__resolvedSecretTraceProvenance') + expect(registry.isComplete()).toBe(false) }) - it('fails one legacy File Get Content response without poisoning the parent registry', async () => { + it('preserves a headerless legacy File Get Content response without poisoning later calls', async () => { const registry = new ResolvedSecretTraceRegistry([], { userId: 'user-1', workspaceId: 'workspace-1', @@ -919,8 +903,8 @@ describe('executeTool Function', () => { { resolvedSecretTraceRegistry: registry } ) - expect(result.success).toBe(false) - expect(JSON.stringify(result)).not.toContain('legacy content') + expect(result.success).toBe(true) + expect(JSON.stringify(result)).toContain('legacy content') expect(registry.isComplete()).toBe(true) }) @@ -1311,7 +1295,7 @@ describe('executeTool Function', () => { expect(registry.isComplete()).toBe(true) }) - it('accepts a legacy Function response after reconstructing its local provenance', async () => { + it('preserves a headerless legacy Function response without poisoning later calls', async () => { const registry = new ResolvedSecretTraceRegistry() global.fetch = Object.assign( vi @@ -1339,7 +1323,7 @@ describe('executeTool Function', () => { expect(registry.isComplete()).toBe(true) }) - it('reconstructs a crossing secret from a legacy Function response', async () => { + it('does not trust provenance inferred only from a headerless legacy Function body', async () => { const registry = new ResolvedSecretTraceRegistry([ { name: 'API_KEY', plaintext: 'legacy-secret', encryptedValue: 'encrypted-value' }, ]) @@ -1364,9 +1348,7 @@ describe('executeTool Function', () => { ) expect(result.success).toBe(true) - expect(registry.getActiveMatches()).toEqual([ - { plaintext: 'legacy-secret', replacement: '{{API_KEY}}' }, - ]) + expect(registry.getActiveMatches()).toEqual([]) expect(registry.isComplete()).toBe(true) }) @@ -1381,7 +1363,9 @@ describe('executeTool Function', () => { ], { userId: 'parent-owner', workspaceId: 'workspace-456' } ) - expect(registry.recordResolved('INPUT_SECRET', 'secret-value')).toBe(true) + expect( + registry.recordResolvedAtInputPath('INPUT_SECRET', 'secret-value', ['inputMapping']) + ).toBe(true) global.fetch = Object.assign( vi.fn().mockResolvedValue( new Response( @@ -1519,7 +1503,7 @@ describe('executeTool Function', () => { expect(registry.isComplete()).toBe(true) }) - it('drops a legacy workflow response without poisoning later provenance', async () => { + it('preserves a headerless legacy workflow response without poisoning later calls', async () => { const registry = new ResolvedSecretTraceRegistry([], { userId: 'parent-user', workspaceId: 'workspace-456', @@ -1547,8 +1531,8 @@ describe('executeTool Function', () => { } ) - expect(result.success).toBe(false) - expect(JSON.stringify(result)).not.toContain('unverifiable legacy output') + expect(result.success).toBe(true) + expect(JSON.stringify(result)).toContain('unverifiable legacy output') expect(registry.isComplete()).toBe(true) vi.mocked(global.fetch).mockResolvedValueOnce( @@ -1591,7 +1575,7 @@ describe('executeTool Function', () => { expect(registry.isComplete()).toBe(true) }) - it('contains incomplete workflow provenance within that tool call', async () => { + it('preserves workflow output with authenticated incomplete lineage', async () => { const registry = new ResolvedSecretTraceRegistry([], { userId: 'parent-user', workspaceId: 'workspace-456', @@ -1631,13 +1615,10 @@ describe('executeTool Function', () => { } ) - expect(result).toMatchObject({ - success: false, - output: {}, - error: 'Internal tool response metadata could not be verified', - }) - expect(JSON.stringify(result)).not.toContain('untrusted partial output') - expect(registry.isComplete()).toBe(true) + expect(result.success).toBe(true) + expect(JSON.stringify(result)).toContain('untrusted partial output') + expect(JSON.stringify(result)).not.toContain('__resolvedSecretTraceProvenance') + expect(registry.isComplete()).toBe(false) }) it('does not charge private provenance against the functional response limit', async () => { @@ -1768,12 +1749,12 @@ describe('executeTool Function', () => { expect(JSON.stringify(result)).not.toContain('__resolvedSecretNames') expect(result).toMatchObject({ success: false, - error: 'Internal tool response metadata could not be verified', + error: 'Internal tool request failed (HTTP 500)', }) expect(registry.isComplete()).toBe(true) }) - it('projects a thrown error with complete provenance before committing it', async () => { + it('preserves a thrown error while committing provenance for downstream projection', async () => { const secret = 'transaction-throw-secret' const registry = new ResolvedSecretTraceRegistry([ { name: 'API_KEY', plaintext: secret, encryptedValue: 'encrypted-value' }, @@ -1797,8 +1778,9 @@ describe('executeTool Function', () => { ), { preconnect: vi.fn() } ) as typeof fetch + const originalError = new Error(secret) mockToolsLogger.error.mockImplementation(() => { - throw new Error(secret) + throw originalError }) const execution = executeTool( @@ -1807,67 +1789,140 @@ describe('executeTool Function', () => { { resolvedSecretTraceRegistry: registry } ) - await expect(execution).rejects.toMatchObject({ message: '{{API_KEY}}' }) + await expect(execution).rejects.toBe(originalError) expect(registry.getActiveMatches()).toEqual([{ plaintext: secret, replacement: '{{API_KEY}}' }]) expect(registry.isComplete()).toBe(true) + expect( + projectToolResultForCopilot({ success: false, error: originalError.message }, registry) + ).toEqual({ success: false, error: '{{API_KEY}}' }) }) - it('contains an incomplete thrown settlement and leaves later calls available', async () => { - const registry = new ResolvedSecretTraceRegistry() + it('preserves empty thrown errors instead of replacing their runtime semantics', async () => { + const secret = '!' + const registry = new ResolvedSecretTraceRegistry([ + { name: 'API_KEY', plaintext: secret, encryptedValue: 'encrypted-value' }, + ]) global.fetch = Object.assign( vi.fn().mockResolvedValue( new Response( JSON.stringify({ success: false, - error: 'untrusted thrown detail', - __resolvedSecretTraceProvenance: { version: 1, complete: true, entries: [] }, + error: 'request failed', + __resolvedSecretNames: ['API_KEY'], }), { status: 500, headers: { 'content-type': 'application/json', - 'x-sim-private-tool-metadata': 'resolved-secret-provenance-v1', + 'x-sim-private-tool-metadata': 'resolved-secret-names-v1', }, } ) ), { preconnect: vi.fn() } ) as typeof fetch + const originalError = new Error('') mockToolsLogger.error.mockImplementation(() => { - throw new Error('untrusted thrown detail') + throw originalError }) - const result = await executeTool( + const execution = executeTool( 'function_execute', - { code: 'throw new Error("untrusted thrown detail")', envVars: {} }, + { code: 'throw new Error({{API_KEY}})', envVars: { API_KEY: secret } }, { resolvedSecretTraceRegistry: registry } ) - expect(result).toEqual({ - success: false, - output: {}, - error: 'Internal tool response metadata could not be verified', + await expect(execution).rejects.toBe(originalError) + expect(registry.getActiveMatches()).toEqual([{ plaintext: secret, replacement: '{{API_KEY}}' }]) + }) + + it('does not rewrite coincidental low-entropy matches in thrown runtime errors', async () => { + const secret = 'x' + const registry = new ResolvedSecretTraceRegistry([ + { name: 'API_KEY', plaintext: secret, encryptedValue: 'encrypted-value' }, + ]) + global.fetch = Object.assign( + vi.fn().mockResolvedValue( + new Response( + JSON.stringify({ + success: false, + error: secret, + __resolvedSecretNames: ['API_KEY'], + }), + { + status: 500, + headers: { + 'content-type': 'application/json', + 'x-sim-private-tool-metadata': 'resolved-secret-names-v1', + }, + } + ) + ), + { preconnect: vi.fn() } + ) as typeof fetch + const originalError = new Error('Box failed') + mockToolsLogger.error.mockImplementation(() => { + throw originalError }) - expect(registry.isComplete()).toBe(true) - mockToolsLogger.error.mockReset() - vi.mocked(global.fetch).mockResolvedValueOnce( - new Response(JSON.stringify({ success: true, output: { result: 'later call succeeded' } }), { - status: 200, - headers: { 'content-type': 'application/json' }, - }) - ) - const laterResult = await executeTool( + const execution = executeTool( 'function_execute', - { code: 'return "later call succeeded"', envVars: {} }, + { code: 'throw new Error({{API_KEY}})', envVars: { API_KEY: secret } }, { resolvedSecretTraceRegistry: registry } ) - expect(laterResult.success).toBe(true) - expect(registry.isComplete()).toBe(true) + await expect(execution).rejects.toBe(originalError) + expect(originalError.message).toBe('Box failed') + expect(registry.getActiveMatches()).toEqual([{ plaintext: secret, replacement: '{{API_KEY}}' }]) }) - it('does not start a private-provenance call from a permanently incomplete parent', async () => { + it('rethrows a local failure after authenticated lineage becomes unavailable', async () => { + const registry = new ResolvedSecretTraceRegistry([], { + userId: 'parent-user', + workspaceId: 'workspace-456', + }) + global.fetch = Object.assign( + vi.fn().mockResolvedValue( + new Response( + JSON.stringify({ + success: false, + error: 'untrusted thrown detail', + __resolvedSecretTraceProvenance: { + version: 1, + complete: false, + entries: [], + scope: { userId: 'parent-user', workspaceId: 'workspace-456' }, + }, + }), + { + status: 500, + headers: { + 'content-type': 'application/json', + 'x-sim-private-tool-metadata': 'resolved-secret-provenance-v1', + }, + } + ) + ), + { preconnect: vi.fn() } + ) as typeof fetch + mockToolsLogger.error.mockImplementation(() => { + throw new Error('untrusted thrown detail') + }) + + const execution = executeTool( + 'workflow_executor_child-workflow', + { workflowId: 'child-workflow', inputMapping: {} }, + { + executionContext: createToolExecutionContext({ userId: 'parent-user' }), + resolvedSecretTraceRegistry: registry, + } + ) + + await expect(execution).rejects.toThrow('untrusted thrown detail') + expect(registry.isComplete()).toBe(false) + }) + + it('runs a private-provenance call from an incomplete parent without replacing its result', async () => { const registry = new ResolvedSecretTraceRegistry() registry.markIncomplete() const fetchMock = vi.mocked(global.fetch) @@ -1878,19 +1933,16 @@ describe('executeTool Function', () => { { resolvedSecretTraceRegistry: registry } ) - expect(result).toEqual({ - success: false, - output: {}, - error: 'Internal tool response metadata could not be verified', - }) - expect(fetchMock).not.toHaveBeenCalled() + expect(result.success).toBe(true) + expect(registry.isComplete()).toBe(false) + expect(fetchMock).toHaveBeenCalled() }) - it('does not start a private-provenance call when its input cannot be bounded', async () => { + it('runs a private-provenance call when its input lineage cannot be bounded', async () => { const registry = new ResolvedSecretTraceRegistry() const incompleteToolRegistry = registry.forkForToolCall() incompleteToolRegistry.markIncomplete() - vi.spyOn(registry, 'forkForToolInputValues').mockReturnValue(incompleteToolRegistry) + vi.spyOn(registry, 'forkForInputPaths').mockReturnValue(incompleteToolRegistry) const fetchMock = vi.mocked(global.fetch) const result = await executeTool( @@ -1899,13 +1951,9 @@ describe('executeTool Function', () => { { resolvedSecretTraceRegistry: registry } ) - expect(result).toEqual({ - success: false, - output: {}, - error: 'Internal tool response metadata could not be verified', - }) - expect(registry.isComplete()).toBe(true) - expect(fetchMock).not.toHaveBeenCalled() + expect(result.success).toBe(true) + expect(registry.isComplete()).toBe(false) + expect(fetchMock).toHaveBeenCalled() }) it('should handle non-existent tool', async () => { @@ -2102,7 +2150,8 @@ describe('Automatic Internal Route Detection', () => { encryptedValue: 'encrypted-unused-secret', }, ]) - registry.recordResolved('PROMPT_TOKEN', 'prompt-secret') + registry.recordResolvedAtInputPath('PROMPT_TOKEN', 'prompt-secret', ['prompt']) + registry.recordResolvedInputProjection(['prompt'], 'prompt-secret', '{{PROMPT_TOKEN}}') const mockTool = { id: 'test_internal_model_tool', name: 'Test Internal Model Tool', @@ -2115,7 +2164,7 @@ describe('Automatic Internal Route Detection', () => { headers: () => ({ 'Content-Type': 'application/json' }), modelInput: { mode: 'private-provenance' as const, - select: (params: { prompt: string }) => params.prompt, + inputPaths: () => [['prompt']], }, body: (params: { prompt: string }) => ({ prompt: params.prompt }), }, @@ -2170,7 +2219,7 @@ describe('Automatic Internal Route Detection', () => { headers: () => ({ 'Content-Type': 'application/json' }), modelInput: { mode: 'private-provenance' as const, - select: (params: { query?: string }) => params.query, + inputPaths: (params: { query?: string }) => (params.query ? [['query']] : []), }, body: (params: { query?: string }) => ({ query: params.query }), }, @@ -2229,9 +2278,16 @@ describe('Automatic Internal Route Detection', () => { encryptedValue: 'encrypted-synthetic-index-collision', }, ]) - registry.recordResolved('MODEL_SECRET', 'prompt') - registry.recordResolved('UNRELATED_SECRET', 'unrelated-secret') - registry.recordResolved('SYNTHETIC_INDEX_COLLISION', '0') + registry.recordResolvedAtInputPath('MODEL_SECRET', 'prompt', ['prompt']) + registry.recordResolvedInputProjection(['prompt'], 'prompt', '{{MODEL_SECRET}}') + registry.recordResolvedAtInputPath('UNRELATED_SECRET', 'unrelated-secret', ['transport']) + registry.recordResolvedInputProjection( + ['transport'], + 'unrelated-secret', + '{{UNRELATED_SECRET}}' + ) + registry.recordResolvedAtInputPath('SYNTHETIC_INDEX_COLLISION', '0', ['unused']) + registry.recordResolvedInputProjection(['unused'], '0', '{{SYNTHETIC_INDEX_COLLISION}}') const mockTool = { id: 'test_internal_projected_model_tool', name: 'Test Internal Projected Model Tool', @@ -2307,8 +2363,14 @@ describe('Automatic Internal Route Detection', () => { encryptedValue: 'encrypted-file-secret', }, ]) - registry.recordResolved('PROMPT_SECRET', 'prompt-secret') - registry.recordResolved('FILE_SECRET', 'file-secret') + registry.recordResolvedAtInputPath('PROMPT_SECRET', 'prompt-secret', ['prompt']) + registry.recordResolvedInputProjection(['prompt'], 'prompt-secret', '{{PROMPT_SECRET}}') + registry.recordResolvedAtInputPath('FILE_SECRET', 'file-secret', ['fileUrl']) + registry.recordResolvedInputProjection( + ['fileUrl'], + 'https://files.example/file-secret', + 'https://files.example/{{FILE_SECRET}}' + ) const mockTool = { id: 'test_internal_mixed_model_tool', name: 'Test Internal Mixed Model Tool', @@ -2326,7 +2388,7 @@ describe('Automatic Internal Route Detection', () => { modelInput: { mode: 'project' as const, select: (params: { prompt: string }) => ({ prompt: params.prompt }), - privateProvenance: (params: { fileUrl: string }) => ({ fileUrl: params.fileUrl }), + privateInputPaths: () => [['fileUrl']], }, body: (params: { prompt: string; fileUrl: string; apiKey: string }) => ({ prompt: params.prompt, @@ -2386,7 +2448,17 @@ describe('Automatic Internal Route Detection', () => { encryptedValue: 'encrypted-nested-secret', }, ]) - registry.recordResolved('NESTED_SECRET', 'nested-secret') + registry.recordResolvedAtInputPath('NESTED_SECRET', 'nested-secret', [ + 'payload', + 'items', + '0', + 'prompt', + ]) + registry.recordResolvedInputProjection( + ['payload', 'items', '0', 'prompt'], + 'nested-secret', + '{{NESTED_SECRET}}' + ) const mockTool = { id: 'test_nested_projected_model_tool', name: 'Test Nested Projected Model Tool', @@ -2474,7 +2546,12 @@ describe('Automatic Internal Route Detection', () => { encryptedValue: 'encrypted-nested-secret', }, ]) - registry.recordResolved('NESTED_SECRET', 'nested-secret') + registry.recordResolvedAtInputPath('NESTED_SECRET', 'nested-secret', ['payload', 'prompt']) + registry.recordResolvedInputProjection( + ['payload', 'prompt'], + 'nested-secret', + '{{NESTED_SECRET}}' + ) const body = vi.fn() const mockTool = { id: 'test_invalid_nested_projected_model_tool', @@ -2528,7 +2605,8 @@ describe('Automatic Internal Route Detection', () => { encryptedValue: 'encrypted-external-secret', }, ]) - registry.recordResolved('PROMPT_SECRET', 'external-secret') + registry.recordResolvedAtInputPath('PROMPT_SECRET', 'external-secret', ['prompt']) + registry.recordResolvedInputProjection(['prompt'], 'external-secret', '{{PROMPT_SECRET}}') const mockTool = { id: 'test_external_projected_model_tool', name: 'Test External Projected Model Tool', @@ -2572,7 +2650,8 @@ describe('Automatic Internal Route Detection', () => { const registry = new ResolvedSecretTraceRegistry([ { name: 'OPAQUE_URL', plaintext: secret, encryptedValue: 'encrypted-opaque-secret' }, ]) - registry.recordResolved('OPAQUE_URL', secret) + registry.recordResolvedAtInputPath('OPAQUE_URL', secret, ['payload']) + registry.recordResolvedInputProjection(['payload'], secret, '{{OPAQUE_URL}}') const url = vi.fn(() => 'https://api.example.com/opaque') const headers = vi.fn(() => ({ 'Content-Type': 'application/json' })) const body = vi.fn((params: { payload: unknown }) => ({ payload: params.payload })) @@ -2585,7 +2664,7 @@ describe('Automatic Internal Route Detection', () => { request: { opaqueModelInput: { mode: 'reject-resolved-secrets' as const, - select: (params: { payload: unknown }) => params.payload, + inputPaths: () => [['payload']], }, url, method: 'POST' as const, @@ -2629,7 +2708,7 @@ describe('Automatic Internal Route Detection', () => { request: { opaqueModelInput: { mode: 'reject-resolved-secrets' as const, - select: (params: { payload: unknown }) => params.payload, + inputPaths: () => [['payload']], }, url: '', method: 'POST' as const, @@ -2657,7 +2736,7 @@ describe('Automatic Internal Route Detection', () => { }) it('preserves legacy opaque execution when no provenance registry exists', async () => { - const select = vi.fn((params: { payload: unknown }) => params.payload) + const inputPaths = vi.fn(() => [['payload']]) const directExecution = vi .fn() .mockResolvedValue({ success: true, output: { payload: 'legacy-value' } }) @@ -2668,7 +2747,7 @@ describe('Automatic Internal Route Detection', () => { version: '1.0.0', params: { payload: { type: 'string', required: true } }, request: { - opaqueModelInput: { mode: 'reject-resolved-secrets' as const, select }, + opaqueModelInput: { mode: 'reject-resolved-secrets' as const, inputPaths }, url: '', method: 'POST' as const, headers: () => ({}), @@ -2683,7 +2762,7 @@ describe('Automatic Internal Route Detection', () => { }) expect(result.success).toBe(true) - expect(select).not.toHaveBeenCalled() + expect(inputPaths).not.toHaveBeenCalled() expect(directExecution).toHaveBeenCalledWith({ payload: 'legacy-value' }, undefined) } finally { Reflect.deleteProperty(tools, 'test_legacy_direct_opaque_model_tool') @@ -2703,7 +2782,7 @@ describe('Automatic Internal Route Detection', () => { request: { opaqueModelInput: { mode: 'reject-resolved-secrets' as const, - select: () => undefined, + inputPaths: () => [], }, url: '', method: 'POST' as const, @@ -2731,7 +2810,8 @@ describe('Automatic Internal Route Detection', () => { const registry = new ResolvedSecretTraceRegistry([ { name: 'LOW_ENTROPY', plaintext: 'true', encryptedValue: 'encrypted-low-entropy' }, ]) - registry.recordResolved('LOW_ENTROPY', 'true') + registry.recordResolvedAtInputPath('LOW_ENTROPY', 'true', ['ordinary']) + registry.recordResolvedInputProjection(['ordinary'], 'true', '{{LOW_ENTROPY}}') const opaquePayload = 'quote" slash\\ newline\n123' const mockTool = { id: 'test_safe_external_opaque_model_tool', @@ -2745,7 +2825,7 @@ describe('Automatic Internal Route Detection', () => { request: { opaqueModelInput: { mode: 'reject-resolved-secrets' as const, - select: (params: { payload: string }) => params.payload, + inputPaths: () => [['payload']], }, url: 'https://api.example.com/safe-opaque', method: 'POST' as const, @@ -2790,7 +2870,8 @@ describe('Automatic Internal Route Detection', () => { encryptedValue: 'encrypted-direct-secret', }, ]) - registry.recordResolved('PROMPT_SECRET', 'direct-secret') + registry.recordResolvedAtInputPath('PROMPT_SECRET', 'direct-secret', ['prompt']) + registry.recordResolvedInputProjection(['prompt'], 'direct-secret', '{{PROMPT_SECRET}}') const directExecution = vi.fn().mockResolvedValue({ success: true, output: { ok: true } }) const postProcess = vi.fn( async (result: { success: boolean; output: { ok: boolean } }) => result @@ -2945,7 +3026,7 @@ describe('Automatic Internal Route Detection', () => { headers: () => ({ 'Content-Type': 'application/json' }), modelInput: { mode: 'private-provenance' as const, - select: (params: { prompt: string }) => params.prompt, + inputPaths: () => [['prompt']], }, body, }, @@ -3606,14 +3687,14 @@ describe('Copilot OAuth Credential Enforcement', () => { describe('Copilot Env Variable Reference Resolution', () => { let cleanupEnvVars: () => void - function mockJsonFetch() { + function mockJsonFetch(data: Record = { ok: true }) { const fetchMock = vi.fn().mockResolvedValue({ ok: true, status: 200, statusText: 'OK', headers: new Headers(), - json: () => Promise.resolve({ ok: true }), - text: () => Promise.resolve(JSON.stringify({ ok: true })), + json: () => Promise.resolve(data), + text: () => Promise.resolve(JSON.stringify(data)), clone: vi.fn().mockReturnThis(), }) global.fetch = Object.assign(fetchMock, { preconnect: vi.fn() }) as typeof fetch @@ -3657,6 +3738,49 @@ describe('Copilot Env Variable Reference Resolution', () => { expect(sentRequestBody(fetchMock).apiKey).toBe('sntrys_real_token') }) + it('keeps direct integration execution raw while projecting only its active workspace secret', async () => { + const activeSecret = 'x' + const unusedSecret = 'true' + mockGetEffectiveDecryptedEnv.mockResolvedValueOnce({ + SERPER_API_KEY: activeSecret, + UNUSED_SECRET: unusedSecret, + }) + const fetchMock = mockJsonFetch({ + reflected: activeSecret, + ordinary: unusedSecret, + }) + const registry = new ResolvedSecretTraceRegistry([ + { + name: 'SERPER_API_KEY', + plaintext: activeSecret, + encryptedValue: 'encrypted-active', + }, + { + name: 'UNUSED_SECRET', + plaintext: unusedSecret, + encryptedValue: 'encrypted-unused', + }, + ]) + const callerParams = { apiKey: '{{SERPER_API_KEY}}' } + + const result = await executeTool('test_env_ref_tool', callerParams, { + executionContext: copilotContext(), + resolvedSecretTraceRegistry: registry, + }) + + expect(result).toMatchObject({ + success: true, + output: { reflected: activeSecret, ordinary: unusedSecret }, + }) + expect(callerParams).toEqual({ apiKey: '{{SERPER_API_KEY}}' }) + expect(mockGetEffectiveDecryptedEnv).toHaveBeenCalledWith('user-123', 'workspace-456') + expect(sentRequestBody(fetchMock).apiKey).toBe(activeSecret) + expect(projectToolResultForCopilot(result, registry)).toMatchObject({ + success: true, + output: { reflected: '{{SERPER_API_KEY}}', ordinary: unusedSecret }, + }) + }) + it('does not let a pending user-only reference affect an unrelated result', async () => { const secret = 'sntrys_real_token' const registry = new ResolvedSecretTraceRegistry([ @@ -4303,7 +4427,7 @@ describe('MCP Tool Execution', () => { expect(registry.isComplete()).toBe(true) }) - it('drops a legacy MCP response without poisoning later provenance', async () => { + it('preserves a headerless legacy MCP response without poisoning later calls', async () => { const registry = new ResolvedSecretTraceRegistry() global.fetch = Object.assign( vi.fn().mockResolvedValue( @@ -4327,11 +4451,8 @@ describe('MCP Tool Execution', () => { } ) - expect(result).toMatchObject({ - success: false, - error: 'Internal tool response metadata could not be verified', - }) - expect(JSON.stringify(result)).not.toContain('legacy output') + expect(result.success).toBe(true) + expect(JSON.stringify(result)).toContain('legacy output') expect(registry.isComplete()).toBe(true) }) diff --git a/apps/sim/tools/index.ts b/apps/sim/tools/index.ts index ca5851a73c2..fcc55bd68df 100644 --- a/apps/sim/tools/index.ts +++ b/apps/sim/tools/index.ts @@ -63,11 +63,12 @@ import { isCustomTool, isMcpTool } from '@/executor/constants' import { resolveSkillContent } from '@/executor/handlers/agent/skills-resolver' import type { ExecutionContext, UserFile } from '@/executor/types' import { resolveEnvVarReferences } from '@/executor/utils/reference-validation' +import { projectResolvedSecretDiagnosticContent } from '@/executor/utils/resolved-secret-content-projection' import { - projectResolvedSecretDiagnosticContent, - projectResolvedSecretModelControlMessage, -} from '@/executor/utils/resolved-secret-content-projection' -import type { ResolvedSecretTraceRegistry } from '@/executor/utils/resolved-secret-trace-registry' + isResolvedSecretTraceProvenanceV1, + type ResolvedSecretInputPath, + type ResolvedSecretTraceRegistry, +} from '@/executor/utils/resolved-secret-trace-registry' import type { ErrorInfo } from '@/tools/error-extractors' import { extractErrorMessage } from '@/tools/error-extractors' import { HostedKeyRateLimitedError, HostedKeyUnavailableError } from '@/tools/errors' @@ -105,17 +106,17 @@ function assertOpaqueToolModelInputSafe( throw new Error(OPAQUE_MODEL_INPUT_PROVENANCE_UNAVAILABLE_ERROR) } - let selection: unknown + let inputPaths: readonly ResolvedSecretInputPath[] try { - selection = opaqueModelInput.select(params) + inputPaths = opaqueModelInput.inputPaths(params) } catch { throw new Error(OPAQUE_MODEL_INPUT_PROVENANCE_UNAVAILABLE_ERROR) } - if (selection === undefined) return + if (inputPaths.length === 0) return let provenance try { - provenance = registry.exportCommittedProvenanceForValue(selection) + provenance = registry.exportCommittedProvenanceForInputPaths(inputPaths) } catch { throw new Error(OPAQUE_MODEL_INPUT_PROVENANCE_UNAVAILABLE_ERROR) } @@ -131,11 +132,10 @@ function projectToolLogMetadata( metadata: Record, registry: ResolvedSecretTraceRegistry | undefined, structuralFallback: Record, - structuralOnlyWithoutRegistry = false + structuralOnly = false ): Record { - if (!registry) { - return structuralOnlyWithoutRegistry ? { ...structuralFallback, redacted: true } : metadata - } + if (structuralOnly) return { ...structuralFallback, redacted: true } + if (!registry) return metadata const projection = projectResolvedSecretDiagnosticContent(metadata, registry) return projection.safe && isPlainRecord(projection.value) @@ -333,7 +333,9 @@ async function resolveCopilotEnvReferences( allowEmbedded: false, missingKeys, onResolved: (name, resolvedValue) => { - resolvedSecretTraceRegistry?.recordResolved(name, resolvedValue) + resolvedSecretTraceRegistry?.recordResolvedAtInputPath(name, resolvedValue, [paramId], { + propagated: true, + }) }, }) if (missingKeys.length > 0) { @@ -346,6 +348,11 @@ async function resolveCopilotEnvReferences( ) } params[paramId] = resolved as string + resolvedSecretTraceRegistry?.recordResolvedInputProjection( + [paramId], + resolved as string, + value + ) } } finally { completePendingActivation?.() @@ -1158,7 +1165,7 @@ interface PrivateToolMetadataPolicy { incomplete: 'reject' | 'propagate' } -type PrivateToolMetadataConsumption = 'verified' | 'unsupported' | 'invalid' +type PrivateToolMetadataConsumption = 'verified' | 'incomplete' | 'invalid' function getFunctionExportedWorkspaceFileIds(payload: Record): string[] { const ids = new Set() @@ -1187,35 +1194,33 @@ function consumeResolvedSecretNames( payload: unknown, params: Record, registry?: ResolvedSecretTraceRegistry -): void { - if (!payload || typeof payload !== 'object' || Array.isArray(payload)) return +): boolean { + if (!payload || typeof payload !== 'object' || Array.isArray(payload)) return false const response = payload as Record - if (!Object.hasOwn(response, RESOLVED_SECRET_NAMES_FIELD)) return + if (!Object.hasOwn(response, RESOLVED_SECRET_NAMES_FIELD)) return false const names = response[RESOLVED_SECRET_NAMES_FIELD] response[RESOLVED_SECRET_NAMES_FIELD] = undefined - if (!registry) return - if (!Array.isArray(names) || !names.every((name) => typeof name === 'string')) { - registry.markIncomplete() - return + return false } const envVars = params.envVars if (!envVars || typeof envVars !== 'object' || Array.isArray(envVars)) { - registry.markIncomplete() - return + return false } + const targetRegistry = registry?.forkForToolCall() for (const name of names) { const value = (envVars as Record)[name] - if (typeof value !== 'string') { - registry.markIncomplete() - continue + if (typeof value !== 'string') return false + if (targetRegistry && !targetRegistry.recordResolved(name, value, { propagated: true })) { + return false } - registry.recordResolved(name, value) } + if (registry && targetRegistry) registry.mergeToolCallRegistry(targetRegistry) + return true } async function consumeResolvedSecretProvenance( @@ -1229,9 +1234,15 @@ async function consumeResolvedSecretProvenance( const provenance = response[RESOLVED_SECRET_PROVENANCE_FIELD] response[RESOLVED_SECRET_PROVENANCE_FIELD] = undefined - if (registry) { - await registry.importCrossingProvenance(provenance, response, { trusted: true }) - } + if (!isResolvedSecretTraceProvenanceV1(provenance)) return false + if (!registry) return true + + const targetRegistry = registry.forkForToolCall() + const imported = await targetRegistry.importCrossingProvenance(provenance, response, { + trusted: true, + }) + if (!imported) return false + registry.mergeToolCallRegistry(targetRegistry) return true } @@ -1276,8 +1287,7 @@ async function consumePrivateToolPayloadMetadata( headers: Headers, requestedType: PrivateToolMetadataType | undefined, params: Record, - registry?: ResolvedSecretTraceRegistry, - incomplete: 'reject' | 'propagate' = 'reject' + registry?: ResolvedSecretTraceRegistry ): Promise { if (!requestedType) return 'verified' @@ -1300,13 +1310,9 @@ async function consumePrivateToolPayloadMetadata( RESOLVED_SECRET_NAMES_METADATA_V1 ) if (legacyInspection.status !== 'verified') return 'invalid' - consumeResolvedSecretNames(record, params, registry) + if (!consumeResolvedSecretNames(record, params, registry)) return 'invalid' } else { - if (!registry) return 'unsupported' - const reconstructed = registry.exportCatalogProvenanceForValue(record) - if (!reconstructed.complete) return 'invalid' - const imported = await registry.importProvenance(reconstructed, { trusted: true }) - if (!imported) return 'invalid' + if (inspection.status !== 'unsupported') return 'invalid' } const fileIds = getFunctionExportedWorkspaceFileIds(record) @@ -1321,25 +1327,15 @@ async function consumePrivateToolPayloadMetadata( await markWorkspaceFileSecretProvenanceUnknown(workspaceId, fileIds) } record[RESOLVED_SECRET_NAMES_FIELD] = undefined - return registry?.isPermanentlyIncomplete() && incomplete === 'reject' ? 'invalid' : 'verified' + return registry?.isPermanentlyIncomplete() ? 'incomplete' : 'verified' } } if (inspection.status === 'unsupported') { - if (requestedType !== RESOLVED_SECRET_NAMES_METADATA_V1 || !record || !registry) { - return 'unsupported' - } - const reconstructed = registry.exportCatalogProvenanceForValue(record) - if (!reconstructed.complete) { - registry.markIncomplete() - return 'invalid' - } - const imported = await registry.importProvenance(reconstructed, { trusted: true }) - return imported ? 'verified' : 'invalid' + return 'verified' } if (inspection.status === 'invalid' || !record) { - registry?.markIncomplete() return 'invalid' } @@ -1348,26 +1344,24 @@ async function consumePrivateToolPayloadMetadata( requestedType === RESOLVED_SECRET_NAMES_METADATA_V1 || requestedType === RESOLVED_SECRET_NAMES_DURABLE_FILES_METADATA_V2 ) { - consumeResolvedSecretNames(record, params, registry) + if (!consumeResolvedSecretNames(record, params, registry)) return 'invalid' } else { - await consumeResolvedSecretProvenance(record, registry) + if (!(await consumeResolvedSecretProvenance(record, registry))) return 'invalid' } } catch { - registry?.markIncomplete() return 'invalid' } record[RESOLVED_SECRET_NAMES_FIELD] = undefined record[RESOLVED_SECRET_PROVENANCE_FIELD] = undefined - return registry?.isPermanentlyIncomplete() && incomplete === 'reject' ? 'invalid' : 'verified' + return registry?.isPermanentlyIncomplete() ? 'incomplete' : 'verified' } async function consumePrivateToolResponseMetadata( response: Response, requestedType: PrivateToolMetadataType | undefined, params: Record, - registry?: ResolvedSecretTraceRegistry, - incomplete: 'reject' | 'propagate' = 'reject' + registry?: ResolvedSecretTraceRegistry ): Promise { if (!requestedType) return { response } @@ -1380,8 +1374,10 @@ async function consumePrivateToolResponseMetadata( undefined, requestedType ) - if (inspection.status === 'invalid') registry?.markIncomplete() - return { response: rebuildSafePrivateToolResponse(response) } + if (inspection.status === 'invalid') { + return { response: rebuildSafePrivateToolResponse(response) } + } + return { response } } const consumption = await consumePrivateToolPayloadMetadata( @@ -1389,13 +1385,15 @@ async function consumePrivateToolResponseMetadata( response.headers, requestedType, params, - registry, - incomplete + registry ) - if (consumption !== 'verified') { + if (consumption === 'invalid') { return { response: rebuildSafePrivateToolResponse(response) } } + if (payload === null || typeof payload !== 'object' || Array.isArray(payload)) { + return { response } + } return { response: rebuildResponseWithoutPrivateToolMetadata( response, @@ -1419,19 +1417,10 @@ function getPrivateToolMetadataPolicy(toolId: string): PrivateToolMetadataPolicy return undefined } -function createPrivateProvenanceFailure(timing?: ToolResponse['timing']): ToolResponse { - return { - success: false, - output: {}, - error: PRIVATE_TOOL_METADATA_ERROR_MESSAGE, - ...(timing ? { timing } : {}), - } -} - /** - * Runs private-provenance tools against an isolated registry and commits only a complete - * settlement. Invalid metadata therefore fails this call without poisoning the workflow's - * parent registry, while valid success and error provenance remain available to later blocks. + * Runs private-provenance tools against an isolated registry. Unavailable authenticated lineage + * marks the parent unknown without replacing the tool's functional result; malformed metadata is + * rejected inside the transport consumer and never committed to the parent. */ export async function executeTool( toolId: string, @@ -1447,16 +1436,12 @@ export async function executeTool( if (privateMetadataPolicy.incomplete === 'propagate') { return executeToolImplementation(toolId, params, options) } - if (parentRegistry.isPermanentlyIncomplete()) { - return createPrivateProvenanceFailure() - } const paramEntries = getOwnEnumerableDataEntries(params) - if (!paramEntries) return createPrivateProvenanceFailure() - const toolRegistry = parentRegistry.forkForToolInputValues(paramEntries.map(([, value]) => value)) - if (!toolRegistry.isComplete()) { - return createPrivateProvenanceFailure() - } + const toolRegistry = paramEntries + ? parentRegistry.forkForInputPaths(paramEntries.map(([key]) => [key] as const)) + : parentRegistry.forkForToolCall() + if (!paramEntries) toolRegistry.markIncomplete() const executionContext = options.executionContext ? { ...options.executionContext, resolvedSecretTraceRegistry: toolRegistry } : undefined @@ -1468,35 +1453,8 @@ export async function executeTool( resolvedSecretTraceRegistry: toolRegistry, }) } catch (error) { - const errorName = - error && typeof error === 'object' && 'name' in error ? String(error.name) : undefined - if (!toolRegistry.isComplete()) { - if (errorName === 'AbortError' || errorName === 'APIUserAbortError') { - throw new DOMException(PRIVATE_TOOL_METADATA_ERROR_MESSAGE, 'AbortError') - } - return createPrivateProvenanceFailure() - } - - const projectedMessage = projectResolvedSecretModelControlMessage( - getErrorMessage(error, PRIVATE_TOOL_METADATA_ERROR_MESSAGE), - toolRegistry - ) - if (projectedMessage === undefined) { - if (errorName === 'AbortError' || errorName === 'APIUserAbortError') { - throw new DOMException(PRIVATE_TOOL_METADATA_ERROR_MESSAGE, 'AbortError') - } - return createPrivateProvenanceFailure() - } - parentRegistry.mergeToolCallRegistry(toolRegistry) - if (errorName === 'AbortError' || errorName === 'APIUserAbortError') { - throw new DOMException(projectedMessage, 'AbortError') - } - throw new Error(projectedMessage) - } - - if (!toolRegistry.isComplete()) { - return createPrivateProvenanceFailure(result.timing) + throw error } parentRegistry.mergeToolCallRegistry(toolRegistry) @@ -1534,8 +1492,13 @@ async function executeToolImplementation( const startTime = new Date() const startTimeISO = startTime.toISOString() const requestId = generateRequestId() + const privateToolMetadataPolicy = resolvedSecretTraceRegistry + ? getPrivateToolMetadataPolicy(toolId) + : undefined const structuralOnlyToolLogs = - normalizeToolId(toolId) === 'function_execute' || isCustomTool(toolId) + normalizeToolId(toolId) === 'function_execute' || + isCustomTool(toolId) || + privateToolMetadataPolicy !== undefined // Hoisted so the outer catch can attribute a thrown failure to the chosen key. let hostedKeyForMetrics: { provider: string; tool: string; key: string } | undefined @@ -1560,9 +1523,6 @@ async function executeToolImplementation( : isMcpTool(normalizedToolId) ? 'mcp' : undefined - const privateToolMetadataPolicy = resolvedSecretTraceRegistry - ? getPrivateToolMetadataPolicy(normalizedToolId) - : undefined const privateToolMetadataType = privateToolMetadataPolicy?.type if (resolvedSecretTraceRegistry && privateToolMetadataType) { @@ -1836,7 +1796,7 @@ async function executeToolImplementation( if ( tool.request.modelInput?.mode === 'private-provenance' || (tool.request.modelInput?.mode === 'project' && - tool.request.modelInput.privateProvenance !== undefined) + tool.request.modelInput.privateInputPaths !== undefined) ) { throw new Error(PRIVATE_MODEL_INPUT_DIRECT_EXECUTION_ERROR_MESSAGE) } @@ -1915,7 +1875,6 @@ async function executeToolImplementation( contextParams, effectiveSignal, privateToolMetadataType, - privateToolMetadataPolicy?.incomplete, resolvedSecretTraceRegistry, internalSandboxProfile ), @@ -1943,7 +1902,6 @@ async function executeToolImplementation( contextParams, effectiveSignal, privateToolMetadataType, - privateToolMetadataPolicy?.incomplete, resolvedSecretTraceRegistry, internalSandboxProfile ) @@ -1956,7 +1914,6 @@ async function executeToolImplementation( contextParams, effectiveSignal, privateToolMetadataType, - privateToolMetadataPolicy?.incomplete, resolvedSecretTraceRegistry, internalSandboxProfile ) @@ -2283,13 +2240,14 @@ async function executeToolRequest( params: Record, signal?: AbortSignal, privateToolMetadataType?: PrivateToolMetadataType, - privateToolMetadataIncomplete: 'reject' | 'propagate' = 'reject', resolvedSecretTraceRegistry?: ResolvedSecretTraceRegistry, internalSandboxProfile?: InternalSandboxProfile ): Promise { const requestId = generateRequestId() const structuralOnlyToolLogs = - normalizeToolId(toolId) === 'function_execute' || isCustomTool(toolId) + normalizeToolId(toolId) === 'function_execute' || + isCustomTool(toolId) || + privateToolMetadataType !== undefined try { const requestParams = prepareToolRequest(tool, params, resolvedSecretTraceRegistry) const endpointUrl = requestParams.url @@ -2593,8 +2551,7 @@ async function executeToolRequest( response, privateToolMetadataType, params, - resolvedSecretTraceRegistry, - privateToolMetadataIncomplete + resolvedSecretTraceRegistry ) response = privateMetadata.response @@ -2604,7 +2561,11 @@ async function executeToolRequest( toolId, signal, }) - response = new Response(new Uint8Array(functionalBody), { + const body = + response.status === 204 || response.status === 205 || response.status === 304 + ? null + : new Uint8Array(functionalBody) + response = new Response(body, { status: response.status, statusText: response.statusText, headers: cloneResponseHeaders(response.headers), @@ -3005,12 +2966,20 @@ async function executeMcpTool( mcpUrl.searchParams.set('userId', mcpScope.userId) } - const response = await fetch(mcpUrl.toString(), { + let response = await fetch(mcpUrl.toString(), { method: 'POST', headers, body, signal, }) + response = ( + await consumePrivateToolResponseMetadata( + response, + privateToolMetadataType, + params, + resolvedSecretTraceRegistry + ) + ).response const endTime = new Date() const endTimeISO = endTime.toISOString() @@ -3036,16 +3005,7 @@ async function executeMcpTool( try { const errorData = await response.json() - const metadataConsumption = await consumePrivateToolPayloadMetadata( - errorData, - response.headers, - privateToolMetadataType, - params, - resolvedSecretTraceRegistry - ) - if (metadataConsumption === 'verified' && errorData.error) { - errorMessage = errorData.error - } + if (errorData.error) errorMessage = errorData.error } catch { // Failed to parse error response, use default message } @@ -3063,26 +3023,6 @@ async function executeMcpTool( } const result = await response.json() - const metadataConsumption = await consumePrivateToolPayloadMetadata( - result, - response.headers, - privateToolMetadataType, - params, - resolvedSecretTraceRegistry - ) - if (metadataConsumption !== 'verified') { - return { - success: false, - output: {}, - error: PRIVATE_TOOL_METADATA_ERROR_MESSAGE, - timing: { - startTime: actualStartTime, - endTime: endTimeISO, - duration, - }, - } - } - if (!result.success) { return { success: false, diff --git a/apps/sim/tools/jina/read_url.ts b/apps/sim/tools/jina/read_url.ts index 5270d2587e6..c2395d9dfd1 100644 --- a/apps/sim/tools/jina/read_url.ts +++ b/apps/sim/tools/jina/read_url.ts @@ -107,8 +107,8 @@ export const readUrlTool: ToolConfig = { request: { opaqueModelInput: { mode: 'reject-resolved-secrets', - select: (params) => - params.useReaderLMv2 === true || params.withGeneratedAlt === true ? params.url : undefined, + inputPaths: (params) => + params.useReaderLMv2 === true || params.withGeneratedAlt === true ? [['url']] : [], }, url: (params: ReadUrlParams) => { return `https://r.jina.ai/https://${params.url.replace(/^https?:\/\//, '')}` diff --git a/apps/sim/tools/knowledge/knowledge.test.ts b/apps/sim/tools/knowledge/knowledge.test.ts index 2b17b6742dd..db1405b298a 100644 --- a/apps/sim/tools/knowledge/knowledge.test.ts +++ b/apps/sim/tools/knowledge/knowledge.test.ts @@ -28,26 +28,26 @@ function createMockResponse(data: unknown): Response { describe('Knowledge Tools', () => { it('uses private provenance for search and durable sidecars for persisted content', () => { expect( - knowledgeSearchTool.request.modelInput?.select({ + knowledgeSearchTool.request.modelInput?.inputPaths({ knowledgeBaseId: 'kb-1', query: 'search secret', apiKey: 'credential', tagFilters: [{ tagName: 'team', tagValue: 'support' }], }) - ).toBe('search secret') + ).toEqual([['query']]) expect( - knowledgeSearchTool.request.modelInput?.select({ + knowledgeSearchTool.request.modelInput?.inputPaths({ knowledgeBaseId: 'kb-1', tagFilters: [{ tagName: 'team', tagValue: 'support' }], }) - ).toBeUndefined() + ).toEqual([['query']]) expect( knowledgeUploadChunkTool.request.secretProvenance?.request?.({ knowledgeBaseId: 'kb-1', documentId: 'doc-1', content: 'chunk secret', }) - ).toEqual([{ key: 'chunk-content', value: 'chunk secret' }]) + ).toEqual([{ key: 'chunk-content', inputPaths: [['content']] }]) expect( knowledgeUpdateChunkTool.request.secretProvenance?.request?.({ knowledgeBaseId: 'kb-1', @@ -56,7 +56,7 @@ describe('Knowledge Tools', () => { content: 'updated secret', enabled: true, }) - ).toEqual([{ key: 'chunk-content', value: 'updated secret' }]) + ).toEqual([{ key: 'chunk-content', inputPaths: [['content']] }]) expect( knowledgeCreateDocumentTool.request.secretProvenance?.request?.({ knowledgeBaseId: 'kb-1', @@ -65,10 +65,9 @@ describe('Knowledge Tools', () => { documentTags: { team: 'support' }, }) ).toEqual([ - { key: 'document-filename:0', value: 'document.txt' }, - { key: 'document-content:0', value: 'document secret' }, - { key: 'document-tag-name:0:0', value: 'team' }, - { key: 'document-tag-value:0:0', value: 'support' }, + { key: 'document-filename:0', inputPaths: [['name']] }, + { key: 'document-content:0', inputPaths: [['content']] }, + { key: 'document-tag-value:0:0', inputPaths: [['documentTags', 'team']] }, ]) expect( knowledgeUpsertDocumentTool.request.secretProvenance?.request?.({ @@ -78,10 +77,9 @@ describe('Knowledge Tools', () => { documentTags: [{ tagName: 'team', value: 'support' }], }) ).toEqual([ - { key: 'document-filename:0', value: 'document.txt' }, - { key: 'document-content:0', value: 'replacement secret' }, - { key: 'document-tag-name:0:0', value: 'team' }, - { key: 'document-tag-value:0:0', value: 'support' }, + { key: 'document-filename:0', inputPaths: [['name']] }, + { key: 'document-content:0', inputPaths: [['content']] }, + { key: 'document-tag-value:0:0', inputPaths: [['documentTags', '0', 'value']] }, ]) expect(knowledgeSearchTool.request.modelInput?.mode).toBe('private-provenance') diff --git a/apps/sim/tools/knowledge/search.ts b/apps/sim/tools/knowledge/search.ts index ebc8aed6944..33f7cd3b6cd 100644 --- a/apps/sim/tools/knowledge/search.ts +++ b/apps/sim/tools/knowledge/search.ts @@ -89,7 +89,7 @@ export const knowledgeSearchTool: ToolConfig = { method: 'POST', modelInput: { mode: 'private-provenance', - select: (params) => params.query, + inputPaths: () => [['query']], }, secretProvenance: { response: { incomplete: 'reject' } }, headers: () => ({ diff --git a/apps/sim/tools/knowledge/secret-provenance.test.ts b/apps/sim/tools/knowledge/secret-provenance.test.ts index 889fe5d1f05..b31a7894ed7 100644 --- a/apps/sim/tools/knowledge/secret-provenance.test.ts +++ b/apps/sim/tools/knowledge/secret-provenance.test.ts @@ -5,7 +5,6 @@ import { describe, expect, it } from 'vitest' import { knowledgeDocumentContentSelectionKey, knowledgeDocumentFilenameSelectionKey, - knowledgeDocumentTagNameSelectionKey, knowledgeDocumentTagValueSelectionKey, parseKnowledgeDocumentTagProvenanceTargets, } from '@/lib/knowledge/secret-provenance-selection' @@ -18,10 +17,9 @@ function serverSelectionKeys(documentTags: unknown): string[] { return [ knowledgeDocumentFilenameSelectionKey(0), knowledgeDocumentContentSelectionKey(0), - ...parseKnowledgeDocumentTagProvenanceTargets(documentTagsData).flatMap((_tag, tagIndex) => [ - knowledgeDocumentTagNameSelectionKey(0, tagIndex), - knowledgeDocumentTagValueSelectionKey(0, tagIndex), - ]), + ...parseKnowledgeDocumentTagProvenanceTargets(documentTagsData).map((_tag, tagIndex) => + knowledgeDocumentTagValueSelectionKey(0, tagIndex) + ), ] } @@ -61,6 +59,48 @@ describe('selectKnowledgeDocumentWriteSecretProvenance', () => { }) expect(selections.map((selection) => selection.key)).toEqual(serverSelectionKeys(documentTags)) - expect(selections).toHaveLength(8) + expect(selections).toHaveLength(5) + expect(selections).toEqual([ + { key: 'document-filename:0', inputPaths: [['name']] }, + { key: 'document-content:0', inputPaths: [['content']] }, + { key: 'document-tag-value:0:0', inputPaths: [['documentTags', 'alpha']] }, + { key: 'document-tag-value:0:1', inputPaths: [['documentTags', 'beta']] }, + { key: 'document-tag-value:0:2', inputPaths: [['documentTags', 'gamma']] }, + ]) + }) + + it('selects array tag values without tracking persisted tag names', () => { + const selections = selectKnowledgeDocumentWriteSecretProvenance({ + name: 'doc.md', + content: 'content', + documentTags: [ + { tagName: 'team', value: 'support' }, + { tagName: 'region', value: 'west' }, + ], + }) + + expect(selections).toEqual([ + { key: 'document-filename:0', inputPaths: [['name']] }, + { key: 'document-content:0', inputPaths: [['content']] }, + { key: 'document-tag-value:0:0', inputPaths: [['documentTags', '0', 'value']] }, + { key: 'document-tag-value:0:1', inputPaths: [['documentTags', '1', 'value']] }, + ]) + }) + + it('uses the whole resolver leaf for JSON-string tag inputs', () => { + const documentTags = JSON.stringify([ + { tagName: 'team', value: 'support' }, + { tagName: 'region', value: 'west' }, + ]) + const selections = selectKnowledgeDocumentWriteSecretProvenance({ + name: 'doc.md', + content: 'content', + documentTags, + }) + + expect(selections.map((selection) => selection.key)).toEqual(serverSelectionKeys(documentTags)) + expect( + selections.slice(2).every(({ inputPaths }) => inputPaths[0]?.[0] === 'documentTags') + ).toBe(true) }) }) diff --git a/apps/sim/tools/knowledge/secret-provenance.ts b/apps/sim/tools/knowledge/secret-provenance.ts index 276517b10e7..bd820221e81 100644 --- a/apps/sim/tools/knowledge/secret-provenance.ts +++ b/apps/sim/tools/knowledge/secret-provenance.ts @@ -1,33 +1,68 @@ +import { isPlainRecord } from '@sim/utils/object' import type { PrivateSecretProvenanceSelection } from '@/lib/execution/model-input-provenance' import { knowledgeDocumentContentSelectionKey, knowledgeDocumentFilenameSelectionKey, - knowledgeDocumentTagNameSelectionKey, knowledgeDocumentTagValueSelectionKey, - parseKnowledgeDocumentTagProvenanceTargets, } from '@/lib/knowledge/secret-provenance-selection' -import { inferDocumentFileInfo } from '@/tools/knowledge/types' -import { formatDocumentTagsForAPI, parseDocumentTags } from '@/tools/shared/tags' +import { parseDocumentTags } from '@/tools/shared/tags' -/** Selects each causally independent persisted document field before request serialization. */ +function isPersistedTagCandidate(entry: unknown): entry is Record { + if (!isPlainRecord(entry)) return false + const tagName = entry.tagName + if (!tagName || (typeof tagName === 'string' && tagName.trim() === '')) return false + return entry.value !== undefined && entry.value !== null && entry.value !== '' +} + +function documentTagValueInputPaths( + value: unknown +): PrivateSecretProvenanceSelection['inputPaths'][] { + if (typeof value === 'string') { + try { + const parsed: unknown = JSON.parse(value) + if (!Array.isArray(parsed)) return [] + return parsed.flatMap((entry) => (isPersistedTagCandidate(entry) ? [[['documentTags']]] : [])) + } catch { + return [] + } + } + + if (Array.isArray(value)) { + return value.flatMap((entry, index) => + isPersistedTagCandidate(entry) ? [[['documentTags', String(index), 'value']]] : [] + ) + } + + if (value && typeof value === 'object') { + return Object.entries(value).flatMap(([tagName, tagValue]) => + tagName.trim() !== '' && tagValue !== undefined && tagValue !== null && tagValue !== '' + ? [[['documentTags', tagName]]] + : [] + ) + } + + return [] +} + +/** Selects durable fields the document sidecar can represent; tag names remain raw and untracked. */ export function selectKnowledgeDocumentWriteSecretProvenance(params: { name?: unknown content?: unknown documentTags?: unknown }): PrivateSecretProvenanceSelection[] { - const name = typeof params.name === 'string' ? params.name.trim() : '' - const content = typeof params.content === 'string' ? params.content.trim() : params.content - const filename = inferDocumentFileInfo(name).filename - const tags = parseKnowledgeDocumentTagProvenanceTargets( - formatDocumentTagsForAPI(parseDocumentTags(params.documentTags)).documentTagsData - ) + const tags = parseDocumentTags(params.documentTags) + const tagPaths = documentTagValueInputPaths(params.documentTags) + const persistedTagPaths = tagPaths.filter((_, index) => { + const tag = tags[index] + return tag !== undefined && tag.tagName.trim() !== '' && tag.value !== '' + }) return [ - { key: knowledgeDocumentFilenameSelectionKey(0), value: filename }, - { key: knowledgeDocumentContentSelectionKey(0), value: content }, - ...tags.flatMap((tag, tagIndex) => [ - { key: knowledgeDocumentTagNameSelectionKey(0, tagIndex), value: tag.tagName }, - { key: knowledgeDocumentTagValueSelectionKey(0, tagIndex), value: tag.value }, - ]), + { key: knowledgeDocumentFilenameSelectionKey(0), inputPaths: [['name']] }, + { key: knowledgeDocumentContentSelectionKey(0), inputPaths: [['content']] }, + ...persistedTagPaths.map((inputPaths, tagIndex) => ({ + key: knowledgeDocumentTagValueSelectionKey(0, tagIndex), + inputPaths, + })), ] } diff --git a/apps/sim/tools/knowledge/update_chunk.ts b/apps/sim/tools/knowledge/update_chunk.ts index e0de0164596..7333567bff6 100644 --- a/apps/sim/tools/knowledge/update_chunk.ts +++ b/apps/sim/tools/knowledge/update_chunk.ts @@ -46,7 +46,7 @@ export const knowledgeUpdateChunkTool: ToolConfig - params.content === undefined ? [] : [{ key: 'chunk-content', value: params.content }], + params.content === undefined ? [] : [{ key: 'chunk-content', inputPaths: [['content']] }], response: { incomplete: 'reject' }, }, headers: () => ({ diff --git a/apps/sim/tools/knowledge/upload_chunk.ts b/apps/sim/tools/knowledge/upload_chunk.ts index 85cf9ba79aa..5701bac0839 100644 --- a/apps/sim/tools/knowledge/upload_chunk.ts +++ b/apps/sim/tools/knowledge/upload_chunk.ts @@ -33,7 +33,7 @@ export const knowledgeUploadChunkTool: ToolConfig [{ key: 'chunk-content', value: params.content }], + request: () => [{ key: 'chunk-content', inputPaths: [['content']] }], response: { incomplete: 'reject' }, }, headers: () => ({ diff --git a/apps/sim/tools/llm/chat.test.ts b/apps/sim/tools/llm/chat.test.ts index b75374f4c69..f168af079fa 100644 --- a/apps/sim/tools/llm/chat.test.ts +++ b/apps/sim/tools/llm/chat.test.ts @@ -2,13 +2,24 @@ * @vitest-environment node */ import { describe, expect, it } from 'vitest' +import { + PRIVATE_MODEL_INPUT_PROVENANCE_HEADER, + PRIVATE_MODEL_INPUT_STATE_HEADER, + PROJECTED_MODEL_INPUT_PATHS_V1, +} from '@/lib/execution/model-input-provenance' +import { + RESOLVED_SECRET_PROVENANCE_FIELD, + RESOLVED_SECRET_PROVENANCE_METADATA_V1, +} from '@/lib/execution/private-tool-metadata' +import { ResolvedSecretTraceRegistry } from '@/executor/utils/resolved-secret-trace-registry' import { llmChatTool } from '@/tools/llm/chat' +import { prepareToolRequest } from '@/tools/request-transport' describe('llmChatTool.request.modelInput', () => { - it('delegates exact model-bound input provenance to the authenticated provider route', () => { + it('selects only prompt fields for exact model-facing projection', () => { const modelInput = llmChatTool.request.modelInput - expect(modelInput?.mode).toBe('private-provenance') - if (modelInput?.mode !== 'private-provenance') throw new Error('Unexpected model input mode') + expect(modelInput?.mode).toBe('project') + if (modelInput?.mode !== 'project') throw new Error('Unexpected model input mode') expect( modelInput.select({ @@ -17,6 +28,55 @@ describe('llmChatTool.request.modelInput', () => { context: 'user', apiKey: 'credential', }) - ).toEqual(['system', 'user']) + ).toEqual({ systemPrompt: 'system', context: 'user' }) + }) + + it('projects active prompt secrets before the provider route while preserving raw params', () => { + const registry = new ResolvedSecretTraceRegistry([ + { name: 'PROMPT_SECRET', plaintext: 'secret-value', encryptedValue: 'encrypted-value' }, + ]) + registry.recordResolvedAtInputPath('PROMPT_SECRET', 'secret-value', ['context']) + registry.recordResolvedInputProjection(['context'], 'secret-value', '{{PROMPT_SECRET}}') + const params = { + model: 'gpt-4o', + systemPrompt: 'ordinary system prompt', + context: 'secret-value', + apiKey: 'credential', + } + + const request = prepareToolRequest(llmChatTool, params, registry) + const body = JSON.parse(request.body ?? '{}') + + expect(body.systemPrompt).toBe('ordinary system prompt') + expect(JSON.parse(body.context)).toEqual([{ role: 'user', content: '{{PROMPT_SECRET}}' }]) + expect(body.apiKey).toBe('credential') + expect(request.headers.get(PRIVATE_MODEL_INPUT_PROVENANCE_HEADER)).toBe( + RESOLVED_SECRET_PROVENANCE_METADATA_V1 + ) + expect(request.headers.get(PRIVATE_MODEL_INPUT_STATE_HEADER)).toBe( + PROJECTED_MODEL_INPUT_PATHS_V1 + ) + expect(body[RESOLVED_SECRET_PROVENANCE_FIELD]).toEqual( + expect.objectContaining({ version: 1, complete: true }) + ) + expect(params.context).toBe('secret-value') + }) + + it('preserves the headerless legacy request when no registry is available', () => { + const request = prepareToolRequest(llmChatTool, { + model: 'gpt-4o', + systemPrompt: 'legacy system prompt', + context: 'legacy context', + apiKey: 'credential', + }) + + expect(request.headers.has(PRIVATE_MODEL_INPUT_PROVENANCE_HEADER)).toBe(false) + expect(request.headers.has(PRIVATE_MODEL_INPUT_STATE_HEADER)).toBe(false) + expect(JSON.parse(request.body ?? '{}')).toEqual( + expect.objectContaining({ + systemPrompt: 'legacy system prompt', + context: JSON.stringify([{ role: 'user', content: 'legacy context' }]), + }) + ) }) }) diff --git a/apps/sim/tools/llm/chat.ts b/apps/sim/tools/llm/chat.ts index ded07e2a632..9d4362be13a 100644 --- a/apps/sim/tools/llm/chat.ts +++ b/apps/sim/tools/llm/chat.ts @@ -134,8 +134,12 @@ export const llmChatTool: ToolConfig = { 'Content-Type': 'application/json', }), modelInput: { - mode: 'private-provenance', - select: (params) => [params.systemPrompt, params.context], + mode: 'project', + select: (params) => ({ + systemPrompt: params.systemPrompt, + context: params.context, + }), + privateInputPaths: () => [['systemPrompt'], ['context']], }, body: (params) => { const provider = getProviderFromModel(params.model) diff --git a/apps/sim/tools/memory/add.ts b/apps/sim/tools/memory/add.ts index 31ee2306a8a..e308b5b743f 100644 --- a/apps/sim/tools/memory/add.ts +++ b/apps/sim/tools/memory/add.ts @@ -40,7 +40,7 @@ export const memoryAddTool: ToolConfig = { url: '/api/memory', method: 'POST', secretProvenance: { - request: (params) => [{ key: 'data', value: { role: params.role, content: params.content } }], + request: () => [{ key: 'data', inputPaths: [['role'], ['content']] }], response: { incomplete: 'reject' }, }, headers: () => ({ diff --git a/apps/sim/tools/mistral/parser.ts b/apps/sim/tools/mistral/parser.ts index 096cbcd819a..86fa1a038c4 100644 --- a/apps/sim/tools/mistral/parser.ts +++ b/apps/sim/tools/mistral/parser.ts @@ -3,8 +3,8 @@ import { toError } from '@sim/utils/errors' import { generateRandomString } from '@sim/utils/random' import { isInternalFileUrl } from '@/lib/uploads/utils/file-utils' import { - selectModelBoundFileInput, - selectPreferredModelBoundFileInput, + selectModelBoundFileInputPaths, + selectPreferredModelBoundFileInputPaths, } from '@/lib/uploads/utils/model-input' import type { MistralParserInput, @@ -126,10 +126,12 @@ export const mistralParserTool: ToolConfig - selectPreferredModelBoundFileInput({ + inputPaths: (params) => + selectPreferredModelBoundFileInputPaths({ file: params.file && typeof params.file === 'object' ? params.file : params.fileUpload, filePath: params.filePath, + fileInputPath: params.file && typeof params.file === 'object' ? ['file'] : ['fileUpload'], + filePathInputPath: ['filePath'], prefer: 'path', includeInlineBase64: true, }), @@ -565,8 +567,8 @@ export const mistralParserV3Tool: ToolConfig - selectModelBoundFileInput(params.file, { + inputPaths: (params) => + selectModelBoundFileInputPaths(params.file, ['file'], { includeInlineBase64: true, }), }, diff --git a/apps/sim/tools/nested-model-input-adapters.test.ts b/apps/sim/tools/nested-model-input-adapters.test.ts index 700d9c6f637..d00aedc74e5 100644 --- a/apps/sim/tools/nested-model-input-adapters.test.ts +++ b/apps/sim/tools/nested-model-input-adapters.test.ts @@ -25,7 +25,7 @@ describe('nested model-input adapters', () => { } expect(modelInput.select({ [key]: text })).toStrictEqual({ [key]: text }) - expect(opaqueModelInput.select({ [key]: text })).toBeUndefined() + expect(opaqueModelInput.inputPaths({ [key]: text })).toEqual([]) } ) @@ -92,12 +92,7 @@ describe('nested model-input adapters', () => { promptText: 'Inspect this image', }) - expect(opaqueModelInput.select({ promptImages })).toStrictEqual([ - { - data: 'quote" slash\\ newline\n123 true', - dimension: { width: 100, height: 200 }, - }, - ]) + expect(opaqueModelInput.inputPaths({ promptImages })).toStrictEqual([['promptImages']]) const body = tool.request.body?.({ apiKey: 'key', @@ -105,9 +100,12 @@ describe('nested model-input adapters', () => { promptText: 'Inspect this image', promptImages, }) - expect((body as { prompt: { images: unknown } }).prompt.images).toStrictEqual( - opaqueModelInput.select({ promptImages }) - ) + expect((body as { prompt: { images: unknown } }).prompt.images).toStrictEqual([ + { + data: 'quote" slash\\ newline\n123 true', + dimension: { width: 100, height: 200 }, + }, + ]) } ) @@ -128,7 +126,7 @@ describe('nested model-input adapters', () => { promptImages: 'not-json', }) ).toStrictEqual({ followupPromptText: 'Continue' }) - expect(opaqueModelInput.select({ promptImages: 'not-json' })).toStrictEqual([]) + expect(opaqueModelInput.inputPaths({ promptImages: 'not-json' })).toStrictEqual([]) const body = tool.request.body?.({ apiKey: 'key', diff --git a/apps/sim/tools/opaque-model-input-selectors.test.ts b/apps/sim/tools/opaque-model-input-selectors.test.ts index 3d2cf642866..b4d4d5d4f0e 100644 --- a/apps/sim/tools/opaque-model-input-selectors.test.ts +++ b/apps/sim/tools/opaque-model-input-selectors.test.ts @@ -31,7 +31,7 @@ import { assemblyaiSttTool, assemblyaiSttV2Tool } from '@/tools/stt/assemblyai' import { deepgramSttTool, deepgramSttV2Tool } from '@/tools/stt/deepgram' import { elevenLabsSttTool, elevenLabsSttV2Tool } from '@/tools/stt/elevenlabs' import { geminiSttTool, geminiSttV2Tool } from '@/tools/stt/gemini' -import { selectSttAudioModelInput } from '@/tools/stt/model-input' +import { selectSttAudioModelInputPaths } from '@/tools/stt/model-input' import { whisperSttTool, whisperSttV2Tool } from '@/tools/stt/whisper' import { crawlTool as tavilyCrawlTool } from '@/tools/tavily/crawl' import { mapTool as tavilyMapTool } from '@/tools/tavily/map' @@ -42,25 +42,28 @@ import type { ToolConfig } from '@/tools/types' import { runwayVideoTool } from '@/tools/video/runway' import { visionTool } from '@/tools/vision/tool' -function selectOpaqueModelInput(tool: ToolConfig, params: Record): unknown { +function selectOpaqueModelInputPaths( + tool: ToolConfig, + params: Record +): readonly (readonly string[])[] { const modelInput = tool.request.modelInput if (!modelInput) throw new Error(`Missing model-input descriptor for ${tool.id}`) - if (modelInput.mode === 'private-provenance') return modelInput.select(params) - if (!modelInput.privateProvenance) { + if (modelInput.mode === 'private-provenance') return modelInput.inputPaths(params) + if (!modelInput.privateInputPaths) { throw new Error(`Missing private provenance selector for ${tool.id}`) } - return modelInput.privateProvenance(params) + return modelInput.privateInputPaths(params) } -function selectRejectedOpaqueModelInput( +function selectRejectedOpaqueModelInputPaths( tool: ToolConfig, params: Record -): unknown { +): readonly (readonly string[])[] { const opaqueModelInput = tool.request.opaqueModelInput if (!opaqueModelInput) throw new Error(`Missing opaque model-input descriptor for ${tool.id}`) expect(opaqueModelInput.mode).toBe('reject-resolved-secrets') - return opaqueModelInput.select(params) + return opaqueModelInput.inputPaths(params) } describe('opaque model-input selectors', () => { @@ -72,19 +75,19 @@ describe('opaque model-input selectors', () => { textractParserTool, ])('%s mirrors legacy path-first input precedence', (tool) => { expect( - selectOpaqueModelInput(tool, { + selectOpaqueModelInputPaths(tool, { filePath: ' https://example.com/effective.pdf ', file: { key: 'unused-file', metadata: 'unused-secret' }, fileUpload: { key: 'unused-upload', metadata: 'unused-secret' }, }) - ).toBe('https://example.com/effective.pdf') + ).toEqual([['filePath']]) }) it.each([extendParserV2Tool, pulseParserV2Tool, reductoParserV2Tool, textractParserV2Tool])( '%s selects only the effective locator from normalized files', (tool) => { expect( - selectOpaqueModelInput(tool, { + selectOpaqueModelInputPaths(tool, { file: { key: 'effective-key', path: 'unused-path', @@ -92,13 +95,13 @@ describe('opaque model-input selectors', () => { metadata: 'unused-secret', }, }) - ).toBeUndefined() + ).toEqual([]) } ) it('selects inline Mistral bytes without unrelated locators or metadata', () => { expect( - selectOpaqueModelInput(mistralParserV3Tool, { + selectOpaqueModelInputPaths(mistralParserV3Tool, { file: { base64: 'effective-bytes', key: 'unused-key', @@ -106,52 +109,50 @@ describe('opaque model-input selectors', () => { metadata: 'unused-secret', }, }) - ).toEqual({ base64: 'effective-bytes' }) + ).toEqual([['file', 'base64']]) }) it('selects only the active Textract source for sync and async requests', () => { expect( - selectOpaqueModelInput(textractParserTool, { + selectOpaqueModelInputPaths(textractParserTool, { processingMode: 'async', s3Uri: ' s3://bucket/effective.pdf ', filePath: 'https://example.com/unused.pdf', file: { key: 'unused-key', metadata: 'unused-secret' }, }) - ).toBe('s3://bucket/effective.pdf') + ).toEqual([['s3Uri']]) expect( - selectOpaqueModelInput(textractAnalyzeExpenseTool, { + selectOpaqueModelInputPaths(textractAnalyzeExpenseTool, { processingMode: 'sync', file: { key: 'effective-key', metadata: 'unused-secret' }, filePath: 'https://example.com/unused.pdf', s3Uri: 's3://bucket/unused.pdf', }) - ).toBeUndefined() + ).toEqual([]) expect( - selectOpaqueModelInput(textractAnalyzeExpenseTool, { + selectOpaqueModelInputPaths(textractAnalyzeExpenseTool, { processingMode: 'async', s3Uri: ' s3://bucket/effective.pdf ', file: { key: 'unused-key', metadata: 'unused-secret' }, }) - ).toBe('s3://bucket/effective.pdf') + ).toEqual([['s3Uri']]) }) it('mirrors independent front and back precedence for Textract Analyze ID', () => { expect( - selectOpaqueModelInput(textractAnalyzeIdTool, { + selectOpaqueModelInputPaths(textractAnalyzeIdTool, { file: { key: 'front-key', metadata: 'unused-secret' }, filePath: 'https://example.com/unused-front.png', filePathBack: ' https://example.com/back.png ', }) - ).toEqual({ - back: 'https://example.com/back.png', - }) + ).toEqual([['filePathBack']]) }) it('selects only the image source actually used by Vision', () => { expect( - selectOpaqueModelInput(visionTool, { + selectOpaqueModelInputPaths(visionTool, { imageFile: { base64: 'effective-bytes', key: 'unused-key', @@ -160,12 +161,12 @@ describe('opaque model-input selectors', () => { }, imageUrl: 'https://example.com/unused.png', }) - ).toEqual({ base64: 'effective-bytes' }) + ).toEqual([['imageFile', 'base64']]) }) it('keeps A2A attachment metadata that is transmitted and drops everything else', () => { expect( - selectOpaqueModelInput(a2aSendMessageTool, { + selectOpaqueModelInputPaths(a2aSendMessageTool, { files: [ { key: 'effective-key', @@ -176,12 +177,12 @@ describe('opaque model-input selectors', () => { }, ], }) - ).toEqual([{ name: 'report.pdf' }]) + ).toEqual([['files', '0', 'name']]) }) it('keeps Firecrawl upload metadata that is transmitted and drops passthrough fields', () => { expect( - selectOpaqueModelInput(firecrawlParseTool, { + selectOpaqueModelInputPaths(firecrawlParseTool, { file: { key: 'effective-key', path: 'unused-path', @@ -190,7 +191,7 @@ describe('opaque model-input selectors', () => { metadata: 'unused-secret', }, }) - ).toEqual({ name: 'report.pdf' }) + ).toEqual([['file', 'name']]) }) it('mirrors Fireflies source precedence without selecting unused file metadata', () => { @@ -211,7 +212,7 @@ describe('opaque model-input selectors', () => { }) expect( - selectOpaqueModelInput(firefliesUploadAudioTool, { + selectOpaqueModelInputPaths(firefliesUploadAudioTool, { audioFile: { key: 'effective-key', url: 'https://example.com/unused.mp3', @@ -220,10 +221,10 @@ describe('opaque model-input selectors', () => { }, audioUrl: 'https://example.com/unused-fallback.mp3', }) - ).toBeUndefined() + ).toEqual([]) expect( - selectOpaqueModelInput(firefliesUploadAudioTool, { + selectOpaqueModelInputPaths(firefliesUploadAudioTool, { audioFile: { url: 'https://example.com/effective.mp3', path: '/api/files/serve/unused.mp3', @@ -231,13 +232,13 @@ describe('opaque model-input selectors', () => { }, audioUrl: 'https://example.com/unused-fallback.mp3', }) - ).toEqual({ url: 'https://example.com/effective.mp3' }) + ).toEqual([['audioFile', 'url']]) expect( - selectOpaqueModelInput(firefliesUploadAudioTool, { + selectOpaqueModelInputPaths(firefliesUploadAudioTool, { audioUrl: 'https://example.com/fallback.mp3', }) - ).toBe('https://example.com/fallback.mp3') + ).toEqual([['audioUrl']]) }) it('normalizes Quiver file objects in both structured and serialized forms', () => { @@ -247,17 +248,17 @@ describe('opaque model-input selectors', () => { metadata: 'unused-secret', }) - expect(selectOpaqueModelInput(quiverImageToSvgTool, { image: serialized })).toBeUndefined() + expect(selectOpaqueModelInputPaths(quiverImageToSvgTool, { image: serialized })).toEqual([]) expect( - selectOpaqueModelInput(quiverTextToSvgTool, { + selectOpaqueModelInputPaths(quiverTextToSvgTool, { references: [serialized, { path: 'effective-path', metadata: 'unused-secret' }], }) - ).toEqual([{ path: 'effective-path' }]) + ).toEqual([['references', '1', 'path']]) }) it('selects only STT source metadata that the target provider transmits', () => { expect( - selectSttAudioModelInput({ + selectSttAudioModelInputPaths({ audioFile: { key: 'uploaded-key', name: 'uploaded.mp3', @@ -267,10 +268,10 @@ describe('opaque model-input selectors', () => { audioFileReference: { key: 'unused-reference' }, audioUrl: 'https://example.com/unused.mp3', }) - ).toBeUndefined() + ).toEqual([]) expect( - selectSttAudioModelInput({ + selectSttAudioModelInputPaths({ audioFileReference: { key: 'reference-key', name: 'reference.wav', @@ -279,10 +280,10 @@ describe('opaque model-input selectors', () => { }, audioUrl: 'https://example.com/unused.mp3', }) - ).toBeUndefined() + ).toEqual([]) expect( - selectSttAudioModelInput( + selectSttAudioModelInputPaths( { audioFile: { key: 'uploaded-key', @@ -294,11 +295,11 @@ describe('opaque model-input selectors', () => { }, { includeName: true } ) - ).toEqual({ name: 'uploaded.mp3' }) + ).toEqual([['audioFile', 'name']]) - expect(selectSttAudioModelInput({ audioUrl: ' https://example.com/audio.mp3 ' })).toBe( - 'https://example.com/audio.mp3' - ) + expect( + selectSttAudioModelInputPaths({ audioUrl: ' https://example.com/audio.mp3 ' }) + ).toEqual([['audioUrl']]) }) it.each([ @@ -318,9 +319,9 @@ describe('opaque model-input selectors', () => { if (modelInput?.mode !== 'project') { throw new Error(`Missing shared STT metadata projection for ${tool.id}`) } - expect(modelInput.privateProvenance).toBeDefined() + expect(modelInput.privateInputPaths).toBeDefined() expect( - modelInput.privateProvenance?.({ + modelInput.privateInputPaths?.({ audioFileReference: { key: 'effective-key', name: 'audio.mp3', @@ -329,7 +330,7 @@ describe('opaque model-input selectors', () => { }, audioUrl: 'https://example.com/unused.mp3', }) - ).toEqual(tool.id.startsWith('stt_whisper') ? { name: 'audio.mp3' } : undefined) + ).toEqual(tool.id.startsWith('stt_whisper') ? [['audioFileReference', 'name']] : []) expect(modelInput.select({ language: 'en', prompt: 'Proper noun' })).toEqual( tool.id.startsWith('stt_whisper') ? { language: 'en', prompt: 'Proper noun' } @@ -350,7 +351,7 @@ describe('opaque model-input selectors', () => { geminiSttV2Tool, ])('$id selects only opaque STT metadata transmitted upstream', (tool) => { expect( - selectOpaqueModelInput(tool, { + selectOpaqueModelInputPaths(tool, { audioFileReference: { key: 'effective-key', name: 'audio.mp3', @@ -359,12 +360,12 @@ describe('opaque model-input selectors', () => { }, audioUrl: 'https://example.com/unused.mp3', }) - ).toEqual(tool.id.startsWith('stt_whisper') ? { name: 'audio.mp3' } : undefined) + ).toEqual(tool.id.startsWith('stt_whisper') ? [['audioFileReference', 'name']] : []) }) it('selects only the Runway visual reference fields consumed by the provider', () => { expect( - selectOpaqueModelInput(runwayVideoTool, { + selectOpaqueModelInputPaths(runwayVideoTool, { visualReference: { key: 'effective-key', type: 'image/png', @@ -372,14 +373,14 @@ describe('opaque model-input selectors', () => { metadata: 'unused-secret', }, }) - ).toBeUndefined() + ).toEqual([]) }) it.each([elevenLabsSpeechToSpeechTool, elevenLabsAudioIsolationTool])( '$id selects only the audio source consumed by ElevenLabs', (tool) => { expect( - selectOpaqueModelInput(tool, { + selectOpaqueModelInputPaths(tool, { audioFile: { key: 'effective-key', name: 'audio.wav', @@ -387,127 +388,115 @@ describe('opaque model-input selectors', () => { metadata: 'unused-secret', }, }) - ).toEqual({ name: 'audio.wav' }) + ).toEqual([['audioFile', 'name']]) } ) it.each([ - [ - exaFindSimilarLinksTool, - { url: 'https://example.com/similar' }, - 'https://example.com/similar', - ], - [firecrawlAgentTool, { urls: ['https://example.com/agent'] }, ['https://example.com/agent']], - [ - firecrawlExtractTool, - { urls: ['https://example.com/extract'] }, - ['https://example.com/extract'], - ], - [contextDevExtractTool, { url: 'https://example.com/extract' }, 'https://example.com/extract'], - [ - contextDevExtractProductTool, - { url: 'https://example.com/product' }, - 'https://example.com/product', - ], - [contextDevExtractProductsTool, { domain: 'example.com' }, 'example.com'], - [browserUseRunTaskTool, { startUrl: 'https://example.com/start' }, 'https://example.com/start'], + [exaFindSimilarLinksTool, { url: 'https://example.com/similar' }, [['url']]], + [firecrawlAgentTool, { urls: ['https://example.com/agent'] }, [['urls']]], + [firecrawlExtractTool, { urls: ['https://example.com/extract'] }, [['urls']]], + [contextDevExtractTool, { url: 'https://example.com/extract' }, [['url']]], + [contextDevExtractProductTool, { url: 'https://example.com/product' }, [['url']]], + [contextDevExtractProductsTool, { domain: 'example.com' }, [['domain']]], + [browserUseRunTaskTool, { startUrl: 'https://example.com/start' }, [['startUrl']]], ])('$id selects its exact always-model-bound opaque input', (tool, params, expected) => { - expect(selectRejectedOpaqueModelInput(tool, params)).toStrictEqual(expected) + expect(selectRejectedOpaqueModelInputPaths(tool, params)).toStrictEqual(expected) }) it('selects Exa content URLs only when summaries are model-generated', () => { expect( - selectRejectedOpaqueModelInput(exaGetContentsTool, { + selectRejectedOpaqueModelInputPaths(exaGetContentsTool, { urls: 'https://example.com/plain', summary: false, }) - ).toBeUndefined() + ).toEqual([]) expect( - selectRejectedOpaqueModelInput(exaGetContentsTool, { + selectRejectedOpaqueModelInputPaths(exaGetContentsTool, { urls: 'https://example.com/summary', summary: true, }) - ).toBe('https://example.com/summary') + ).toEqual([['urls']]) expect( - selectRejectedOpaqueModelInput(exaGetContentsTool, { + selectRejectedOpaqueModelInputPaths(exaGetContentsTool, { urls: 'https://example.com/query', summaryQuery: 'Summarize this', }) - ).toBe('https://example.com/query') + ).toEqual([['urls']]) }) it('selects Firecrawl URLs only for formats or prompts that invoke models', () => { expect( - selectRejectedOpaqueModelInput(firecrawlScrapeTool, { + selectRejectedOpaqueModelInputPaths(firecrawlScrapeTool, { url: 'https://example.com/plain', formats: ['markdown'], }) - ).toBeUndefined() + ).toEqual([]) expect( - selectRejectedOpaqueModelInput(firecrawlScrapeTool, { + selectRejectedOpaqueModelInputPaths(firecrawlScrapeTool, { url: 'https://example.com/json', formats: [{ type: 'json', schema: { type: 'object' } }], }) - ).toBe('https://example.com/json') + ).toEqual([['url']]) expect( - selectRejectedOpaqueModelInput(firecrawlScrapeTool, { + selectRejectedOpaqueModelInputPaths(firecrawlScrapeTool, { url: 'https://example.com/string-json', formats: ['json'], }) - ).toBe('https://example.com/string-json') + ).toEqual([['url']]) expect( - selectRejectedOpaqueModelInput(firecrawlBatchScrapeTool, { + selectRejectedOpaqueModelInputPaths(firecrawlBatchScrapeTool, { urls: ['https://example.com/question'], scrapeOptions: { formats: [{ type: 'question', question: 'What changed?' }] }, }) - ).toStrictEqual(['https://example.com/question']) + ).toStrictEqual([['urls']]) expect( - selectRejectedOpaqueModelInput(firecrawlCrawlTool, { + selectRejectedOpaqueModelInputPaths(firecrawlCrawlTool, { url: 'https://example.com/plain-crawl', formats: ['markdown'], }) - ).toBeUndefined() + ).toEqual([]) expect( - selectRejectedOpaqueModelInput(firecrawlCrawlTool, { + selectRejectedOpaqueModelInputPaths(firecrawlCrawlTool, { url: 'https://example.com/prompted-crawl', prompt: 'Focus on pricing', }) - ).toBe('https://example.com/prompted-crawl') + ).toEqual([['url']]) }) it.each([tavilyCrawlTool, tavilyMapTool])( '$id selects its URL only when natural-language instructions are active', (tool) => { expect( - selectRejectedOpaqueModelInput(tool, { + selectRejectedOpaqueModelInputPaths(tool, { url: 'https://example.com/plain', }) - ).toBeUndefined() + ).toEqual([]) expect( - selectRejectedOpaqueModelInput(tool, { + selectRejectedOpaqueModelInputPaths(tool, { url: 'https://example.com/instructed', instructions: 'Find pricing', }) - ).toBe('https://example.com/instructed') + ).toEqual([['url']]) } ) it('selects Jina Reader URLs only for ReaderLM or generated-alt processing', () => { expect( - selectRejectedOpaqueModelInput(jinaReadUrlTool, { url: 'https://example.com/plain' }) - ).toBeUndefined() + selectRejectedOpaqueModelInputPaths(jinaReadUrlTool, { url: 'https://example.com/plain' }) + ).toEqual([]) expect( - selectRejectedOpaqueModelInput(jinaReadUrlTool, { + selectRejectedOpaqueModelInputPaths(jinaReadUrlTool, { url: 'https://example.com/readerlm', useReaderLMv2: true, }) - ).toBe('https://example.com/readerlm') + ).toEqual([['url']]) expect( - selectRejectedOpaqueModelInput(jinaReadUrlTool, { + selectRejectedOpaqueModelInputPaths(jinaReadUrlTool, { url: 'https://example.com/alt', withGeneratedAlt: true, }) - ).toBe('https://example.com/alt') + ).toEqual([['url']]) }) it.each([launchAgentTool, launchAgentV2Tool, addFollowupTool, addFollowupV2Tool])( @@ -516,10 +505,12 @@ describe('opaque model-input selectors', () => { const promptImages = JSON.stringify([ { data: 'quote" slash\\ newline\n123 true', dimension: { width: 10, height: 20 } }, ]) - expect(selectRejectedOpaqueModelInput(tool, { promptImages })).toStrictEqual([ - { data: 'quote" slash\\ newline\n123 true', dimension: { width: 10, height: 20 } }, + expect(selectRejectedOpaqueModelInputPaths(tool, { promptImages })).toStrictEqual([ + ['promptImages'], ]) - expect(selectRejectedOpaqueModelInput(tool, { promptImages: 'not-json' })).toStrictEqual([]) + expect(selectRejectedOpaqueModelInputPaths(tool, { promptImages: 'not-json' })).toStrictEqual( + [] + ) } ) }) diff --git a/apps/sim/tools/params.ts b/apps/sim/tools/params.ts index fb4fed18287..75dc85c2a5e 100644 --- a/apps/sim/tools/params.ts +++ b/apps/sim/tools/params.ts @@ -834,13 +834,16 @@ export function createExecutionToolSchema(toolConfig: ToolConfig): ToolSchema { return schema } -/** - * Filters out user-provided parameters from tool schema for LLM - */ -export function filterSchemaForLLM( - originalSchema: ToolSchema, +interface FilterableToolSchema { + properties?: Record + required?: string[] +} + +/** Filters user-provided parameters from any object-shaped tool schema sent to an LLM. */ +export function filterSchemaForLLM( + originalSchema: T, userProvidedParams: Record -): ToolSchema { +): T { if (!originalSchema || !originalSchema.properties) { return originalSchema } @@ -859,11 +862,10 @@ export function filterSchemaForLLM( } }) - return { - ...originalSchema, + return Object.assign({}, originalSchema, { properties: filteredProperties, required: filteredRequired, - } + }) } /** diff --git a/apps/sim/tools/pulse/parser.ts b/apps/sim/tools/pulse/parser.ts index 47d829b056f..93af5b27934 100644 --- a/apps/sim/tools/pulse/parser.ts +++ b/apps/sim/tools/pulse/parser.ts @@ -1,8 +1,8 @@ import { toError } from '@sim/utils/errors' import { isInternalFileUrl } from '@/lib/uploads/utils/file-utils' import { - selectModelBoundFileInput, - selectPreferredModelBoundFileInput, + selectModelBoundFileInputPaths, + selectPreferredModelBoundFileInputPaths, } from '@/lib/uploads/utils/model-input' import type { PulseParserInput, PulseParserOutput, PulseParserV2Input } from '@/tools/pulse/types' import type { ToolConfig } from '@/tools/types' @@ -79,10 +79,12 @@ export const pulseParserTool: ToolConfig = request: { modelInput: { mode: 'private-provenance', - select: (params) => - selectPreferredModelBoundFileInput({ + inputPaths: (params) => + selectPreferredModelBoundFileInputPaths({ file: params.file && typeof params.file === 'object' ? params.file : params.fileUpload, filePath: params.filePath, + fileInputPath: params.file && typeof params.file === 'object' ? ['file'] : ['fileUpload'], + filePathInputPath: ['filePath'], prefer: 'path', }), }, @@ -294,7 +296,7 @@ export const pulseParserV2Tool: ToolConfig selectModelBoundFileInput(params.file), + inputPaths: (params) => selectModelBoundFileInputPaths(params.file, ['file']), }, url: '/api/tools/pulse/parse', method: 'POST', diff --git a/apps/sim/tools/quiver/image_to_svg.ts b/apps/sim/tools/quiver/image_to_svg.ts index 99e31e49322..46291e65f16 100644 --- a/apps/sim/tools/quiver/image_to_svg.ts +++ b/apps/sim/tools/quiver/image_to_svg.ts @@ -1,4 +1,4 @@ -import { selectModelBoundFileInput } from '@/lib/uploads/utils/model-input' +import { selectModelBoundFileInputPaths } from '@/lib/uploads/utils/model-input' import type { QuiverImageToSvgParams, QuiverSvgResponse } from '@/tools/quiver/types' import type { ToolConfig } from '@/tools/types' @@ -68,7 +68,8 @@ export const quiverImageToSvgTool: ToolConfig selectModelBoundFileInput(params.image, { parseSerializedFile: true }), + inputPaths: (params) => + selectModelBoundFileInputPaths(params.image, ['image'], { parseSerializedFile: true }), }, url: '/api/tools/quiver/image-to-svg', method: 'POST', diff --git a/apps/sim/tools/quiver/text_to_svg.ts b/apps/sim/tools/quiver/text_to_svg.ts index fc4f1e7ff47..4b66d7c87ef 100644 --- a/apps/sim/tools/quiver/text_to_svg.ts +++ b/apps/sim/tools/quiver/text_to_svg.ts @@ -1,4 +1,4 @@ -import { selectModelBoundFileInput } from '@/lib/uploads/utils/model-input' +import { selectModelBoundFileInputPaths } from '@/lib/uploads/utils/model-input' import type { QuiverSvgResponse, QuiverTextToSvgParams } from '@/tools/quiver/types' import type { ToolConfig } from '@/tools/types' @@ -78,8 +78,10 @@ export const quiverTextToSvgTool: ToolConfig - selectModelBoundFileInput(params.references, { parseSerializedFile: true }), + privateInputPaths: (params) => + selectModelBoundFileInputPaths(params.references, ['references'], { + parseSerializedFile: true, + }), }, url: '/api/tools/quiver/text-to-svg', method: 'POST', diff --git a/apps/sim/tools/reducto/parser.ts b/apps/sim/tools/reducto/parser.ts index 0945127b2fa..d8de3b68d1f 100644 --- a/apps/sim/tools/reducto/parser.ts +++ b/apps/sim/tools/reducto/parser.ts @@ -1,8 +1,8 @@ import { toError } from '@sim/utils/errors' import { isInternalFileUrl } from '@/lib/uploads/utils/file-utils' import { - selectModelBoundFileInput, - selectPreferredModelBoundFileInput, + selectModelBoundFileInputPaths, + selectPreferredModelBoundFileInputPaths, } from '@/lib/uploads/utils/model-input' import type { ReductoParserInput, @@ -59,10 +59,12 @@ export const reductoParserTool: ToolConfig - selectPreferredModelBoundFileInput({ + inputPaths: (params) => + selectPreferredModelBoundFileInputPaths({ file: params.file && typeof params.file === 'object' ? params.file : params.fileUpload, filePath: params.filePath, + fileInputPath: params.file && typeof params.file === 'object' ? ['file'] : ['fileUpload'], + filePathInputPath: ['filePath'], prefer: 'path', }), }, @@ -220,7 +222,7 @@ export const reductoParserV2Tool: ToolConfig selectModelBoundFileInput(params.file), + inputPaths: (params) => selectModelBoundFileInputPaths(params.file, ['file']), }, url: '/api/tools/reducto/parse', method: 'POST', diff --git a/apps/sim/tools/request-transport.test.ts b/apps/sim/tools/request-transport.test.ts index efb7923d66f..bfa21b1fc76 100644 --- a/apps/sim/tools/request-transport.test.ts +++ b/apps/sim/tools/request-transport.test.ts @@ -41,7 +41,7 @@ describe('private-provenance tool registry invariant', () => { body: () => ({ probe: true }), modelInput: { mode: 'private-provenance', - select: () => undefined, + inputPaths: () => [], }, }, } diff --git a/apps/sim/tools/request-transport.ts b/apps/sim/tools/request-transport.ts index 9852a78f3b0..64d1f3c20ab 100644 --- a/apps/sim/tools/request-transport.ts +++ b/apps/sim/tools/request-transport.ts @@ -5,8 +5,8 @@ import { addModelInputProvenanceToRequest, createModelInputProvenanceRequestMetadata, createPrivateSecretProvenanceRequestMetadata, + markModelInputProjected, } from '@/lib/execution/model-input-provenance' -import { projectResolvedSecretModelContent } from '@/executor/utils/resolved-secret-content-projection' import type { ResolvedSecretTraceRegistry } from '@/executor/utils/resolved-secret-trace-registry' import type { ToolConfig } from '@/tools/types' @@ -77,59 +77,45 @@ export function projectToolModelInputParams( if (!selection) throw new Error(MODEL_INPUT_PROJECTION_ERROR_MESSAGE) const { record: selected, entries } = selection - const selectedValues = entries.map(([, value]) => value) - const projection = projectResolvedSecretModelContent( - selectedValues, - registry.forkForToolInputValues(selectedValues) - ) - if ( - !projection.safe || - !Array.isArray(projection.value) || - projection.value.length !== entries.length - ) { + const originalSelectedParams: Record = {} + for (const [key] of entries) originalSelectedParams[key] = params[key] + const projection = registry.projectResolvedInputSelection(originalSelectedParams) + if (!projection.complete) { throw new Error(MODEL_INPUT_PROJECTION_ERROR_MESSAGE) } - const projectedSelected = Object.create(Object.getPrototypeOf(selected)) as Record< - string, - unknown - > - for (let index = 0; index < entries.length; index++) { - Object.defineProperty(projectedSelected, entries[index][0], { - value: projection.value[index], - enumerable: true, - configurable: true, - writable: true, - }) - } - if (!haveExactOwnKeys(selected, projectedSelected)) { + const projectedParams = { ...params, ...projection.value } + const projectedSelection = inspectSelectedModelInputRecord( + tool, + modelInput.select(projectedParams) + ) + if (!projectedSelection || !haveExactOwnKeys(selected, projectedSelection.record)) { throw new Error(MODEL_INPUT_PROJECTION_ERROR_MESSAGE) } + JSON.stringify(projectedSelection.record) - if (!modelInput.applyProjected) return { ...params, ...projectedSelected } + if (!modelInput.applyProjected) return projectedParams - const originalSelectedParams: Record = {} - for (const [key] of entries) originalSelectedParams[key] = params[key] const selectedParamsClone = structuredClone(originalSelectedParams) const patch = inspectSelectedModelInputRecord( tool, - modelInput.applyProjected(selectedParamsClone, projectedSelected) + modelInput.applyProjected(selectedParamsClone, projectedSelection.record) ) if (!patch || !haveExactOwnKeys(selected, patch.record)) { throw new Error(MODEL_INPUT_PROJECTION_ERROR_MESSAGE) } - const projectedParams = { ...params, ...patch.record } - const verification = inspectSelectedModelInputRecord(tool, modelInput.select(projectedParams)) + const patchedParams = { ...projectedParams, ...patch.record } + const verification = inspectSelectedModelInputRecord(tool, modelInput.select(patchedParams)) if ( !verification || !haveExactOwnKeys(selected, verification.record) || - !isDeepStrictEqual(verification.record, projectedSelected) + !isDeepStrictEqual(verification.record, projectedSelection.record) ) { throw new Error(MODEL_INPUT_PROJECTION_ERROR_MESSAGE) } - return projectedParams + return patchedParams } catch { throw new Error(MODEL_INPUT_PROJECTION_ERROR_MESSAGE) } @@ -199,7 +185,7 @@ export function prepareToolRequest( const secretProvenance = tool.request.secretProvenance const hasPrivateModelInputProvenance = modelInput?.mode === 'private-provenance' || - (modelInput?.mode === 'project' && modelInput.privateProvenance !== undefined) + (modelInput?.mode === 'project' && modelInput.privateInputPaths !== undefined) if (hasPrivateModelInputProvenance && !configuredUrl.startsWith('/api/')) { throw new Error(PRIVATE_MODEL_INPUT_EXTERNAL_URL_ERROR_MESSAGE) @@ -217,14 +203,14 @@ export function prepareToolRequest( throw new Error(PRIVATE_SECRET_PROVENANCE_EXTERNAL_URL_ERROR_MESSAGE) } - const selectedModelInput = + const selectedModelInputPaths = modelInput?.mode === 'private-provenance' - ? modelInput.select(requestInput) + ? modelInput.inputPaths(requestInput) : modelInput?.mode === 'project' - ? modelInput.privateProvenance?.(requestInput) + ? modelInput.privateInputPaths?.(requestInput) : undefined const modelInputMetadata = hasPrivateModelInputProvenance - ? createModelInputProvenanceRequestMetadata(registry, selectedModelInput) + ? createModelInputProvenanceRequestMetadata(registry, selectedModelInputPaths ?? []) : undefined const secretProvenanceMetadata = secretProvenance?.request ? createPrivateSecretProvenanceRequestMetadata(registry, secretProvenance.request(requestInput)) @@ -248,6 +234,9 @@ export function prepareToolRequest( request.headers, modelInputMetadata ) + if (modelInputMetadata && modelInput?.mode === 'project') { + markModelInputProjected(request.headers) + } request.body = JSON.stringify( addModelInputProvenanceToRequest( bodyWithModelInputProvenance, diff --git a/apps/sim/tools/stt/assemblyai.ts b/apps/sim/tools/stt/assemblyai.ts index 2060e89a591..c291867447d 100644 --- a/apps/sim/tools/stt/assemblyai.ts +++ b/apps/sim/tools/stt/assemblyai.ts @@ -1,4 +1,4 @@ -import { selectSttAudioModelInput } from '@/tools/stt/model-input' +import { selectSttAudioModelInputPaths } from '@/tools/stt/model-input' import type { SttParams, SttResponse, SttV2Params } from '@/tools/stt/types' import { STT_ENTITY_OUTPUT_PROPERTIES, @@ -98,7 +98,7 @@ export const assemblyaiSttTool: ToolConfig = { modelInput: { mode: 'project', select: (params) => ({ language: params.language }), - privateProvenance: selectSttAudioModelInput, + privateInputPaths: selectSttAudioModelInputPaths, }, url: '/api/tools/stt', method: 'POST', diff --git a/apps/sim/tools/stt/deepgram.ts b/apps/sim/tools/stt/deepgram.ts index 33db41aa41e..8b35570e5ff 100644 --- a/apps/sim/tools/stt/deepgram.ts +++ b/apps/sim/tools/stt/deepgram.ts @@ -1,4 +1,4 @@ -import { selectSttAudioModelInput } from '@/tools/stt/model-input' +import { selectSttAudioModelInputPaths } from '@/tools/stt/model-input' import type { SttParams, SttResponse, SttV2Params } from '@/tools/stt/types' import { STT_SEGMENT_OUTPUT_PROPERTIES } from '@/tools/stt/types' import type { ToolConfig } from '@/tools/types' @@ -70,7 +70,7 @@ export const deepgramSttTool: ToolConfig = { modelInput: { mode: 'project', select: (params) => ({ language: params.language }), - privateProvenance: selectSttAudioModelInput, + privateInputPaths: selectSttAudioModelInputPaths, }, url: '/api/tools/stt', method: 'POST', diff --git a/apps/sim/tools/stt/elevenlabs.ts b/apps/sim/tools/stt/elevenlabs.ts index 8b15a2b883e..9fe6647876a 100644 --- a/apps/sim/tools/stt/elevenlabs.ts +++ b/apps/sim/tools/stt/elevenlabs.ts @@ -1,4 +1,4 @@ -import { selectSttAudioModelInput } from '@/tools/stt/model-input' +import { selectSttAudioModelInputPaths } from '@/tools/stt/model-input' import type { SttParams, SttResponse, SttV2Params } from '@/tools/stt/types' import type { ToolConfig } from '@/tools/types' @@ -63,7 +63,7 @@ export const elevenLabsSttTool: ToolConfig = { modelInput: { mode: 'project', select: (params) => ({ language: params.language }), - privateProvenance: selectSttAudioModelInput, + privateInputPaths: selectSttAudioModelInputPaths, }, url: '/api/tools/stt', method: 'POST', diff --git a/apps/sim/tools/stt/gemini.ts b/apps/sim/tools/stt/gemini.ts index ffdfa823ddf..dc4f19dd1e4 100644 --- a/apps/sim/tools/stt/gemini.ts +++ b/apps/sim/tools/stt/gemini.ts @@ -1,4 +1,4 @@ -import { selectSttAudioModelInput } from '@/tools/stt/model-input' +import { selectSttAudioModelInputPaths } from '@/tools/stt/model-input' import type { SttParams, SttResponse, SttV2Params } from '@/tools/stt/types' import type { ToolConfig } from '@/tools/types' @@ -63,7 +63,7 @@ export const geminiSttTool: ToolConfig = { modelInput: { mode: 'project', select: (params) => ({ language: params.language }), - privateProvenance: selectSttAudioModelInput, + privateInputPaths: selectSttAudioModelInputPaths, }, url: '/api/tools/stt', method: 'POST', diff --git a/apps/sim/tools/stt/model-input.test.ts b/apps/sim/tools/stt/model-input.test.ts index 33fee1df679..13363e7916f 100644 --- a/apps/sim/tools/stt/model-input.test.ts +++ b/apps/sim/tools/stt/model-input.test.ts @@ -3,7 +3,6 @@ */ import { describe, expect, it } from 'vitest' import { deepgramSttTool } from '@/tools/stt/deepgram' -import { selectSttAudioModelInput } from '@/tools/stt/model-input' import { whisperSttTool } from '@/tools/stt/whisper' import type { ToolConfig } from '@/tools/types' @@ -12,20 +11,19 @@ const AUDIO_FILE = { key: 'workspace/ws-1/secret-recording.mp3', } -function selectPrivateProvenance(tool: ToolConfig): unknown { +function selectPrivateInputPaths(tool: ToolConfig): readonly (readonly string[])[] { const modelInput = tool.request.modelInput expect(modelInput?.mode).toBe('project') if (modelInput?.mode !== 'project') throw new Error(`Expected ${tool.id} to project model input`) - return modelInput.privateProvenance?.({ audioFile: AUDIO_FILE }) + return modelInput.privateInputPaths?.({ audioFile: AUDIO_FILE }) ?? [] } describe('STT model input provenance', () => { it('excludes filenames by default when the provider receives only audio bytes', () => { - expect(selectSttAudioModelInput({ audioFile: AUDIO_FILE })).toBeUndefined() - expect(selectPrivateProvenance(deepgramSttTool)).toBeUndefined() + expect(selectPrivateInputPaths(deepgramSttTool)).toEqual([]) }) it('selects the filename only for Whisper, which serializes it upstream', () => { - expect(selectPrivateProvenance(whisperSttTool)).toEqual({ name: 'secret-recording.mp3' }) + expect(selectPrivateInputPaths(whisperSttTool)).toEqual([['audioFile', 'name']]) }) }) diff --git a/apps/sim/tools/stt/model-input.ts b/apps/sim/tools/stt/model-input.ts index 70ec69bdd70..8ab58a4c9df 100644 --- a/apps/sim/tools/stt/model-input.ts +++ b/apps/sim/tools/stt/model-input.ts @@ -1,4 +1,5 @@ -import { selectModelBoundFileInput } from '@/lib/uploads/utils/model-input' +import { selectModelBoundFileInputPaths } from '@/lib/uploads/utils/model-input' +import type { ResolvedSecretInputPath } from '@/executor/utils/resolved-secret-trace-registry' interface SttAudioModelInputParams { audioFile?: unknown @@ -6,21 +7,25 @@ interface SttAudioModelInputParams { audioUrl?: unknown } -/** Selects the single audio source consumed by the shared STT route. */ -export function selectSttAudioModelInput( +/** Selects exact resolver paths for the single audio source consumed by the shared STT route. */ +export function selectSttAudioModelInputPaths( params: SttAudioModelInputParams, options: { includeName?: boolean } = {} -): unknown { +): readonly ResolvedSecretInputPath[] { const fileOptions = { includeName: options.includeName ?? false } as const if (params.audioFile) { - return selectModelBoundFileInput(params.audioFile, fileOptions) + return selectModelBoundFileInputPaths(params.audioFile, ['audioFile'], fileOptions) } if (params.audioFileReference) { - return selectModelBoundFileInput(params.audioFileReference, fileOptions) + return selectModelBoundFileInputPaths( + params.audioFileReference, + ['audioFileReference'], + fileOptions + ) } if (typeof params.audioUrl === 'string' && params.audioUrl.trim() !== '') { - return params.audioUrl.trim() + return [['audioUrl']] } - return undefined + return [] } diff --git a/apps/sim/tools/stt/whisper.ts b/apps/sim/tools/stt/whisper.ts index db8e4bb6cc9..4447c20775c 100644 --- a/apps/sim/tools/stt/whisper.ts +++ b/apps/sim/tools/stt/whisper.ts @@ -1,4 +1,4 @@ -import { selectSttAudioModelInput } from '@/tools/stt/model-input' +import { selectSttAudioModelInputPaths } from '@/tools/stt/model-input' import type { SttParams, SttResponse, SttV2Params } from '@/tools/stt/types' import { STT_SEGMENT_OUTPUT_PROPERTIES } from '@/tools/stt/types' import type { ToolConfig } from '@/tools/types' @@ -103,7 +103,7 @@ export const whisperSttTool: ToolConfig = { language: params.language, prompt: params.prompt, }), - privateProvenance: (params) => selectSttAudioModelInput(params, { includeName: true }), + privateInputPaths: (params) => selectSttAudioModelInputPaths(params, { includeName: true }), }, url: '/api/tools/stt', method: 'POST', diff --git a/apps/sim/tools/table/batch_insert_rows.ts b/apps/sim/tools/table/batch_insert_rows.ts index 39fdb2f228d..5911b2d4532 100644 --- a/apps/sim/tools/table/batch_insert_rows.ts +++ b/apps/sim/tools/table/batch_insert_rows.ts @@ -36,7 +36,7 @@ export const tableBatchInsertRowsTool: ToolConfig< request: { secretProvenance: { - request: (params) => selectTableRowSecretProvenance(params.rows), + request: (params) => selectTableRowSecretProvenance(params.rows, 'rows'), response: { incomplete: 'propagate' }, }, url: (params: TableBatchInsertParams) => `/api/table/${params.tableId}/rows`, diff --git a/apps/sim/tools/tavily/crawl.ts b/apps/sim/tools/tavily/crawl.ts index fd0d79d849b..8b331adf60b 100644 --- a/apps/sim/tools/tavily/crawl.ts +++ b/apps/sim/tools/tavily/crawl.ts @@ -109,7 +109,7 @@ export const crawlTool: ToolConfig = { }, opaqueModelInput: { mode: 'reject-resolved-secrets', - select: (params) => (params.instructions ? params.url : undefined), + inputPaths: (params) => (params.instructions ? [['url']] : []), }, url: 'https://api.tavily.com/crawl', method: 'POST', diff --git a/apps/sim/tools/tavily/map.ts b/apps/sim/tools/tavily/map.ts index 710cf849f66..da6f0ddd7b7 100644 --- a/apps/sim/tools/tavily/map.ts +++ b/apps/sim/tools/tavily/map.ts @@ -85,7 +85,7 @@ export const mapTool: ToolConfig = { }, opaqueModelInput: { mode: 'reject-resolved-secrets', - select: (params) => (params.instructions ? params.url : undefined), + inputPaths: (params) => (params.instructions ? [['url']] : []), }, url: 'https://api.tavily.com/map', method: 'POST', diff --git a/apps/sim/tools/textract/analyze-expense.ts b/apps/sim/tools/textract/analyze-expense.ts index 7b4f186b8cd..15311fddea7 100644 --- a/apps/sim/tools/textract/analyze-expense.ts +++ b/apps/sim/tools/textract/analyze-expense.ts @@ -1,6 +1,6 @@ import { createLogger } from '@sim/logger' import { toError } from '@sim/utils/errors' -import { selectPreferredModelBoundFileInput } from '@/lib/uploads/utils/model-input' +import { selectPreferredModelBoundFileInputPaths } from '@/lib/uploads/utils/model-input' import type { TextractAnalyzeExpenseOutput, TextractAnalyzeExpenseV2Input, @@ -117,14 +117,16 @@ export const textractAnalyzeExpenseTool: ToolConfig< request: { modelInput: { mode: 'private-provenance', - select: (params) => { + inputPaths: (params) => { const processingMode = params.processingMode || 'sync' if (processingMode === 'async') { - return typeof params.s3Uri === 'string' ? params.s3Uri.trim() : params.s3Uri + return typeof params.s3Uri === 'string' && params.s3Uri.trim() !== '' ? [['s3Uri']] : [] } - return selectPreferredModelBoundFileInput({ + return selectPreferredModelBoundFileInputPaths({ file: params.file, filePath: params.filePath, + fileInputPath: ['file'], + filePathInputPath: ['filePath'], prefer: 'file', }) }, diff --git a/apps/sim/tools/textract/analyze-id.ts b/apps/sim/tools/textract/analyze-id.ts index 70bde83f1f3..9c13dde7dda 100644 --- a/apps/sim/tools/textract/analyze-id.ts +++ b/apps/sim/tools/textract/analyze-id.ts @@ -1,6 +1,6 @@ import { createLogger } from '@sim/logger' import { toError } from '@sim/utils/errors' -import { selectPreferredModelBoundFileInput } from '@/lib/uploads/utils/model-input' +import { selectPreferredModelBoundFileInputPaths } from '@/lib/uploads/utils/model-input' import type { TextractAnalyzeIdOutput, TextractAnalyzeIdV2Input } from '@/tools/textract/types' import type { ToolConfig } from '@/tools/types' @@ -61,21 +61,22 @@ export const textractAnalyzeIdTool: ToolConfig { - const front = selectPreferredModelBoundFileInput({ + inputPaths: (params) => { + const front = selectPreferredModelBoundFileInputPaths({ file: params.file, filePath: params.filePath, + fileInputPath: ['file'], + filePathInputPath: ['filePath'], prefer: 'file', }) - const back = selectPreferredModelBoundFileInput({ + const back = selectPreferredModelBoundFileInputPaths({ file: params.fileBack, filePath: params.filePathBack, + fileInputPath: ['fileBack'], + filePathInputPath: ['filePathBack'], prefer: 'file', }) - return { - ...(front !== undefined ? { front } : {}), - ...(back !== undefined ? { back } : {}), - } + return [...front, ...back] }, }, url: '/api/tools/textract/analyze-id', diff --git a/apps/sim/tools/textract/parser.ts b/apps/sim/tools/textract/parser.ts index 88e9b1c6bce..4dade2a903a 100644 --- a/apps/sim/tools/textract/parser.ts +++ b/apps/sim/tools/textract/parser.ts @@ -1,6 +1,6 @@ import { createLogger } from '@sim/logger' import { toError } from '@sim/utils/errors' -import { selectPreferredModelBoundFileInput } from '@/lib/uploads/utils/model-input' +import { selectPreferredModelBoundFileInputPaths } from '@/lib/uploads/utils/model-input' import type { TextractParserInput, TextractParserOutput, @@ -121,14 +121,16 @@ export const textractParserTool: ToolConfig { + privateInputPaths: (params) => { const processingMode = params.processingMode || 'sync' if (processingMode === 'async') { - return typeof params.s3Uri === 'string' ? params.s3Uri.trim() : params.s3Uri + return typeof params.s3Uri === 'string' && params.s3Uri.trim() !== '' ? [['s3Uri']] : [] } - return selectPreferredModelBoundFileInput({ + return selectPreferredModelBoundFileInputPaths({ file: params.file && typeof params.file === 'object' ? params.file : params.fileUpload, filePath: params.filePath, + fileInputPath: params.file && typeof params.file === 'object' ? ['file'] : ['fileUpload'], + filePathInputPath: ['filePath'], prefer: 'path', }) }, diff --git a/apps/sim/tools/types.ts b/apps/sim/tools/types.ts index fc2e25436c7..3b125a0c989 100644 --- a/apps/sim/tools/types.ts +++ b/apps/sim/tools/types.ts @@ -2,6 +2,7 @@ import type { MothershipResource } from '@/lib/copilot/resources/types' import type { HostedKeyRateLimitConfig } from '@/lib/core/rate-limiter' import type { PrivateSecretProvenanceSelection } from '@/lib/execution/model-input-provenance' import type { OAuthService } from '@/lib/oauth' +import type { ResolvedSecretInputPath } from '@/executor/utils/resolved-secret-trace-registry' export type BYOKProviderId = | 'openai' @@ -201,7 +202,7 @@ export interface ToolConfig

{ * signed URLs. Provenance is delivered privately to an authenticated internal route, * which owns the final allow/reject decision. */ - privateProvenance?: (params: P) => unknown + privateInputPaths?: (params: P) => readonly ResolvedSecretInputPath[] } | { /** @@ -209,7 +210,7 @@ export interface ToolConfig

{ * the corresponding projection boundary. */ mode: 'private-provenance' - select: (params: P) => unknown + inputPaths: (params: P) => readonly ResolvedSecretInputPath[] } /** * Selects model-bound values whose byte representation cannot be rewritten safely. The @@ -218,7 +219,7 @@ export interface ToolConfig

{ */ opaqueModelInput?: { mode: 'reject-resolved-secrets' - select: (params: P) => unknown + inputPaths: (params: P) => readonly ResolvedSecretInputPath[] } /** * Transports encrypted secret provenance across an authenticated internal diff --git a/apps/sim/tools/video/runway.ts b/apps/sim/tools/video/runway.ts index 1f464669064..b681b1f5a71 100644 --- a/apps/sim/tools/video/runway.ts +++ b/apps/sim/tools/video/runway.ts @@ -1,4 +1,4 @@ -import { selectModelBoundFileInput } from '@/lib/uploads/utils/model-input' +import { selectModelBoundFileInputPaths } from '@/lib/uploads/utils/model-input' import type { ToolConfig } from '@/tools/types' import type { VideoParams, VideoResponse } from '@/tools/video/types' @@ -64,7 +64,8 @@ export const runwayVideoTool: ToolConfig = { modelInput: { mode: 'project', select: (params) => ({ prompt: params.prompt }), - privateProvenance: (params) => selectModelBoundFileInput(params.visualReference), + privateInputPaths: (params) => + selectModelBoundFileInputPaths(params.visualReference, ['visualReference']), }, url: '/api/tools/video', method: 'POST', diff --git a/apps/sim/tools/vision/tool.ts b/apps/sim/tools/vision/tool.ts index 971a4d81678..ac393d225be 100644 --- a/apps/sim/tools/vision/tool.ts +++ b/apps/sim/tools/vision/tool.ts @@ -1,4 +1,4 @@ -import { selectPreferredModelBoundFileInput } from '@/lib/uploads/utils/model-input' +import { selectPreferredModelBoundFileInputPaths } from '@/lib/uploads/utils/model-input' import type { ToolConfig } from '@/tools/types' import type { VisionParams, VisionResponse, VisionV2Params } from '@/tools/vision/types' @@ -46,10 +46,12 @@ export const visionTool: ToolConfig = { modelInput: { mode: 'project', select: (params) => ({ prompt: params.prompt }), - privateProvenance: (params) => - selectPreferredModelBoundFileInput({ + privateInputPaths: (params) => + selectPreferredModelBoundFileInputPaths({ file: params.imageFile, filePath: params.imageUrl, + fileInputPath: ['imageFile'], + filePathInputPath: ['imageUrl'], prefer: 'file', includeInlineBase64: true, }), diff --git a/apps/sim/tools/workflow/executor.test.ts b/apps/sim/tools/workflow/executor.test.ts index 3c826cd4daf..e31ac6ebbfe 100644 --- a/apps/sim/tools/workflow/executor.test.ts +++ b/apps/sim/tools/workflow/executor.test.ts @@ -33,11 +33,13 @@ describe('workflowExecutorTool', () => { expected: {}, }, ])( - 'selects the same normalized $name used by the request body', + 'selects the inputMapping root and preserves $name body normalization', ({ inputMapping, expected }) => { const params = { workflowId: 'test-workflow-id', inputMapping } - expect(selectProvenance?.(params)).toEqual([{ key: 'input', value: expected }]) + expect(selectProvenance?.(params)).toEqual([ + { key: 'input', inputPaths: [['inputMapping']] }, + ]) expect(workflowExecutorTool.request.body?.(params)).toMatchObject({ input: expected }) } ) diff --git a/apps/sim/tools/workflow/executor.ts b/apps/sim/tools/workflow/executor.ts index fb541eea705..875e47044f8 100644 --- a/apps/sim/tools/workflow/executor.ts +++ b/apps/sim/tools/workflow/executor.ts @@ -41,7 +41,7 @@ export const workflowExecutorTool: ToolConfig< request: (params) => [ { key: WORKFLOW_EXECUTOR_INPUT_PROVENANCE_KEY, - value: normalizeWorkflowExecutorInput(params.inputMapping), + inputPaths: [['inputMapping']], }, ], response: { incomplete: 'reject' }, diff --git a/packages/db/package.json b/packages/db/package.json index 842211bd27a..c30bb96a4bd 100644 --- a/packages/db/package.json +++ b/packages/db/package.json @@ -21,6 +21,7 @@ "scripts": { "db:push": "bunx drizzle-kit push --config=./drizzle.config.ts", "db:migrate": "bun --env-file=.env run ./scripts/migrate.ts", + "db:reconcile-fork-kb-file-ownership": "bun --env-file=.env run ./scripts/reconcile-fork-kb-file-ownership.ts", "db:reconcile-workspace-storage": "bun --env-file=.env run ./scripts/reconcile-workspace-storage.ts", "db:studio": "bunx drizzle-kit studio --config=./drizzle.config.ts", "test": "vitest run", diff --git a/packages/db/schema.ts b/packages/db/schema.ts index 9100b4ffe7b..11e35ceb81e 100644 --- a/packages/db/schema.ts +++ b/packages/db/schema.ts @@ -1985,10 +1985,15 @@ export const workspaceFiles = pgTable( ) export interface WorkspaceFileSecretProvenanceEntry extends DurableSecretProvenanceEntry { - name: string sourceUserId: string } +export interface StoredWorkspaceFileSecretProvenanceEntry + extends WorkspaceFileSecretProvenanceEntry { + name: string + anonymous?: true +} + /** * Private, durable provenance for bytes stored in `workspace_files`. * @@ -2005,7 +2010,10 @@ export const workspaceFileSecretProvenance = pgTable( .references(() => workspaceFiles.id, { onDelete: 'cascade' }), contentUpdatedAt: timestamp('content_updated_at').notNull(), status: text('status').notNull(), - entries: jsonb('entries').$type().notNull().default([]), + entries: jsonb('entries') + .$type() + .notNull() + .default([]), updatedAt: timestamp('updated_at').notNull().defaultNow(), }, (table) => ({ diff --git a/packages/db/script-migrations-paused-billing-attribution.test.ts b/packages/db/script-migrations-paused-billing-attribution.test.ts index 473e8bc9495..bcf4f844b71 100644 --- a/packages/db/script-migrations-paused-billing-attribution.test.ts +++ b/packages/db/script-migrations-paused-billing-attribution.test.ts @@ -440,6 +440,7 @@ describe('script migration registry', () => { '0001_backfill_table_order_keys', '0002_backfill_paused_billing_attribution', '0003_backfill_workspace_storage_usage', + '0004_backfill_fork_kb_file_ownership', ]) }) }) diff --git a/packages/db/script-migrations/0004_backfill_fork_kb_file_ownership.test.ts b/packages/db/script-migrations/0004_backfill_fork_kb_file_ownership.test.ts new file mode 100644 index 00000000000..3b4ea591f31 --- /dev/null +++ b/packages/db/script-migrations/0004_backfill_fork_kb_file_ownership.test.ts @@ -0,0 +1,212 @@ +/** + * @vitest-environment node + */ +import type { Sql } from 'postgres' +import { describe, expect, it, vi } from 'vitest' +import { + assertForkKnowledgeBaseOwnershipFullyReconciled, + assertForkKnowledgeBaseOwnershipPostDrainAck, + createPostgresForkKnowledgeBaseOwnershipRepairStore, + FORK_KB_OWNERSHIP_POST_DRAIN_ACK, + type ForkKnowledgeBaseOwnershipRepairStore, + reconcileForkKnowledgeBaseFileOwnership, +} from './0004_backfill_fork_kb_file_ownership' + +interface CapturedQuery { + text: string + values: unknown[] +} + +function normalizeSql(value: string): string { + return value.replace(/\s+/g, ' ').trim() +} + +function createSqlHarness(options: { + candidates?: string[] + insertedKeys?: string[] + unresolved?: number +}): { queries: CapturedQuery[]; sql: Sql } { + const queries: CapturedQuery[] = [] + const query = vi.fn((strings: TemplateStringsArray, ...values: unknown[]) => { + const text = strings.join('?') + queries.push({ text, values }) + if (text.includes('SELECT d.id')) { + return Promise.resolve((options.candidates ?? []).map((id) => ({ id }))) + } + if (text.includes('INSERT INTO workspace_files')) { + return Promise.resolve((options.insertedKeys ?? []).map((key) => ({ key }))) + } + if (text.includes('SELECT count(*) AS count')) { + return Promise.resolve([{ count: options.unresolved ?? 0 }]) + } + throw new Error(`Unexpected SQL in test: ${text}`) + }) + const sql = query as unknown as Sql + sql.begin = vi.fn(async (callback) => callback(sql)) as Sql['begin'] + return { queries, sql } +} + +describe('fork knowledge-base ownership reconciliation', () => { + it('processes bounded keyset pages sequentially and aggregates unresolved rows', async () => { + const events: string[] = [] + const pageCalls: Array<{ afterId: string; limit: number }> = [] + const repairCalls: string[][] = [] + const pages = new Map([ + ['', ['fork_document_a', 'fork_document_b']], + ['fork_document_b', ['fork_document_c']], + ['fork_document_c', []], + ]) + const store: ForkKnowledgeBaseOwnershipRepairStore = { + async listCandidateDocumentIds(afterId, limit) { + events.push(`list:${afterId}`) + pageCalls.push({ afterId, limit }) + return pages.get(afterId) ?? [] + }, + async repairDocumentIds(documentIds) { + events.push(`repair:${documentIds.join(',')}`) + repairCalls.push([...documentIds]) + return documentIds.includes('fork_document_c') + ? { inserted: 0, unresolved: 1 } + : { inserted: documentIds.length, unresolved: 0 } + }, + } + + await expect(reconcileForkKnowledgeBaseFileOwnership(store, { batchSize: 2 })).resolves.toEqual( + { scanned: 3, inserted: 2, unresolved: 1 } + ) + expect(pageCalls).toEqual([ + { afterId: '', limit: 2 }, + { afterId: 'fork_document_b', limit: 2 }, + { afterId: 'fork_document_c', limit: 2 }, + ]) + expect(repairCalls).toEqual([['fork_document_a', 'fork_document_b'], ['fork_document_c']]) + expect(events).toEqual([ + 'list:', + 'repair:fork_document_a,fork_document_b', + 'list:fork_document_b', + 'repair:fork_document_c', + 'list:fork_document_c', + ]) + }) + + it('rejects oversized and non-advancing store pages', async () => { + const oversized: ForkKnowledgeBaseOwnershipRepairStore = { + listCandidateDocumentIds: vi.fn().mockResolvedValue(['a', 'b']), + repairDocumentIds: vi.fn(), + } + await expect( + reconcileForkKnowledgeBaseFileOwnership(oversized, { batchSize: 1 }) + ).rejects.toThrow('oversized page') + + const nonAdvancing: ForkKnowledgeBaseOwnershipRepairStore = { + listCandidateDocumentIds: vi.fn().mockResolvedValue(['']), + repairDocumentIds: vi.fn(), + } + await expect(reconcileForkKnowledgeBaseFileOwnership(nonAdvancing)).rejects.toThrow( + 'non-advancing page' + ) + }) + + it('is idempotent after the first run records every uncontested binding', async () => { + const missing = new Set(['fork_document_a', 'fork_document_b']) + const store: ForkKnowledgeBaseOwnershipRepairStore = { + async listCandidateDocumentIds(afterId, limit) { + return [...missing] + .filter((id) => id > afterId) + .sort() + .slice(0, limit) + }, + async repairDocumentIds(documentIds) { + for (const id of documentIds) missing.delete(id) + return { inserted: documentIds.length, unresolved: 0 } + }, + } + + await expect(reconcileForkKnowledgeBaseFileOwnership(store)).resolves.toEqual({ + scanned: 2, + inserted: 2, + unresolved: 0, + }) + await expect(reconcileForkKnowledgeBaseFileOwnership(store)).resolves.toEqual({ + scanned: 0, + inserted: 0, + unresolved: 0, + }) + }) + + it('selects only active documents with canonical deterministic fork identities', async () => { + const harness = createSqlHarness({ candidates: ['fork_document_a'] }) + const store = createPostgresForkKnowledgeBaseOwnershipRepairStore(harness.sql) + + await expect(store.listCandidateDocumentIds('fork_document_0', 250)).resolves.toEqual([ + 'fork_document_a', + ]) + + const query = harness.queries[0] + const text = normalizeSql(query.text) + expect(text).toContain("d.id ~ '^fork_document_[0-9a-f]{40}$'") + expect(text).toContain("d.storage_key = 'kb/fork-' || d.id") + expect(text).toContain('d.user_excluded = false') + expect(text).toContain('d.archived_at IS NULL') + expect(text).toContain('d.deleted_at IS NULL') + expect(text).toContain('kb.deleted_at IS NULL') + expect(text).toContain('kb.workspace_id IS NOT NULL') + expect(text).toContain("bound.context = 'knowledge-base'") + expect(text).toContain('bound.workspace_id = kb.workspace_id') + expect(text).toContain('ORDER BY d.id LIMIT ?') + expect(query.values).toEqual(['fork_document_0', 250]) + }) + + it('inserts only uncontested bindings and reports every still-unbound candidate', async () => { + const harness = createSqlHarness({ + insertedKeys: ['kb/fork-fork_document_a'], + unresolved: 1, + }) + const store = createPostgresForkKnowledgeBaseOwnershipRepairStore(harness.sql) + + await expect(store.repairDocumentIds(['fork_document_a', 'fork_document_b'])).resolves.toEqual({ + inserted: 1, + unresolved: 1, + }) + + const insert = normalizeSql(harness.queries[0].text) + expect(insert).toContain('coalesce(d.uploaded_by, kb.user_id)') + expect(insert).toContain("d.id ~ '^fork_document_[0-9a-f]{40}$'") + expect(insert).toContain("d.storage_key = 'kb/fork-' || d.id") + expect(insert).toContain('other_document.storage_key = d.storage_key') + expect(insert).toContain('other_kb.workspace_id IS DISTINCT FROM kb.workspace_id') + expect(insert).toContain('active_binding.key = d.storage_key') + expect(insert).toContain('active_binding.deleted_at IS NULL') + expect(insert).toContain('ON CONFLICT DO NOTHING RETURNING key') + expect(harness.queries[0].values).toEqual([['fork_document_a', 'fork_document_b']]) + + const remaining = normalizeSql(harness.queries[1].text) + expect(remaining).toContain("d.id ~ '^fork_document_[0-9a-f]{40}$'") + expect(remaining).toContain("bound.context = 'knowledge-base'") + expect(remaining).toContain('bound.workspace_id = kb.workspace_id') + expect(harness.queries[1].values).toEqual([['fork_document_a', 'fork_document_b']]) + }) + + it('requires the post-drain acknowledgement and fails unresolved operator runs', () => { + expect(() => assertForkKnowledgeBaseOwnershipPostDrainAck(undefined)).toThrow( + 'only after old app instances are drained' + ) + expect(() => + assertForkKnowledgeBaseOwnershipPostDrainAck(FORK_KB_OWNERSHIP_POST_DRAIN_ACK) + ).not.toThrow() + expect(() => + assertForkKnowledgeBaseOwnershipFullyReconciled({ + scanned: 1, + inserted: 0, + unresolved: 1, + }) + ).toThrow('left 1 conflicting document(s) unresolved') + expect(() => + assertForkKnowledgeBaseOwnershipFullyReconciled({ + scanned: 1, + inserted: 1, + unresolved: 0, + }) + ).not.toThrow() + }) +}) diff --git a/packages/db/script-migrations/0004_backfill_fork_kb_file_ownership.ts b/packages/db/script-migrations/0004_backfill_fork_kb_file_ownership.ts new file mode 100644 index 00000000000..5fe0f0a24d1 --- /dev/null +++ b/packages/db/script-migrations/0004_backfill_fork_kb_file_ownership.ts @@ -0,0 +1,226 @@ +import type { Sql } from 'postgres' +import type { ScriptMigration } from './types' + +export const FORK_KB_OWNERSHIP_REPAIR_BATCH_SIZE = 250 +export const FORK_KB_OWNERSHIP_POST_DRAIN_ACK = 'old-apps-drained' + +export interface ForkKnowledgeBaseOwnershipRepairResult { + scanned: number + inserted: number + unresolved: number +} + +interface ForkKnowledgeBaseOwnershipRepairBatchResult { + inserted: number + unresolved: number +} + +export interface ForkKnowledgeBaseOwnershipRepairStore { + listCandidateDocumentIds(afterId: string, limit: number): Promise + repairDocumentIds( + documentIds: readonly string[] + ): Promise +} + +interface ForkKnowledgeBaseOwnershipRepairOptions { + batchSize?: number +} + +/** Validates the explicit operator acknowledgement required by the post-cutover rerun. */ +export function assertForkKnowledgeBaseOwnershipPostDrainAck(value: string | undefined): void { + if (value !== FORK_KB_OWNERSHIP_POST_DRAIN_ACK) { + throw new Error( + `Set KB_FORK_OWNERSHIP_RECONCILE_ACK=${FORK_KB_OWNERSHIP_POST_DRAIN_ACK} only after old app instances are drained` + ) + } +} + +/** Fails the operator-run reconciliation when any conflicting binding still needs review. */ +export function assertForkKnowledgeBaseOwnershipFullyReconciled( + result: ForkKnowledgeBaseOwnershipRepairResult +): void { + if (result.unresolved > 0) { + throw new Error( + `Fork knowledge-base ownership reconciliation left ${result.unresolved} conflicting document(s) unresolved` + ) + } +} + +/** + * Repairs only the deterministic KB-file bindings written by workspace forks. + * Candidate ids are keyset-paged and each page is committed before the next is + * loaded, keeping application memory and database transaction size bounded. + */ +export async function reconcileForkKnowledgeBaseFileOwnership( + store: ForkKnowledgeBaseOwnershipRepairStore, + options: ForkKnowledgeBaseOwnershipRepairOptions = {} +): Promise { + const batchSize = options.batchSize ?? FORK_KB_OWNERSHIP_REPAIR_BATCH_SIZE + if (!Number.isInteger(batchSize) || batchSize <= 0) { + throw new Error('Fork KB ownership repair batch size must be a positive integer') + } + + const result: ForkKnowledgeBaseOwnershipRepairResult = { + scanned: 0, + inserted: 0, + unresolved: 0, + } + let afterId = '' + + for (;;) { + const documentIds = await store.listCandidateDocumentIds(afterId, batchSize) + if (documentIds.length === 0) return result + if (documentIds.length > batchSize) { + throw new Error('Fork KB ownership repair store returned an oversized page') + } + + const lastId = documentIds.at(-1) + if (!lastId || lastId <= afterId) { + throw new Error('Fork KB ownership repair store returned a non-advancing page') + } + + const batch = await store.repairDocumentIds(documentIds) + result.scanned += documentIds.length + result.inserted += batch.inserted + result.unresolved += batch.unresolved + afterId = lastId + } +} + +/** Creates the PostgreSQL implementation used by both deploy-time and post-drain repair. */ +export function createPostgresForkKnowledgeBaseOwnershipRepairStore( + sql: Sql +): ForkKnowledgeBaseOwnershipRepairStore { + return { + async listCandidateDocumentIds(afterId, limit) { + const rows = await sql>` + SELECT d.id + FROM document d + INNER JOIN knowledge_base kb ON kb.id = d.knowledge_base_id + WHERE d.id > ${afterId} + AND d.id ~ '^fork_document_[0-9a-f]{40}$' + AND d.storage_key = 'kb/fork-' || d.id + AND d.user_excluded = false + AND d.archived_at IS NULL + AND d.deleted_at IS NULL + AND kb.deleted_at IS NULL + AND kb.workspace_id IS NOT NULL + AND NOT EXISTS ( + SELECT 1 + FROM workspace_files bound + WHERE bound.key = d.storage_key + AND bound.context = 'knowledge-base' + AND bound.workspace_id = kb.workspace_id + AND bound.deleted_at IS NULL + ) + ORDER BY d.id + LIMIT ${limit} + ` + return rows.map((row) => row.id) + }, + + async repairDocumentIds(documentIds) { + if (documentIds.length === 0) return { inserted: 0, unresolved: 0 } + + return sql.begin(async (tx) => { + const inserted = await tx>` + INSERT INTO workspace_files ( + id, + key, + user_id, + workspace_id, + context, + original_name, + display_name, + content_type, + size + ) + SELECT + gen_random_uuid()::text, + d.storage_key, + coalesce(d.uploaded_by, kb.user_id), + kb.workspace_id, + 'knowledge-base', + d.filename, + d.filename, + d.mime_type, + d.file_size + FROM document d + INNER JOIN knowledge_base kb ON kb.id = d.knowledge_base_id + WHERE d.id = ANY(${documentIds}::text[]) + AND d.id ~ '^fork_document_[0-9a-f]{40}$' + AND d.storage_key = 'kb/fork-' || d.id + AND d.user_excluded = false + AND d.archived_at IS NULL + AND d.deleted_at IS NULL + AND kb.deleted_at IS NULL + AND kb.workspace_id IS NOT NULL + AND NOT EXISTS ( + SELECT 1 + FROM document other_document + INNER JOIN knowledge_base other_kb + ON other_kb.id = other_document.knowledge_base_id + WHERE other_document.storage_key = d.storage_key + AND other_document.user_excluded = false + AND other_document.archived_at IS NULL + AND other_document.deleted_at IS NULL + AND other_kb.deleted_at IS NULL + AND other_kb.workspace_id IS DISTINCT FROM kb.workspace_id + ) + AND NOT EXISTS ( + SELECT 1 + FROM workspace_files active_binding + WHERE active_binding.key = d.storage_key + AND active_binding.deleted_at IS NULL + ) + ON CONFLICT DO NOTHING + RETURNING key + ` + + const [remaining] = await tx>` + SELECT count(*) AS count + FROM document d + INNER JOIN knowledge_base kb ON kb.id = d.knowledge_base_id + WHERE d.id = ANY(${documentIds}::text[]) + AND d.id ~ '^fork_document_[0-9a-f]{40}$' + AND d.storage_key = 'kb/fork-' || d.id + AND d.user_excluded = false + AND d.archived_at IS NULL + AND d.deleted_at IS NULL + AND kb.deleted_at IS NULL + AND kb.workspace_id IS NOT NULL + AND NOT EXISTS ( + SELECT 1 + FROM workspace_files bound + WHERE bound.key = d.storage_key + AND bound.context = 'knowledge-base' + AND bound.workspace_id = kb.workspace_id + AND bound.deleted_at IS NULL + ) + ` + + return { + inserted: inserted.length, + unresolved: Number(remaining?.count ?? 0), + } + }) + }, + } +} + +export const backfillForkKnowledgeBaseFileOwnership: ScriptMigration = { + name: '0004_backfill_fork_kb_file_ownership', + async up(sql) { + const result = await reconcileForkKnowledgeBaseFileOwnership( + createPostgresForkKnowledgeBaseOwnershipRepairStore(sql) + ) + console.log( + `fork KB ownership repair — scanned: ${result.scanned}, inserted: ${result.inserted}, unresolved: ${result.unresolved}` + ) + if (result.unresolved > 0) { + console.warn( + `fork KB ownership repair left ${result.unresolved} conflicting document(s) denied; run the post-drain reconciliation and audit any remaining conflicts` + ) + } + }, +} diff --git a/packages/db/script-migrations/index.ts b/packages/db/script-migrations/index.ts index 8bc927b616b..f480b5652c3 100644 --- a/packages/db/script-migrations/index.ts +++ b/packages/db/script-migrations/index.ts @@ -2,6 +2,7 @@ import type { Sql } from 'postgres' import { backfillTableOrderKeys } from './0001_backfill_table_order_keys' import { backfillPausedBillingAttribution } from './0002_backfill_paused_billing_attribution' import { backfillWorkspaceStorageUsage } from './0003_backfill_workspace_storage_usage' +import { backfillForkKnowledgeBaseFileOwnership } from './0004_backfill_fork_kb_file_ownership' import type { ScriptMigration } from './types' export type { ScriptMigration } from './types' @@ -15,6 +16,7 @@ export const scriptMigrations: readonly ScriptMigration[] = [ backfillTableOrderKeys, backfillPausedBillingAttribution, backfillWorkspaceStorageUsage, + backfillForkKnowledgeBaseFileOwnership, ] /** diff --git a/packages/db/scripts/reconcile-fork-kb-file-ownership.ts b/packages/db/scripts/reconcile-fork-kb-file-ownership.ts new file mode 100644 index 00000000000..6b3b235a41d --- /dev/null +++ b/packages/db/scripts/reconcile-fork-kb-file-ownership.ts @@ -0,0 +1,33 @@ +import { createLogger } from '@sim/logger' +import postgres from 'postgres' +import { + assertForkKnowledgeBaseOwnershipFullyReconciled, + assertForkKnowledgeBaseOwnershipPostDrainAck, + createPostgresForkKnowledgeBaseOwnershipRepairStore, + reconcileForkKnowledgeBaseFileOwnership, +} from '../script-migrations/0004_backfill_fork_kb_file_ownership' + +const logger = createLogger('ForkKnowledgeBaseOwnershipReconciliation') +const url = process.env.MIGRATION_DATABASE_URL || process.env.DATABASE_URL + +if (!url) { + throw new Error('Missing MIGRATION_DATABASE_URL or DATABASE_URL') +} +assertForkKnowledgeBaseOwnershipPostDrainAck(process.env.KB_FORK_OWNERSHIP_RECONCILE_ACK) + +const sql = postgres(url, { + max: 1, + connect_timeout: 10, + max_lifetime: null, + connection: { application_name: 'sim-fork-kb-ownership-reconcile' }, +}) + +try { + const result = await reconcileForkKnowledgeBaseFileOwnership( + createPostgresForkKnowledgeBaseOwnershipRepairStore(sql) + ) + logger.info('Fork knowledge-base ownership reconciliation completed', result) + assertForkKnowledgeBaseOwnershipFullyReconciled(result) +} finally { + await sql.end() +} diff --git a/packages/testing/src/mocks/logging-session.mock.ts b/packages/testing/src/mocks/logging-session.mock.ts index cb3ee83431f..c01f1696489 100644 --- a/packages/testing/src/mocks/logging-session.mock.ts +++ b/packages/testing/src/mocks/logging-session.mock.ts @@ -27,6 +27,11 @@ export const loggingSessionMockFns = { mockWaitForPostExecution: vi.fn().mockResolvedValue(undefined), mockSetTrustedExecutionCorrelation: vi.fn(), mockSetExecutionDeadlineAt: vi.fn(), + mockExportResolvedSecretTraceProvenanceForValue: vi.fn().mockReturnValue({ + version: 1, + complete: false, + entries: [], + }), mockProjectBlockLogsForDisplay: vi.fn(async (logs: unknown) => logs), mockProjectDisplayContent: vi.fn(async (content: unknown) => content), mockProjectLiveDisplayText: vi.fn(async (_field: string, value: string) => ({ value })), @@ -59,6 +64,8 @@ function buildLoggingSessionInstance() { waitForPostExecution: loggingSessionMockFns.mockWaitForPostExecution, setTrustedExecutionCorrelation: loggingSessionMockFns.mockSetTrustedExecutionCorrelation, setExecutionDeadlineAt: loggingSessionMockFns.mockSetExecutionDeadlineAt, + exportResolvedSecretTraceProvenanceForValue: + loggingSessionMockFns.mockExportResolvedSecretTraceProvenanceForValue, projectBlockLogsForDisplay: loggingSessionMockFns.mockProjectBlockLogsForDisplay, projectDisplayContent: loggingSessionMockFns.mockProjectDisplayContent, projectLiveDisplayText: loggingSessionMockFns.mockProjectLiveDisplayText,