From 71d7d8da566e2c3a2427835b0f441eaa9a2abe5d Mon Sep 17 00:00:00 2001 From: Vikhyath Mondreti Date: Thu, 6 Aug 2026 03:55:25 -0700 Subject: [PATCH] fix(tools): align private provenance with wire payloads (#6325) * fix(tools): align private provenance with wire payloads * fix(execution): separate provenance source from actor --- .../api/knowledge/secret-provenance.test.ts | 100 +++++++++++++++--- .../app/api/memory/secret-provenance.test.ts | 88 +++++++++++++++ .../api/table/row-secret-provenance.test.ts | 39 ++++++- .../app/api/table/row-secret-provenance.ts | 7 +- .../app/api/tools/file/manage/route.test.ts | 39 ++++++- apps/sim/app/api/tools/file/manage/route.ts | 4 +- .../resolved-secret-trace-registry.test.ts | 14 ++- .../durable-secret-provenance.test.ts | 84 +++++++++++++++ .../execution/durable-secret-provenance.ts | 20 +++- .../table/secret-provenance-selection.test.ts | 56 ++++++++++ .../lib/table/secret-provenance-selection.ts | 12 ++- apps/sim/tools/index.test.ts | 90 ++++++++++++++++ apps/sim/tools/index.ts | 16 +-- 13 files changed, 530 insertions(+), 39 deletions(-) create mode 100644 apps/sim/lib/table/secret-provenance-selection.test.ts diff --git a/apps/sim/app/api/knowledge/secret-provenance.test.ts b/apps/sim/app/api/knowledge/secret-provenance.test.ts index cc4a6e58d36..75467c52dcd 100644 --- a/apps/sim/app/api/knowledge/secret-provenance.test.ts +++ b/apps/sim/app/api/knowledge/secret-provenance.test.ts @@ -38,6 +38,25 @@ function createHeaderlessRequest(payload: Record): NextRequest }) } +function privateChunkPayload( + scope: { userId: string; workspaceId?: string }, + entries: Array<{ name: string; encryptedValue: string }> = [] +) { + return { + content: 'workflow content', + [PRIVATE_SECRET_PROVENANCE_FIELD]: { + version: 1 as const, + complete: true, + selections: [ + { + key: 'chunk-content', + provenance: { version: 1 as const, complete: true, entries, scope }, + }, + ], + }, + } +} + describe('knowledge write secret provenance', () => { it('classifies a headerless external chunk write as exact-empty', () => { const payload = { content: 'manual content' } @@ -99,22 +118,7 @@ describe('knowledge write secret provenance', () => { }) it('tracks exact-empty provenance only when an internal write supplies a verified envelope', () => { - const bundle = { - version: 1 as const, - complete: true, - selections: [ - { - key: 'chunk-content', - provenance: { - version: 1 as const, - complete: true, - entries: [], - scope: PRIVATE_PROVENANCE_SCOPE, - }, - }, - ], - } - const payload = { content: 'workflow content', [PRIVATE_SECRET_PROVENANCE_FIELD]: bundle } + const payload = privateChunkPayload(PRIVATE_PROVENANCE_SCOPE) const result = resolveKnowledgeWriteSecretProvenance({ request: createRequest(payload), @@ -131,6 +135,70 @@ describe('knowledge write secret provenance', () => { }) }) + it('accepts a different provenance source user in the destination workspace', () => { + const payload = privateChunkPayload({ userId: 'workflow-owner', workspaceId: 'workspace-1' }, [ + { name: 'TOKEN', encryptedValue: 'encrypted-token' }, + ]) + + expect( + resolveKnowledgeWriteSecretProvenance({ + request: createRequest(payload), + payload, + authType: AuthType.INTERNAL_JWT, + userId: 'billing-actor', + workspaceId: 'workspace-1', + selectionKeys: ['chunk-content'], + }) + ).toEqual({ + success: true, + provenances: [ + { + status: 'exact', + entries: [ + { + name: 'TOKEN', + encryptedValue: 'encrypted-token', + sourceUserId: 'workflow-owner', + sourceWorkspaceId: 'workspace-1', + }, + ], + }, + ], + }) + }) + + it('rejects provenance from another workspace', () => { + const payload = privateChunkPayload({ + userId: 'workflow-owner', + workspaceId: 'workspace-2', + }) + const result = resolveKnowledgeWriteSecretProvenance({ + request: createRequest(payload), + payload, + authType: AuthType.INTERNAL_JWT, + userId: 'billing-actor', + workspaceId: 'workspace-1', + selectionKeys: ['chunk-content'], + }) + + expect(result.success).toBe(false) + if (!result.success) expect(result.response.status).toBe(400) + }) + + it('keeps workspace-less knowledge writes isolated to the authenticated user', () => { + const payload = privateChunkPayload({ userId: 'workflow-owner' }) + const result = resolveKnowledgeWriteSecretProvenance({ + request: createRequest(payload), + payload, + authType: AuthType.INTERNAL_JWT, + userId: 'billing-actor', + selectionKeys: ['chunk-content'], + }) + + expect(result.success).toBe(false) + if (!result.success) expect(result.response.status).toBe(400) + }) + it('rejects a private provenance envelope from an external caller', () => { const bundle = { version: 1 as const, diff --git a/apps/sim/app/api/memory/secret-provenance.test.ts b/apps/sim/app/api/memory/secret-provenance.test.ts index 4b139c1695c..c2ce6b51059 100644 --- a/apps/sim/app/api/memory/secret-provenance.test.ts +++ b/apps/sim/app/api/memory/secret-provenance.test.ts @@ -20,6 +20,30 @@ import { resolveMemoryWriteSecretProvenance, } from '@/app/api/memory/secret-provenance' +function privateMemoryWrite( + scope: { userId: string; workspaceId?: string }, + entries: Array<{ name: string; encryptedValue: string }> = [] +) { + const payload = { + [PRIVATE_SECRET_PROVENANCE_FIELD]: { + version: 1 as const, + complete: true, + selections: [ + { + key: 'data', + provenance: { version: 1 as const, complete: true, entries, scope }, + }, + ], + }, + } + const request = new NextRequest('http://localhost/api/memory', { + method: 'POST', + headers: { [PRIVATE_SECRET_PROVENANCE_HEADER]: PRIVATE_SECRET_PROVENANCE_BUNDLE_V1 }, + body: JSON.stringify(payload), + }) + return { payload, request } +} + describe('memory write secret provenance', () => { beforeEach(() => { resetDbChainMock() @@ -83,6 +107,70 @@ describe('memory write secret provenance', () => { if (!result.success) expect(result.response.status).toBe(400) }) + it('accepts exact-empty provenance from the workflow owner in the actor workspace', () => { + const { payload, request } = privateMemoryWrite({ + userId: 'workflow-owner', + workspaceId: 'workspace-1', + }) + + expect( + resolveMemoryWriteSecretProvenance({ + request, + payload, + authType: AuthType.INTERNAL_JWT, + userId: 'billing-actor', + workspaceId: 'workspace-1', + }) + ).toEqual({ success: true, provenance: { status: 'exact', entries: [] } }) + }) + + it('preserves the workflow owner as the source of same-workspace provenance', () => { + const { payload, request } = privateMemoryWrite( + { userId: 'workflow-owner', workspaceId: 'workspace-1' }, + [{ name: 'TOKEN', encryptedValue: 'encrypted-token' }] + ) + + expect( + resolveMemoryWriteSecretProvenance({ + request, + payload, + authType: AuthType.INTERNAL_JWT, + userId: 'billing-actor', + workspaceId: 'workspace-1', + }) + ).toEqual({ + success: true, + provenance: { + status: 'exact', + entries: [ + { + name: 'TOKEN', + encryptedValue: 'encrypted-token', + sourceUserId: 'workflow-owner', + sourceWorkspaceId: 'workspace-1', + }, + ], + }, + }) + }) + + it('rejects provenance from another workspace', () => { + const { payload, request } = privateMemoryWrite({ + userId: 'workflow-owner', + workspaceId: 'workspace-2', + }) + const result = resolveMemoryWriteSecretProvenance({ + request, + payload, + authType: AuthType.INTERNAL_JWT, + userId: 'billing-actor', + workspaceId: 'workspace-1', + }) + + expect(result.success).toBe(false) + if (!result.success) expect(result.response.status).toBe(400) + }) + it('bounds only requested private response provenance without querying sidecars', async () => { const request = new NextRequest('http://localhost/api/memory', { headers: { diff --git a/apps/sim/app/api/table/row-secret-provenance.test.ts b/apps/sim/app/api/table/row-secret-provenance.test.ts index cdbbd29cdda..f84a74442d2 100644 --- a/apps/sim/app/api/table/row-secret-provenance.test.ts +++ b/apps/sim/app/api/table/row-secret-provenance.test.ts @@ -188,7 +188,7 @@ describe('resolveTableWriteSecretProvenance', () => { expect(result.success).toBe(false) }) - it('rejects a bundle whose selection scope does not match the caller', () => { + it('accepts a different source user in the authorized destination workspace', () => { const rows = [{ email: 'a@b.c' }] const payload = { [PRIVATE_SECRET_PROVENANCE_FIELD]: { @@ -218,6 +218,43 @@ describe('resolveTableWriteSecretProvenance', () => { rowKeys: ['0'], }) + expect(result.success).toBe(true) + if (!result.success) return + expect(result.provenanceByRowKey?.['0'].columns.col_email).toMatchObject({ + scope: { userId: 'someone-else', workspaceId: WORKSPACE_ID }, + }) + }) + + it('rejects a bundle whose selection comes from another workspace', () => { + const rows = [{ email: 'a@b.c' }] + const payload = { + [PRIVATE_SECRET_PROVENANCE_FIELD]: { + version: 1, + complete: true, + selections: [ + { + key: tableRowSecretProvenanceSelectionKey(0, 'email'), + provenance: { + ...traceProvenance(), + scope: { userId: USER_ID, workspaceId: 'another-workspace' }, + }, + }, + ], + }, + } + + const result = resolveTableWriteSecretProvenance({ + request: createMockRequest('POST', payload, { + [PRIVATE_SECRET_PROVENANCE_HEADER]: PRIVATE_SECRET_PROVENANCE_BUNDLE_V1, + }), + payload, + authType: AuthType.INTERNAL_JWT, + userId: USER_ID, + workspaceId: WORKSPACE_ID, + targets: createTableWriteProvenanceTargets(rows, translateNames), + rowKeys: ['0'], + }) + expect(result.success).toBe(false) }) }) diff --git a/apps/sim/app/api/table/row-secret-provenance.ts b/apps/sim/app/api/table/row-secret-provenance.ts index 1b3a843d975..9d72ce58990 100644 --- a/apps/sim/app/api/table/row-secret-provenance.ts +++ b/apps/sim/app/api/table/row-secret-provenance.ts @@ -1,5 +1,6 @@ import { type NextRequest, NextResponse } from 'next/server' import { AuthType, type AuthTypeValue } from '@/lib/auth/hybrid' +import { isPrivateSecretProvenanceScopeCompatible } from '@/lib/execution/durable-secret-provenance' import { inspectPrivateSecretProvenanceRequest, isPrivateSecretProvenanceBundleV1, @@ -138,8 +139,10 @@ export function resolveTableWriteSecretProvenance(options: { const target = targetBySelectionKey.get(selection.key) if ( !target || - selection.provenance.scope?.userId !== options.userId || - selection.provenance.scope?.workspaceId !== options.workspaceId + !isPrivateSecretProvenanceScopeCompatible(selection.provenance.scope, { + userId: options.userId, + workspaceId: options.workspaceId, + }) ) { return { success: false, response: invalidProvenanceResponse() } } 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 49268df4344..27fbdb18858 100644 --- a/apps/sim/app/api/tools/file/manage/route.test.ts +++ b/apps/sim/app/api/tools/file/manage/route.test.ts @@ -192,7 +192,7 @@ describe('POST /api/tools/file/manage content provenance', () => { }) }) - it('stores exact causal provenance for a trusted file write', async () => { + it('stores exact causal provenance from a different user in the actor workspace', async () => { const response = await POST( createMockRequest( 'POST', @@ -211,7 +211,7 @@ describe('POST /api/tools/file/manage content provenance', () => { version: 1, complete: true, entries: [{ name: 'TOKEN', encryptedValue: 'encrypted-token' }], - scope: { userId: 'user-1', workspaceId: 'workspace-1' }, + scope: { userId: 'workflow-owner', workspaceId: 'workspace-1' }, }, }, ], @@ -236,7 +236,7 @@ describe('POST /api/tools/file/manage content provenance', () => { { name: 'TOKEN', encryptedValue: 'encrypted-token', - sourceUserId: 'user-1', + sourceUserId: 'workflow-owner', sourceWorkspaceId: 'workspace-1', }, ], @@ -245,6 +245,39 @@ describe('POST /api/tools/file/manage content provenance', () => { ) }) + it('rejects file-write provenance from another workspace', async () => { + const response = await POST( + createMockRequest( + 'POST', + { + operation: 'write', + workspaceId: 'workspace-1', + fileName: 'new.txt', + content: 'secret-value', + __privateSecretProvenance: { + version: 1, + complete: true, + selections: [ + { + key: 'content', + provenance: { + version: 1, + complete: true, + entries: [{ name: 'TOKEN', encryptedValue: 'encrypted-token' }], + scope: { userId: 'workflow-owner', workspaceId: 'workspace-2' }, + }, + }, + ], + }, + }, + PRIVATE_SECRET_PROVENANCE_HEADER + ) + ) + + expect(response.status).toBe(400) + expect(mockUploadWorkspaceFile).not.toHaveBeenCalled() + }) + it('preserves existing file-path behavior when a filename was resolved from a secret', async () => { const response = await POST( createMockRequest( diff --git a/apps/sim/app/api/tools/file/manage/route.ts b/apps/sim/app/api/tools/file/manage/route.ts index a42cdaab8a4..1af9a5f4c70 100644 --- a/apps/sim/app/api/tools/file/manage/route.ts +++ b/apps/sim/app/api/tools/file/manage/route.ts @@ -337,13 +337,13 @@ function resolveFileMutationSecretProvenance(options: { return { success: false, error: 'Invalid file secret provenance' } } - const expectedScope = { userId: options.userId, workspaceId: options.workspaceId } + const destinationScope = { userId: options.userId, workspaceId: options.workspaceId } const provenanceBySelection = new Map() for (const selectionKey of options.selectionKeys) { const provenance = durableSecretProvenanceFromPrivateBundle( inspection.value, selectionKey, - expectedScope + destinationScope ) if ( !provenance || 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 809b9a5dda9..91269f82c6a 100644 --- a/apps/sim/executor/utils/resolved-secret-trace-registry.test.ts +++ b/apps/sim/executor/utils/resolved-secret-trace-registry.test.ts @@ -516,6 +516,10 @@ describe('ResolvedSecretTraceRegistry', () => { userId: 'user-1', workspaceId: 'workspace-2', }) + const differentUserSameWorkspace = new ResolvedSecretTraceRegistry([], { + userId: 'user-2', + workspaceId: 'workspace-1', + }) const missingReceiverScope = new ResolvedSecretTraceRegistry() const missingSourceScope = new ResolvedSecretTraceRegistry([], { userId: 'user-1', @@ -524,6 +528,9 @@ describe('ResolvedSecretTraceRegistry', () => { expect(await sameScope.importProvenance(provenance, { trusted: true })).toBe(true) expect(await mismatchedScope.importProvenance(provenance, { trusted: true })).toBe(true) + expect(await differentUserSameWorkspace.importProvenance(provenance, { trusted: true })).toBe( + true + ) expect(await missingReceiverScope.importProvenance(provenance, { trusted: true })).toBe(true) expect( await missingSourceScope.importProvenance( @@ -535,7 +542,12 @@ describe('ResolvedSecretTraceRegistry', () => { expect(sameScope.getActiveMatches()).toEqual([ { plaintext: 'decrypted:ciphertext', replacement: '{{TOKEN}}' }, ]) - for (const registry of [mismatchedScope, missingReceiverScope, missingSourceScope]) { + for (const registry of [ + mismatchedScope, + differentUserSameWorkspace, + missingReceiverScope, + missingSourceScope, + ]) { expect(registry.getActiveMatches()).toEqual([ { plaintext: 'decrypted:ciphertext', diff --git a/apps/sim/lib/execution/durable-secret-provenance.test.ts b/apps/sim/lib/execution/durable-secret-provenance.test.ts index 14a1c787b33..75d75d22d38 100644 --- a/apps/sim/lib/execution/durable-secret-provenance.test.ts +++ b/apps/sim/lib/execution/durable-secret-provenance.test.ts @@ -3,10 +3,29 @@ */ import { describe, expect, it } from 'vitest' import { + durableSecretProvenanceFromPrivateBundle, filterDurableSecretProvenanceBySourceValues, hashDurableSecretProvenanceValue, } from '@/lib/execution/durable-secret-provenance' +function privateBundle(scope?: { userId: string; workspaceId?: string }) { + return { + version: 1 as const, + complete: true, + selections: [ + { + key: 'value', + provenance: { + version: 1 as const, + complete: true, + entries: [{ name: 'TOKEN', encryptedValue: 'encrypted-token' }], + ...(scope ? { scope } : {}), + }, + }, + ], + } +} + describe('durable secret provenance hashing', () => { it('hashes equivalent plain JSON deterministically without key-order sensitivity', () => { expect(hashDurableSecretProvenanceValue({ b: [true, null], a: 'value' })).toBe( @@ -49,3 +68,68 @@ describe('durable secret provenance hashing', () => { ).toEqual({ status: 'exact', entries: [] }) }) }) + +describe('private durable provenance scope admission', () => { + it('accepts a different source user in the authorized destination workspace', () => { + expect( + durableSecretProvenanceFromPrivateBundle( + privateBundle({ userId: 'workflow-owner', workspaceId: 'workspace-1' }), + 'value', + { userId: 'billing-actor', workspaceId: 'workspace-1' } + ) + ).toEqual({ + status: 'exact', + entries: [ + { + name: 'TOKEN', + encryptedValue: 'encrypted-token', + sourceUserId: 'workflow-owner', + sourceWorkspaceId: 'workspace-1', + }, + ], + }) + }) + + it('rejects a source from another or no workspace', () => { + expect( + durableSecretProvenanceFromPrivateBundle( + privateBundle({ userId: 'workflow-owner', workspaceId: 'workspace-2' }), + 'value', + { userId: 'billing-actor', workspaceId: 'workspace-1' } + ) + ).toBeUndefined() + expect( + durableSecretProvenanceFromPrivateBundle( + privateBundle({ userId: 'workflow-owner' }), + 'value', + { userId: 'billing-actor', workspaceId: 'workspace-1' } + ) + ).toBeUndefined() + expect( + durableSecretProvenanceFromPrivateBundle(privateBundle(), 'value', { + userId: 'billing-actor', + workspaceId: 'workspace-1', + }) + ).toBeUndefined() + }) + + it('keeps workspace-less destinations isolated to the authenticated user', () => { + expect( + durableSecretProvenanceFromPrivateBundle(privateBundle({ userId: 'user-1' }), 'value', { + userId: 'user-1', + }) + ).toMatchObject({ status: 'exact' }) + expect( + durableSecretProvenanceFromPrivateBundle(privateBundle({ userId: 'someone-else' }), 'value', { + userId: 'user-1', + }) + ).toBeUndefined() + expect( + durableSecretProvenanceFromPrivateBundle( + privateBundle({ userId: 'user-1', workspaceId: 'workspace-1' }), + 'value', + { userId: 'user-1' } + ) + ).toBeUndefined() + }) +}) diff --git a/apps/sim/lib/execution/durable-secret-provenance.ts b/apps/sim/lib/execution/durable-secret-provenance.ts index 51ea26e2232..334f4cfa07d 100644 --- a/apps/sim/lib/execution/durable-secret-provenance.ts +++ b/apps/sim/lib/execution/durable-secret-provenance.ts @@ -112,11 +112,27 @@ export function durableSecretProvenanceFromRegistry( return durableSecretProvenanceFromEnvelope(registry.exportCommittedProvenanceForValue(value)) } +/** + * Checks whether a provenance source may cross into an already-authorized destination. + * Workspace resources accept sources from any user in that same workspace; personal resources + * remain restricted to the authenticated user and cannot accept workspace-scoped provenance. + */ +export function isPrivateSecretProvenanceScopeCompatible( + sourceScope: ResolvedSecretTraceScopeV1 | undefined, + destinationScope: { userId: string; workspaceId?: string } +): sourceScope is ResolvedSecretTraceScopeV1 { + if (!sourceScope) return false + if (destinationScope.workspaceId !== undefined) { + return sourceScope.workspaceId === destinationScope.workspaceId + } + return sourceScope.userId === destinationScope.userId && sourceScope.workspaceId === undefined +} + /** Reads one authenticated private selection without exposing it through the functional payload. */ export function durableSecretProvenanceFromPrivateBundle( value: unknown, selectionKey: string, - expectedScope: { userId: string; workspaceId?: string } + destinationScope: { userId: string; workspaceId?: string } ): DurableSecretProvenance | undefined { if (!isPrivateSecretProvenanceBundleV1(value)) return undefined const bundle: PrivateSecretProvenanceBundleV1 = value @@ -127,7 +143,7 @@ export function durableSecretProvenanceFromPrivateBundle( } const selection = selections[0] const scope = selection.provenance.scope - if (scope?.userId !== expectedScope.userId || scope.workspaceId !== expectedScope.workspaceId) { + if (!isPrivateSecretProvenanceScopeCompatible(scope, destinationScope)) { return undefined } return durableSecretProvenanceFromEnvelope(selection.provenance) diff --git a/apps/sim/lib/table/secret-provenance-selection.test.ts b/apps/sim/lib/table/secret-provenance-selection.test.ts new file mode 100644 index 00000000000..f54a59a835e --- /dev/null +++ b/apps/sim/lib/table/secret-provenance-selection.test.ts @@ -0,0 +1,56 @@ +/** + * @vitest-environment node + */ +import { describe, expect, it } from 'vitest' +import { PRIVATE_SECRET_PROVENANCE_FIELD } from '@/lib/execution/private-tool-metadata' +import { selectTableRowSecretProvenance } from '@/lib/table/secret-provenance-selection' +import { ResolvedSecretTraceRegistry } from '@/executor/utils/resolved-secret-trace-registry' +import { prepareToolRequest } from '@/tools/request-transport' +import { tableBatchInsertRowsTool } from '@/tools/table/batch_insert_rows' + +interface TableWriteRequestBody { + rows: Record[] + [PRIVATE_SECRET_PROVENANCE_FIELD]: { + selections: Array<{ key: string }> + } +} + +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' }, + ]) + + 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' }, + ]) + }) + + it('keeps selection keys aligned with the serialized request body', () => { + const request = prepareToolRequest( + tableBatchInsertRowsTool, + { + tableId: 'table-1', + rows: [{ email: 'user@example.com', status: 'queued', processed_at: undefined }], + _context: { workspaceId: 'workspace-1' }, + }, + new ResolvedSecretTraceRegistry([], { + userId: 'user-1', + workspaceId: 'workspace-1', + }) + ) + const body = JSON.parse(request.body ?? '') as TableWriteRequestBody + const wireSelectionKeys = body.rows.flatMap((row, rowIndex) => + Object.keys(row).map((columnKey) => JSON.stringify([rowIndex, columnKey])) + ) + + expect(body.rows).toEqual([{ email: 'user@example.com', status: 'queued' }]) + expect( + body[PRIVATE_SECRET_PROVENANCE_FIELD].selections.map((selection) => selection.key) + ).toEqual(wireSelectionKeys) + }) +}) diff --git a/apps/sim/lib/table/secret-provenance-selection.ts b/apps/sim/lib/table/secret-provenance-selection.ts index 6732f504412..a9bb19f5a1f 100644 --- a/apps/sim/lib/table/secret-provenance-selection.ts +++ b/apps/sim/lib/table/secret-provenance-selection.ts @@ -3,13 +3,15 @@ import type { RowData } from '@/lib/table/types' /** Stable keyed selections shared by table tool descriptors and authenticated routes. */ export function selectTableRowSecretProvenance( - rows: readonly RowData[] + rows: readonly Partial[] ): PrivateSecretProvenanceSelection[] { return rows.flatMap((row, rowIndex) => - Object.entries(row).map(([columnKey, value]) => ({ - key: tableRowSecretProvenanceSelectionKey(rowIndex, columnKey), - value, - })) + Object.entries(row) + .filter(([, value]) => value !== undefined) + .map(([columnKey, value]) => ({ + key: tableRowSecretProvenanceSelectionKey(rowIndex, columnKey), + value, + })) ) } diff --git a/apps/sim/tools/index.test.ts b/apps/sim/tools/index.test.ts index d0a8b824f19..a0e4f2d90cc 100644 --- a/apps/sim/tools/index.test.ts +++ b/apps/sim/tools/index.test.ts @@ -35,6 +35,8 @@ import { ResolvedSecretTraceRegistry, } from '@/executor/utils/resolved-secret-trace-registry' import { fileGetContentTool } from '@/tools/file/get' +import { memoryAddTool } from '@/tools/memory/add' +import { tableBatchInsertRowsTool } from '@/tools/table/batch_insert_rows' import { workflowExecutorTool } from '@/tools/workflow/executor' // Hoisted mock state - these are available to vi.mock factories @@ -123,6 +125,8 @@ vi.mock('@/lib/uploads/contexts/workspace/workspace-file-secret-provenance', () const mockRegistryTools: Record = { workflow_executor: workflowExecutorTool, file_get_content: fileGetContentTool, + memory_add: memoryAddTool, + table_batch_insert_rows: tableBatchInsertRowsTool, http_request: { id: 'http_request', name: 'HTTP Request', @@ -755,6 +759,92 @@ describe('executeTool Function', () => { ]) }) + it.each([ + { + name: 'table propagate policy', + toolId: 'table_batch_insert_rows', + status: 400, + params: { + tableId: 'table-1', + rows: [{ name: 'duplicate' }], + _context: { userId: 'user-1', workspaceId: 'workspace-1' }, + }, + }, + { + name: 'memory isolated policy', + toolId: 'memory_add', + status: 500, + params: { + id: 'memory-1', + role: 'user', + content: { owner: 'user-1' }, + _context: { userId: 'user-1', workspaceId: 'workspace-1' }, + }, + }, + ])( + 'preserves an unverified error status for the $name without exposing its body or headers', + async ({ toolId, status, params }) => { + const registry = new ResolvedSecretTraceRegistry([], { + userId: 'user-1', + workspaceId: 'workspace-1', + }) + const untrustedDetail = 'route-secret-plaintext' + const untrustedHeader = 'route-secret-header-value' + global.fetch = Object.assign( + vi.fn().mockResolvedValue( + new Response(JSON.stringify({ error: untrustedDetail }), { + status, + headers: { + 'content-type': 'application/json', + 'x-route-error-detail': untrustedHeader, + }, + }) + ), + { preconnect: vi.fn() } + ) 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, + }) + 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) + expect(registry.isComplete()).toBe(true) + } + ) + + it('maps an unverified non-error HTTP status to a metadata failure', async () => { + const registry = new ResolvedSecretTraceRegistry() + global.fetch = Object.assign(vi.fn().mockResolvedValue(new Response(null, { status: 304 })), { + preconnect: vi.fn(), + }) as typeof fetch + + const result = await executeTool( + 'function_execute', + { code: 'return "unreachable"', envVars: {} }, + { 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(registry.isComplete()).toBe(true) + }) + it('contains incomplete File Get Content provenance within that tool call', async () => { const registry = new ResolvedSecretTraceRegistry([], { userId: 'user-1', diff --git a/apps/sim/tools/index.ts b/apps/sim/tools/index.ts index 86f04360b33..ca5851a73c2 100644 --- a/apps/sim/tools/index.ts +++ b/apps/sim/tools/index.ts @@ -1252,18 +1252,20 @@ function rebuildResponseWithoutPrivateToolMetadata( } function rebuildSafePrivateToolResponse(response: Response): Response { - const headers = new Headers(response.headers) - headers.delete('content-length') - headers.delete(PRIVATE_TOOL_METADATA_RESPONSE_HEADER) - headers.set('content-type', 'application/json') + const hasHttpErrorStatus = response.status >= 400 && response.status <= 599 + const status = hasHttpErrorStatus ? response.status : 502 + const error = hasHttpErrorStatus + ? `Internal tool request failed (HTTP ${response.status})` + : PRIVATE_TOOL_METADATA_ERROR_MESSAGE + const headers = new Headers({ 'content-type': 'application/json' }) return new Response( JSON.stringify({ success: false, - error: PRIVATE_TOOL_METADATA_ERROR_MESSAGE, + error, }), { - status: 502, - statusText: 'Bad Gateway', + status, + ...(!hasHttpErrorStatus ? { statusText: 'Bad Gateway' } : {}), headers, } )