From 73cff4cc4c8c56b78b956cffff615b1c25e4547c Mon Sep 17 00:00:00 2001 From: Vikhyath Mondreti Date: Sun, 4 Oct 2026 12:09:36 -0700 Subject: [PATCH 1/3] fix(files): retain known lineage through binary exports and extraction --- .../mounted-file-secret-provenance.test.ts | 54 ++- .../mounted-file-secret-provenance.ts | 36 +- .../execute-request.test.ts | 349 ++++++++++++------ .../lib/function-execution/execute-request.ts | 143 ++++--- ...xecution-archive-provenance.integration.ts | 66 +++- .../workbench-confidentiality.live.test.ts | 79 ++++ apps/sim/lib/uploads/archive.test.ts | 24 -- apps/sim/lib/uploads/archive.ts | 11 +- 8 files changed, 478 insertions(+), 284 deletions(-) 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 2404d29e1a9..ae2dcf537a9 100644 --- a/apps/sim/lib/execution/mounted-file-secret-provenance.test.ts +++ b/apps/sim/lib/execution/mounted-file-secret-provenance.test.ts @@ -12,7 +12,7 @@ describe('mounted file output provenance scanner', () => { })) }) - it('classifies only mounted secrets present in the exact exported bytes', async () => { + it('retains full mounted lineage while narrowing text exports to matching literals', async () => { const scanner = await createMountedFileSecretProvenanceScanner({ version: 1, complete: true, @@ -23,6 +23,23 @@ describe('mounted file output provenance scanner', () => { scope: { userId: 'user-1', workspaceId: 'workspace-1' }, }) + expect(scanner?.provenance).toEqual({ + status: 'exact', + entries: [ + { + name: 'MOUNTED_FILE_SECRET', + encryptedValue: 'encrypted-a', + sourceUserId: 'user-1', + sourceWorkspaceId: 'workspace-1', + }, + { + name: 'ORIGINAL_NAME', + encryptedValue: 'encrypted-b', + sourceUserId: 'user-1', + sourceWorkspaceId: 'workspace-1', + }, + ], + }) expect(scanner?.scan(Buffer.from('ordinary output'))).toEqual({ status: 'exact', entries: [] }) expect(scanner?.scan(Buffer.from('prefix first secret suffix'))).toEqual({ status: 'exact', @@ -48,26 +65,18 @@ describe('mounted file output provenance scanner', () => { }) }) - it('reports whether the mount carried any secret material', async () => { - const withSecrets = await createMountedFileSecretProvenanceScanner({ - version: 1, - complete: true, - entries: [{ encryptedValue: 'encrypted-a' }], - scope: { userId: 'user-1', workspaceId: 'workspace-1' }, - }) - expect(withSecrets?.hasSecrets).toBe(true) - + it('keeps a mount without secret material exact-empty', async () => { const withoutSecrets = await createMountedFileSecretProvenanceScanner({ version: 1, complete: true, entries: [], scope: { userId: 'user-1', workspaceId: 'workspace-1' }, }) - expect(withoutSecrets?.hasSecrets).toBe(false) + expect(withoutSecrets?.provenance).toEqual({ status: 'exact', entries: [] }) expect(withoutSecrets?.scan(Buffer.from('anything'))).toEqual({ status: 'exact', entries: [] }) }) - it('keeps hasSecrets true when attested entries yield no scannable plaintext', async () => { + it('keeps attested entries unknown when they yield no plaintext', async () => { encryptionMockFns.mockDecryptSecret.mockImplementation(async () => ({ decrypted: '' })) const scanner = await createMountedFileSecretProvenanceScanner({ @@ -77,7 +86,8 @@ describe('mounted file output provenance scanner', () => { scope: { userId: 'user-1', workspaceId: 'workspace-1' }, }) - expect(scanner?.hasSecrets).toBe(true) + expect(scanner?.provenance).toEqual({ status: 'unknown' }) + expect(scanner?.scan(Buffer.from('ordinary output'))).toEqual({ status: 'unknown' }) }) it.each(['false', 'hunter2', '""""'])( @@ -96,7 +106,7 @@ describe('mounted file output provenance scanner', () => { status: 'exact', entries: [], }) - expect(scanner?.hasSecrets).toBe(false) + expect(scanner?.provenance).toEqual({ status: 'exact', entries: [] }) } ) @@ -112,7 +122,17 @@ describe('mounted file output provenance scanner', () => { scope: { userId: 'user-1', workspaceId: 'workspace-1' }, }) - expect(scanner?.hasSecrets).toBe(true) + expect(scanner?.provenance).toEqual({ + status: 'exact', + entries: [ + { + name: 'MOUNTED_FILE_SECRET', + encryptedValue: 'encrypted-boundary', + sourceUserId: 'user-1', + sourceWorkspaceId: 'workspace-1', + }, + ], + }) expect(scanner?.scan(Buffer.from('false hunter22'))).toEqual({ status: 'exact', entries: [ @@ -132,7 +152,7 @@ describe('mounted file output provenance scanner', () => { complete: false, entries: [], }) - expect(incomplete?.hasSecrets).toBe(true) + expect(incomplete?.provenance).toEqual({ status: 'unknown' }) expect(incomplete?.scan(Buffer.from('raw output'))).toEqual({ status: 'unknown' }) encryptionMockFns.mockDecryptSecret.mockRejectedValueOnce(new Error('decrypt failed')) @@ -142,7 +162,7 @@ describe('mounted file output provenance scanner', () => { entries: [{ encryptedValue: 'encrypted-a' }], scope: { userId: 'user-1', workspaceId: 'workspace-1' }, }) - expect(unavailable?.hasSecrets).toBe(true) + expect(unavailable?.provenance).toEqual({ status: 'unknown' }) 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 1bf6003ad6c..19587809553 100644 --- a/apps/sim/lib/execution/mounted-file-secret-provenance.ts +++ b/apps/sim/lib/execution/mounted-file-secret-provenance.ts @@ -1,7 +1,10 @@ import type { WorkspaceFileSecretProvenanceEntry } from '@sim/db/schema' import { compareStrings } from '@sim/utils/string' import { decryptSecret } from '@/lib/core/security/encryption' -import type { WorkspaceFileSecretProvenance } from '@/lib/uploads/contexts/workspace/workspace-file-secret-provenance' +import { + mergeWorkspaceFileSecretProvenance, + type WorkspaceFileSecretProvenance, +} from '@/lib/uploads/contexts/workspace/workspace-file-secret-provenance' import { createResolvedSecretMatcher, scanResolvedSecretString, @@ -13,18 +16,13 @@ const MAX_MOUNTED_FILE_SECRET_MATCH_EVENTS = 1_000_000 const ANONYMOUS_MOUNTED_FILE_SECRET_NAME = 'MOUNTED_FILE_SECRET' export interface MountedFileSecretProvenanceScanner { - /** - * True when the envelope carries material protected by the shared literal policy, or an entry - * cannot be inspected. Successfully decrypted short values do not taint derived binary files. - * Entries that fail to yield plaintext keep this true: losing the ability to scan them makes - * the mount less classifiable, not more. - */ - hasSecrets: boolean + /** Complete protected candidates for opaque exports; text exports may narrow them by scanning. */ + provenance: WorkspaceFileSecretProvenance scan(buffer: Buffer): WorkspaceFileSecretProvenance } const UNKNOWN_MOUNTED_FILE_SECRET_PROVENANCE_SCANNER: MountedFileSecretProvenanceScanner = { - hasSecrets: true, + provenance: { status: 'unknown' }, scan: () => ({ status: 'unknown' }), } @@ -39,17 +37,13 @@ export async function createMountedFileSecretProvenanceScanner( if (!provenance.complete) return UNKNOWN_MOUNTED_FILE_SECRET_PROVENANCE_SCANNER if (!provenance.scope?.userId) return undefined - let hasSecrets = false + const protectedEntries: WorkspaceFileSecretProvenanceEntry[] = [] const entriesByScanLiteral = new Map>() try { for (const entry of provenance.entries) { const { decrypted: plaintext } = await decryptSecret(entry.encryptedValue) - if (!plaintext) { - hasSecrets = true - continue - } + if (!plaintext) return UNKNOWN_MOUNTED_FILE_SECRET_PROVENANCE_SCANNER if (isNonIdentifyingSecretLiteral(plaintext)) continue - hasSecrets = true const fileEntry: WorkspaceFileSecretProvenanceEntry = { name: entry.name || ANONYMOUS_MOUNTED_FILE_SECRET_NAME, encryptedValue: entry.encryptedValue, @@ -58,6 +52,7 @@ export async function createMountedFileSecretProvenanceScanner( ? { sourceWorkspaceId: provenance.scope.workspaceId } : {}), } + protectedEntries.push(fileEntry) for (const scanLiteral of new Set([plaintext, JSON.stringify(plaintext).slice(1, -1)])) { const entries = entriesByScanLiteral.get(scanLiteral) ?? @@ -73,8 +68,13 @@ export async function createMountedFileSecretProvenanceScanner( return UNKNOWN_MOUNTED_FILE_SECRET_PROVENANCE_SCANNER } + const sourceProvenance = mergeWorkspaceFileSecretProvenance({ + status: 'exact', + entries: protectedEntries, + }) + if (entriesByScanLiteral.size === 0) { - return { hasSecrets, scan: () => ({ status: 'exact', entries: [] }) } + return { provenance: sourceProvenance, scan: () => ({ status: 'exact', entries: [] }) } } let matcher @@ -86,11 +86,11 @@ export async function createMountedFileSecretProvenanceScanner( return UNKNOWN_MOUNTED_FILE_SECRET_PROVENANCE_SCANNER } if (!matcher) { - return { hasSecrets, scan: () => ({ status: 'exact', entries: [] }) } + return { provenance: sourceProvenance, scan: () => ({ status: 'exact', entries: [] }) } } return { - hasSecrets, + provenance: sourceProvenance, /** * A scan that cannot finish yields `unknown` — a taint — where the registry's per-value scan * over-approximates instead. The asymmetry is deliberate: that scan only narrows a candidate diff --git a/apps/sim/lib/function-execution/execute-request.test.ts b/apps/sim/lib/function-execution/execute-request.test.ts index dd1e8e478b6..672f13e594a 100644 --- a/apps/sim/lib/function-execution/execute-request.test.ts +++ b/apps/sim/lib/function-execution/execute-request.test.ts @@ -1145,6 +1145,7 @@ describe('Function execution request', () => { exportedFiles: { '/home/user/short.json': JSON.stringify({ value: shortValue }), '/home/user/protected.txt': 'hunter22', + '/home/user/archive.zip': 'UEsDBA==', }, }) @@ -1166,6 +1167,11 @@ describe('Function execution request', () => { sandboxPath: '/home/user/protected.txt', mimeType: 'text/plain', }, + { + path: 'files/archive.zip', + sandboxPath: '/home/user/archive.zip', + mimeType: 'application/zip', + }, ], }, }) @@ -1178,22 +1184,24 @@ describe('Function execution request', () => { secretProvenance: { status: 'exact', entries: [] }, }) ) - expect(mockWriteWorkspaceFileByPath).toHaveBeenCalledWith( - expect.objectContaining({ - target: expect.objectContaining({ path: 'files/protected.txt' }), - secretProvenance: { - status: 'exact', - entries: [ - { - name: 'API_KEY', - encryptedValue: 'encrypted:hunter22', - sourceUserId: 'user-123', - sourceWorkspaceId: 'workspace-1', - }, - ], - }, - }) - ) + for (const path of ['files/protected.txt', 'files/archive.zip']) { + expect(mockWriteWorkspaceFileByPath).toHaveBeenCalledWith( + expect.objectContaining({ + target: expect.objectContaining({ path }), + secretProvenance: { + status: 'exact', + entries: [ + { + name: 'API_KEY', + encryptedValue: 'encrypted:hunter22', + sourceUserId: 'user-123', + sourceWorkspaceId: 'workspace-1', + }, + ], + }, + }) + ) + } }) it('classifies exports exact-empty when the only compiled secret is exempt, still reporting its name', async () => { @@ -1258,51 +1266,57 @@ describe('Function execution request', () => { expect(data.__resolvedSecretNames).toEqual(['API_KEY']) }) - it('keeps recording the non-exempt owner when an exempt name shares its plaintext', async () => { - envFlagsMock.isRemoteSandboxEnabled = true - mockExecuteInSandbox.mockResolvedValueOnce({ - result: 'done', - stdout: '', - sandboxId: 'sandbox-123', - exportedFiles: { '/home/user/secret.txt': 'Bearer shared-value' }, - }) - - const response = await POST( - createMockRequest('POST', { - code: 'print("{{EXEMPT_KEY}}", "{{OTHER_KEY}}")', - language: 'python', - workspaceId: 'workspace-1', - envVars: { EXEMPT_KEY: 'shared-value', OTHER_KEY: 'shared-value' }, - unredactedSecretNames: ['EXEMPT_KEY'], - outputs: { - files: [ - { - path: 'files/secret.txt', - sandboxPath: '/home/user/secret.txt', - mimeType: 'text/plain', - }, - ], + it.each(['txt', 'zip'])( + 'keeps the non-exempt owner for a %s export with a shared plaintext', + async (extension) => { + envFlagsMock.isRemoteSandboxEnabled = true + mockExecuteInSandbox.mockResolvedValueOnce({ + result: 'done', + stdout: '', + sandboxId: 'sandbox-123', + exportedFiles: { + [`/home/user/secret.${extension}`]: + extension === 'txt' ? 'Bearer shared-value' : 'UEsDBA==', }, }) - ) - expect(response.status).toBe(200) - expect(mockWriteWorkspaceFileByPath).toHaveBeenCalledWith( - expect.objectContaining({ - secretProvenance: { - status: 'exact', - entries: [ - { - name: 'OTHER_KEY', - encryptedValue: 'encrypted:shared-value', - sourceUserId: 'user-123', - sourceWorkspaceId: 'workspace-1', - }, - ], - }, - }) - ) - }) + const response = await POST( + createMockRequest('POST', { + code: 'print("{{EXEMPT_KEY}}", "{{OTHER_KEY}}")', + language: 'python', + workspaceId: 'workspace-1', + envVars: { EXEMPT_KEY: 'shared-value', OTHER_KEY: 'shared-value' }, + unredactedSecretNames: ['EXEMPT_KEY'], + outputs: { + files: [ + { + path: `files/secret.${extension}`, + sandboxPath: `/home/user/secret.${extension}`, + mimeType: extension === 'txt' ? 'text/plain' : 'application/zip', + }, + ], + }, + }) + ) + + expect(response.status).toBe(200) + expect(mockWriteWorkspaceFileByPath).toHaveBeenCalledWith( + expect.objectContaining({ + secretProvenance: { + status: 'exact', + entries: [ + { + name: 'OTHER_KEY', + encryptedValue: 'encrypted:shared-value', + sourceUserId: 'user-123', + sourceWorkspaceId: 'workspace-1', + }, + ], + }, + }) + ) + } + ) it('classifies text exports against private mounted-file provenance', async () => { envFlagsMock.isRemoteSandboxEnabled = true @@ -1396,53 +1410,62 @@ describe('Function execution request', () => { 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' }, - }) + it.each(['txt', 'zip'])( + 'keeps authenticated incomplete mount provenance unknown for %s exports', + async (extension) => { + envFlagsMock.isRemoteSandboxEnabled = true + mockExecuteInSandbox.mockResolvedValueOnce({ + result: 'raw result', + stdout: '', + sandboxId: 'sandbox-123', + exportedFiles: { + [`/home/user/output.${extension}`]: + extension === 'txt' ? 'raw output' : Buffer.from('raw output').toString('base64'), + }, + }) - 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: [], + const response = await POST( + createMockRequest( + 'POST', + { + code: 'print("done")', + language: 'python', + workspaceId: 'workspace-1', + outputs: { + files: [ + { + path: `files/output.${extension}`, + sandboxPath: `/home/user/output.${extension}`, + mimeType: extension === 'txt' ? 'text/plain' : 'application/zip', + }, + ], + }, + [PRIVATE_SECRET_PROVENANCE_FIELD]: { + version: 1, + complete: false, + selections: [], + }, }, - }, - { [PRIVATE_SECRET_PROVENANCE_HEADER]: PRIVATE_SECRET_PROVENANCE_BUNDLE_V1 } + { [PRIVATE_SECRET_PROVENANCE_HEADER]: PRIVATE_SECRET_PROVENANCE_BUNDLE_V1 } + ) ) - ) - expect(response.status).toBe(200) - expect((await response.json()).output.exported.files).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' }, - }) - ) - }) + expect(response.status).toBe(200) + expect((await response.json()).output.exported.files).toEqual([ + expect.objectContaining({ + fileId: `wf_output_${extension}`, + vfsPath: `files/output.${extension}`, + }), + ]) + 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 @@ -1548,12 +1571,13 @@ describe('Function execution request', () => { }) it.each([ - { plaintext: 'mounted-secret', expectedStatus: 'unknown' }, - { plaintext: 'false', expectedStatus: 'exact' }, - { plaintext: '""""', expectedStatus: 'exact' }, + { plaintext: 'mounted-secret', expectedStatus: 'exact', protected: true }, + { plaintext: 'false', expectedStatus: 'exact', protected: false }, + { plaintext: '""""', expectedStatus: 'exact', protected: false }, + { plaintext: '', expectedStatus: 'unknown', protected: false }, ])( 'classifies binary exports $expectedStatus with mounted plaintext $plaintext', - async ({ plaintext, expectedStatus }) => { + async ({ plaintext, expectedStatus, protected: hasProtectedEntry }) => { mockDecryptSecret.mockResolvedValueOnce({ decrypted: plaintext }) envFlagsMock.isRemoteSandboxEnabled = true mockExecuteInSandbox.mockResolvedValueOnce({ @@ -1604,14 +1628,29 @@ describe('Function execution request', () => { expect(response.status).toBe(200) expect(mockWriteWorkspaceFileByPath).toHaveBeenCalledWith( expect.objectContaining({ + buffer: Buffer.from('/9j/4AAQ', 'base64'), secretProvenance: - expectedStatus === 'exact' ? { status: 'exact', entries: [] } : { status: 'unknown' }, + expectedStatus === 'exact' + ? { + status: 'exact', + entries: hasProtectedEntry + ? [ + { + name: 'MOUNTED_FILE_SECRET', + encryptedValue: 'encrypted:mounted-secret', + sourceUserId: 'user-123', + sourceWorkspaceId: 'workspace-1', + }, + ] + : [], + } + : { status: 'unknown' }, }) ) } ) - it('marks binary exports unknown without failing the Function execution', async () => { + it('retains compiled secret lineage beside unchanged binary exports', async () => { envFlagsMock.isRemoteSandboxEnabled = true mockExecuteInSandbox.mockResolvedValueOnce({ result: 'done', @@ -1640,8 +1679,22 @@ describe('Function execution request', () => { expect(response.status).toBe(200) expect(mockWriteWorkspaceFileByPath).toHaveBeenCalledWith( - expect.objectContaining({ secretProvenance: { status: 'unknown' } }) + expect.objectContaining({ + buffer: Buffer.from('UEsDBA==', 'base64'), + secretProvenance: { + status: 'exact', + entries: [ + { + name: 'API_KEY', + encryptedValue: 'encrypted:secret-value', + sourceUserId: 'user-123', + sourceWorkspaceId: 'workspace-1', + }, + ], + }, + }) ) + expect(JSON.stringify(await response.json())).not.toContain('encrypted:secret-value') }) it('rejects one oversized sandbox output before creating a workspace file buffer', async () => { @@ -2015,12 +2068,12 @@ describe('Function execution request', () => { }) it.each([ - { name: 'report.zip', secret: undefined, expectedStatus: 'exact' }, - { name: 'report.zip', secret: 'super-secret-value', expectedStatus: 'unknown' }, - { name: 'report.txt', secret: 'super-secret-value', expectedStatus: 'unknown' }, + { name: 'report.zip', secret: undefined }, + { name: 'report.zip', secret: 'super-secret-value' }, + { name: 'report.txt', secret: 'super-secret-value' }, ])( 'preserves binary provenance for harvested $name with secret=$secret', - async ({ name, secret, expectedStatus }) => { + async ({ name, secret }) => { envFlagsMock.isRemoteSandboxEnabled = true const zip = new JSZip() zip.file('report.txt', secret ?? 'ordinary report') @@ -2058,7 +2111,19 @@ describe('Function execution request', () => { name, expect.any(String), 'user-123', - expectedStatus === 'exact' ? { status: 'exact', entries: [] } : { status: 'unknown' } + { + status: 'exact', + entries: secret + ? [ + { + name: 'MY_SECRET', + encryptedValue: `encrypted:${secret}`, + sourceUserId: 'user-123', + sourceWorkspaceId: 'workspace-1', + }, + ] + : [], + } ) const data = await response.json() expect(data.output.files[0]).not.toHaveProperty('secretProvenance') @@ -2092,7 +2157,17 @@ describe('Function execution request', () => { }) ) expect(response.status).toBe(200) - expect(mockUploadExecutionFile.mock.calls[0][5]).toEqual({ status: 'unknown' }) + expect(mockUploadExecutionFile.mock.calls[0][5]).toEqual({ + status: 'exact', + entries: [ + { + name: 'MY_SECRET', + encryptedValue: 'encrypted:super-secret-value', + sourceUserId: 'user-123', + sourceWorkspaceId: 'workspace-1', + }, + ], + }) }) it('refuses an unknown tracked execution mount before running code', async () => { @@ -2194,7 +2269,17 @@ describe('Function execution request', () => { expect(registry.exportProvenance().entries).toEqual([ expect.objectContaining({ encryptedValue: 'encrypted:mounted-secret' }), ]) - expect(mockUploadExecutionFile.mock.calls[0][5]).toEqual({ status: 'unknown' }) + expect(mockUploadExecutionFile.mock.calls[0][5]).toEqual({ + status: 'exact', + entries: [ + { + name: 'API_KEY', + encryptedValue: 'encrypted:mounted-secret', + sourceUserId: 'user-123', + sourceWorkspaceId: 'workspace-1', + }, + ], + }) }) it.each([ @@ -2254,9 +2339,19 @@ describe('Function execution request', () => { expect(response.status).toBe(archive || unredacted ? 200 : 400) if (archive) { - expect(mockUploadExecutionFile.mock.calls[0][5]).toEqual( - unredacted ? { status: 'exact', entries: [] } : { status: 'unknown' } - ) + expect(mockUploadExecutionFile.mock.calls[0][5]).toEqual({ + status: 'exact', + entries: unredacted + ? [] + : [ + { + name: 'API_KEY', + encryptedValue: plaintext, + sourceUserId: 'user-123', + sourceWorkspaceId: 'workspace-1', + }, + ], + }) } else { expect(mockUploadExecutionFile).not.toHaveBeenCalled() } @@ -2360,9 +2455,21 @@ describe('Function execution request', () => { ], }) } else { - expect(mockUploadExecutionFile.mock.calls[0][5]).toEqual({ - status: knownMount ? 'unknown' : 'unrecorded', - }) + expect(mockUploadExecutionFile.mock.calls[0][5]).toEqual( + knownMount + ? { + status: 'exact', + entries: [ + { + name: 'API_KEY', + encryptedValue: 'encrypted:mounted-secret', + sourceUserId: 'user-123', + sourceWorkspaceId: 'workspace-1', + }, + ], + } + : { status: 'unrecorded' } + ) } } else expect(mockUploadExecutionFile).not.toHaveBeenCalled() } diff --git a/apps/sim/lib/function-execution/execute-request.ts b/apps/sim/lib/function-execution/execute-request.ts index 69a74ea8737..5471b566be2 100644 --- a/apps/sim/lib/function-execution/execute-request.ts +++ b/apps/sim/lib/function-execution/execute-request.ts @@ -73,6 +73,8 @@ import { RESOLVED_SECRET_NAMES_METADATA_V1, requestsPrivateToolMetadata, } from '@/lib/execution/private-tool-metadata' +import { SecretProvenanceBudget } from '@/lib/execution/provenance-budget' +import { PROVENANCE_MAX_SERIALIZED_BYTES } from '@/lib/execution/provenance-limits' import { executeInSandbox, executeShellInSandbox, @@ -1022,6 +1024,7 @@ interface FunctionRouteExecutionContext { outputSecretMatcher?: ResolvedSecretMatcher outputSecretNamesByScanLiteral: Map outputSecretPlaintextsByName: Map + compiledBinaryFileProvenance?: WorkspaceFileSecretProvenance /** * In-scope names the caller's registry certified as redaction-exempt. They stay in * `outputSecretPlaintextsByName` — the response's resolved-name reporting and the usage @@ -1325,51 +1328,21 @@ function activateReferencedSecretProvenance(context: FunctionRouteExecutionConte } } -/** - * Compiled secret names that still demand redaction, and whose value a scan could - * actually find. Exempt names don't count. - * - * Non-identifying literals are excluded on the same predicate - * {@link createResolvedSecretMatcher} uses to drop them, because the two decisions - * have to agree. When every in-scope value is shorter than the substitutable-literal - * minimum, the matcher builds nothing and returns `undefined`; a counter that still - * reported those names would send - * {@link getOutputFileSecretProvenance} down its no-matcher branch and classify - * every output as `unknown` — failing an export while claiming it contains a - * secret that, by that very policy, is too short to be attributed to anything. - */ -function countProtectedOutputSecretNames(context: FunctionRouteExecutionContext): number { - let count = 0 +/** Compiled candidates subject to the shared literal and exemption policies. */ +function getProtectedOutputSecretNames(context: FunctionRouteExecutionContext): string[] { + const names: string[] = [] for (const [name, plaintext] of context.outputSecretPlaintextsByName) { if (context.unredactedSecretNames.has(name)) continue if (isNonIdentifyingSecretLiteral(plaintext)) continue - count += 1 + names.push(name) } - return count + return names } /** - * 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. Exempt names don't count: a binary export whose only - * in-scope secrets are redaction-exempt is deliberately classified exact-empty rather than locked. - */ -function hasSecretMaterialInScope(context: FunctionRouteExecutionContext): boolean { - if (countProtectedOutputSecretNames(context) > 0) return true - return Boolean( - context.mountedFileSecretProvenanceScanner?.hasSecrets || - context.runtimeFileSecretProvenanceScanner?.hasSecrets - ) -} - -/** - * Classifies the secret provenance of one exported sandbox file. - * - * Text exports are scanned for the exact resolved-secret plaintexts in scope. Binary exports cannot - * be scanned soundly — re-encoding can carry a secret without leaving a literal substring — so they - * are classified only when no secret material was in scope at all; with nothing available to embed, - * the bytes are provably secret-free. Otherwise they stay unknown, which fails closed at every - * model and runtime boundary that later reads the file. + * Text exports narrow known candidates to matching literals. Opaque exports retain the complete + * encrypted candidate set, so runtime consumers can protect decoded output. Direct opaque model + * delivery still requires an empty set at its own boundary. */ async function getOutputFileSecretProvenance( buffer: Buffer, @@ -1384,63 +1357,77 @@ async function getOutputFileSecretProvenance( await createMountedFileSecretProvenanceScanner(provenance) if (!context.runtimeFileSecretProvenanceScanner && provenance.entries.length > 0) { context.runtimeFileSecretProvenanceScanner = { - hasSecrets: true, + provenance: { status: 'unknown' }, scan: () => ({ status: 'unknown' }), } } } - if (isBinary) { - return hasSecretMaterialInScope(context) - ? { status: 'unknown' } - : context.runtimeInputProvenanceUnrecorded - ? { status: 'unrecorded' } - : EXACT_EMPTY_WORKSPACE_FILE_SECRET_PROVENANCE - } const mountedFileProvenance = mergeWorkspaceFileSecretProvenance( - context.mountedFileSecretProvenanceScanner?.scan(buffer) ?? + (isBinary + ? context.mountedFileSecretProvenanceScanner?.provenance + : context.mountedFileSecretProvenanceScanner?.scan(buffer)) ?? EXACT_EMPTY_WORKSPACE_FILE_SECRET_PROVENANCE, - context.runtimeFileSecretProvenanceScanner?.scan(buffer) ?? + (isBinary + ? context.runtimeFileSecretProvenanceScanner?.provenance + : context.runtimeFileSecretProvenanceScanner?.scan(buffer)) ?? EXACT_EMPTY_WORKSPACE_FILE_SECRET_PROVENANCE, context.runtimeInputProvenanceUnrecorded ? { status: 'unrecorded' } : EXACT_EMPTY_WORKSPACE_FILE_SECRET_PROVENANCE ) - if (countProtectedOutputSecretNames(context) === 0) { + if (isBinary && context.compiledBinaryFileProvenance) { + return mergeWorkspaceFileSecretProvenance( + context.compiledBinaryFileProvenance, + mountedFileProvenance + ) + } + const protectedNames = getProtectedOutputSecretNames(context) + if (protectedNames.length === 0 || mountedFileProvenance.status === 'unknown') { return mountedFileProvenance } - if (!context.outputSecretMatcher) return { status: 'unknown' } - const matchedNames = new Set() - try { - scanResolvedSecretString( - buffer.toString('utf8'), - context.outputSecretMatcher, - (scanLiteral) => { - for (const name of context.outputSecretNamesByScanLiteral.get(scanLiteral) ?? []) { - matchedNames.add(name) - } - }, - MAX_PRIVATE_FILE_SECRET_MATCH_EVENTS - ) - } catch { - return { status: 'unknown' } + const matchedNames = new Set(isBinary ? protectedNames : []) + if (!isBinary) { + if (!context.outputSecretMatcher) return { status: 'unknown' } + try { + scanResolvedSecretString( + buffer.toString('utf8'), + context.outputSecretMatcher, + (scanLiteral) => { + for (const name of context.outputSecretNamesByScanLiteral.get(scanLiteral) ?? []) { + matchedNames.add(name) + } + }, + MAX_PRIVATE_FILE_SECRET_MATCH_EVENTS + ) + } catch { + return { status: 'unknown' } + } } try { - const entries = await Promise.all( - [...matchedNames].sort().map(async (name) => { - const plaintext = context.outputSecretPlaintextsByName.get(name) - if (plaintext === undefined) { - throw new Error('Resolved secret provenance name is outside the scoped catalog') - } - return { - name, - encryptedValue: (await encryptSecret(plaintext)).encrypted, - sourceUserId: scope.userId, - sourceWorkspaceId: scope.workspaceId, - } - }) - ) + const entries = [] + const budget = new SecretProvenanceBudget() + for (const name of [...matchedNames].sort()) { + const plaintext = context.outputSecretPlaintextsByName.get(name) + if (plaintext === undefined) { + throw new Error('Resolved secret provenance name is outside the scoped catalog') + } + if (Buffer.byteLength(plaintext, 'utf8') > PROVENANCE_MAX_SERIALIZED_BYTES) { + return { status: 'unknown' } + } + const entry = { + name, + encryptedValue: (await encryptSecret(plaintext)).encrypted, + sourceUserId: scope.userId, + sourceWorkspaceId: scope.workspaceId, + } + if (!budget.add(entry.encryptedValue, Buffer.byteLength(JSON.stringify(entry), 'utf8'))) { + return { status: 'unknown' } + } + entries.push(entry) + } + if (isBinary) context.compiledBinaryFileProvenance = { status: 'exact', entries } return mergeWorkspaceFileSecretProvenance({ status: 'exact', entries }, mountedFileProvenance) } catch { return { status: 'unknown' } diff --git a/apps/sim/lib/knowledge/__integration__/execution-archive-provenance.integration.ts b/apps/sim/lib/knowledge/__integration__/execution-archive-provenance.integration.ts index 3a3332a2569..3fa7181a256 100644 --- a/apps/sim/lib/knowledge/__integration__/execution-archive-provenance.integration.ts +++ b/apps/sim/lib/knowledge/__integration__/execution-archive-provenance.integration.ts @@ -62,6 +62,7 @@ import { import { filterModelSafeWorkspaceFileAttachments, getBoundWorkspaceFileSecretProvenance, + importWorkspaceFileSecretProvenanceForRuntime, isModelSafeWorkspaceFileKey, isOpaqueWorkspaceFileEgressSafe, type WorkspaceFileSecretProvenance, @@ -69,6 +70,8 @@ import { import { deleteFile, downloadFile } from '@/lib/uploads/core/storage-service' import { createWorkspaceFileDelegatedPrincipal } from '@/lib/workspace-files/application/delegated-principal' import type { UserFile } from '@/executor/types' +import { projectResolvedSecretModelContent } from '@/executor/utils/resolved-secret-content-projection' +import { ResolvedSecretTraceRegistry } from '@/executor/utils/resolved-secret-trace-registry' const fixtures: ReturnType[] = [] const trackedEventIds: string[] = [] @@ -258,34 +261,61 @@ describe('execution archive durable provenance', () => { expect(await getBoundWorkspaceFileSecretProvenance(ids.workspaceId, source.identity)).toEqual({ status: 'unknown', }) + expect( + await importWorkspaceFileSecretProvenanceForRuntime({ + workspaceId: ids.workspaceId, + identity: source.identity, + registry: new ResolvedSecretTraceRegistry([], { + userId: ids.aliceId, + workspaceId: ids.workspaceId, + }), + }) + ).toBe(false) await assertBlockedConsumers(ids, source) }) - it('does not infer safe extracted bytes from a secret-bearing archive or expose private metadata', async () => { + it('retains extracted secret lineage for protected runtime readback without permitting opaque delivery', async () => { const ids = await seed() const { encrypted } = await encryptSecret(FIXTURE_SECRET) - const archive = await uploadArchive( - ids, - { - status: 'exact', - entries: [ - { - name: 'FIXTURE_SECRET', - encryptedValue: encrypted, - sourceUserId: ids.aliceId, - sourceWorkspaceId: ids.workspaceId, - }, - ], - }, - `name,description\nOrion,${FIXTURE_SECRET}\n` - ) + const provenance: WorkspaceFileSecretProvenance = { + status: 'exact', + entries: [ + { + name: 'FIXTURE_SECRET', + encryptedValue: encrypted, + sourceUserId: ids.aliceId, + sourceWorkspaceId: ids.workspaceId, + }, + ], + } + const content = `name,description\nOrion,${FIXTURE_SECRET}\n` + const archive = await uploadArchive(ids, provenance, content) const source = await extract(ids, archive) expect(source.publicMetadata).not.toContain(FIXTURE_SECRET) expect(source.publicMetadata).not.toContain(encrypted) expect(source.publicMetadata).not.toContain('encryptedValue') - expect(await getBoundWorkspaceFileSecretProvenance(ids.workspaceId, source.identity)).toEqual({ - status: 'unknown', + expect(await getBoundWorkspaceFileSecretProvenance(ids.workspaceId, source.identity)).toEqual( + provenance + ) + const registry = new ResolvedSecretTraceRegistry([], { + userId: ids.aliceId, + workspaceId: ids.workspaceId, }) + expect( + await importWorkspaceFileSecretProvenanceForRuntime({ + workspaceId: ids.workspaceId, + identity: source.identity, + registry, + }) + ).toBe(true) + const storedContent = ( + await downloadFile({ key: source.child.key, context: 'workspace' }) + ).toString() + expect(storedContent).toBe(content) + const projected = projectResolvedSecretModelContent(storedContent, registry) + expect(projected.safe).toBe(true) + if (!projected.safe) throw new Error('Known extracted lineage withheld runtime readback') + expect(projected.value).toBe('name,description\nOrion,{{FIXTURE_SECRET}}\n') await assertBlockedConsumers(ids, source) }) diff --git a/apps/sim/lib/mothership/tools/handlers/workbench-confidentiality.live.test.ts b/apps/sim/lib/mothership/tools/handlers/workbench-confidentiality.live.test.ts index 94cf2baa8c8..4e0f61e889a 100644 --- a/apps/sim/lib/mothership/tools/handlers/workbench-confidentiality.live.test.ts +++ b/apps/sim/lib/mothership/tools/handlers/workbench-confidentiality.live.test.ts @@ -1,4 +1,5 @@ import { execFile } from 'node:child_process' +import { createHash } from 'node:crypto' import { createReadStream } from 'node:fs' import { mkdir, mkdtemp, readdir, readFile, rm, stat, writeFile } from 'node:fs/promises' import { dirname, join } from 'node:path' @@ -943,6 +944,84 @@ describe('persistent workbench output confidentiality', () => { expect(saved.buffer).toEqual(Buffer.from([0x89, 0x50, 0x4e, 0x47, 0, 1])) expect(saved.secretProvenance).toEqual({ status: 'exact', entries: [] }) }) + it('retains export lineage for archive inspection and redaction in a fresh workbench', async () => { + const initial = await run('printf "%s" "$TOKEN" > payload.txt', ['TOKEN']) + expect(initial.projected.safe).toBe(true) + const result = await inResourceScope(() => + executeFunctionExecute( + { + code: `python3 - <<'PYTHON' +import zipfile +with zipfile.ZipFile('bundle.zip', 'w', compression=zipfile.ZIP_DEFLATED) as archive: + archive.write('payload.txt') + archive.writestr('operations.json', '[]') +PYTHON`, + language: 'shell', + outputs: { files: [{ path: 'files/bundle.zip', sandboxPath: 'bundle.zip' }] }, + }, + context() + ) + ) + expect(result.success).toBe(true) + const saved = io.write.mock.calls.at(-1)?.[0] + if (!saved) throw new Error('Archive export did not persist a file') + expect(saved.buffer.includes(Buffer.from(canary))).toBe(false) + expect(saved.secretProvenance).toMatchObject({ + status: 'exact', + entries: [expect.objectContaining({ name: 'TOKEN', sourceUserId: scope.userId })], + }) + expect(JSON.stringify(saved.secretProvenance)).not.toContain(canary) + + root = await mkdtemp('/private/tmp/sim-workbench-test-') + roots.push(root) + await mkdir(workerPath('/home/user'), { recursive: true }) + chatId = `review-${generateShortId(12)}` + machine = localWorker() + io.find.mockResolvedValue(machine) + parent = new ResolvedSecretTraceRegistry([], scope) + const identity = { providerId: 'e2b' as const, sandboxId: machine.sandboxId } + await initializeSessionFileProvenance(chatSandboxSessionKey(chatId), identity) + const file = createWorkbenchFileProvenance({ + ...scope, + sessionKey: chatSandboxSessionKey(chatId), + }) + const stream = new Blob([saved.buffer]).stream() + file.trackDownload(stream, saved.secretProvenance) + await machine.writeFile( + '/home/user/bundle.zip', + Buffer.from(await new Response(file.observeDownload(identity, stream)).arrayBuffer()) + ) + const listing = await run(`python3 - <<'PYTHON' +import hashlib, zipfile +with open('bundle.zip', 'rb') as source: + print(hashlib.sha256(source.read()).hexdigest()) +with zipfile.ZipFile('bundle.zip') as archive: + print(','.join(archive.namelist())) + archive.extractall() +PYTHON`) + expect(listing.projected.safe).toBe(true) + expect(listing.projected.result).toMatchObject({ + success: true, + output: { + stdout: `${createHash('sha256').update(saved.buffer).digest('hex')}\npayload.txt,operations.json`, + }, + }) + const readback = await run('cat payload.txt') + expect(readback.projected.safe).toBe(true) + expect(readback.projected.result).toMatchObject({ + success: true, + output: { stdout: '{{TOKEN}}' }, + }) + expect(JSON.stringify(readback.projected.result)).not.toContain(canary) + const ordinary = await run('printf ready') + expect(ordinary.projected.result).toMatchObject({ + success: true, + output: { stdout: 'ready' }, + }) + expect( + (await readCliInputFile(chatSandboxSessionKey(chatId), 'operations.json')).toString() + ).toBe('[]') + }) it('retains historical secret provenance on a text export', async () => { await run('printf "%s" "$TOKEN" > saved.txt', ['TOKEN']) const current = context() diff --git a/apps/sim/lib/uploads/archive.test.ts b/apps/sim/lib/uploads/archive.test.ts index 83dc60ee911..8dffaf3009b 100644 --- a/apps/sim/lib/uploads/archive.test.ts +++ b/apps/sim/lib/uploads/archive.test.ts @@ -261,30 +261,6 @@ describe('decompressArchiveBufferToWorkspaceFiles', () => { expect(mockPurge).not.toHaveBeenCalled() }) - 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, - entries: [{ name: 'TOKEN', encryptedValue: 'encrypted-token' }], - } - - await decompressArchiveBufferToWorkspaceFiles(buffer, { - workspaceId: 'ws', - principal: TEST_PRINCIPAL, - secretProvenance, - }) - - expect(mockUpload).toHaveBeenCalledTimes(2) - for (const call of mockUpload.mock.calls) { - expect(call[0].input).toEqual( - expect.objectContaining({ - secretProvenance: { status: 'unknown' }, - notifyWorkspaceChange: false, - }) - ) - } - }) - 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 27675986a12..7762193915a 100644 --- a/apps/sim/lib/uploads/archive.ts +++ b/apps/sim/lib/uploads/archive.ts @@ -291,8 +291,8 @@ 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. Decompression is not byte-preserving, - * so known secret contributions become unknown on extracted files. Exact-empty and unrecorded - * classifications retain their existing input policy without changing the extracted bytes. + * so every extracted file inherits the archive's full candidate set. Runtime consumers redact + * decoded content; direct opaque delivery still refuses known secret contributions. */ export async function decompressArchiveBufferToWorkspaceFiles( buffer: Buffer, @@ -321,11 +321,6 @@ export async function decompressArchiveBufferToWorkspaceFiles( secretProvenance = { status: 'unknown' }, notifyWorkspaceChange = true, } = opts - const extractedSecretProvenance: WorkspaceFileSecretProvenance = - secretProvenance.status === 'unrecorded' || - (secretProvenance.status === 'exact' && secretProvenance.entries.length === 0) - ? secretProvenance - : { status: 'unknown' } assertCentralDirWithinCaps(buffer) @@ -491,7 +486,7 @@ export async function decompressArchiveBufferToWorkspaceFiles( // Auto-suffix on collision: one leaf name that already exists must not // roll back an otherwise valid extraction. exactName: false, - secretProvenance: extractedSecretProvenance, + secretProvenance, notifyWorkspaceChange: false, }, }) From f5083e7fbcf35a0405836f94b965a75572a9f53b Mon Sep 17 00:00:00 2001 From: Vikhyath Mondreti Date: Sun, 4 Oct 2026 12:47:21 -0700 Subject: [PATCH 2/3] chore(tests): verify generated file lineage through real storage --- .../execute-request.test.ts | 717 ++---------------- ...xecution-archive-provenance.integration.ts | 481 +++++++++++- 2 files changed, 545 insertions(+), 653 deletions(-) diff --git a/apps/sim/lib/function-execution/execute-request.test.ts b/apps/sim/lib/function-execution/execute-request.test.ts index 672f13e594a..653af53e30b 100644 --- a/apps/sim/lib/function-execution/execute-request.test.ts +++ b/apps/sim/lib/function-execution/execute-request.test.ts @@ -22,7 +22,6 @@ import { workspaceFileReferenceMock, workspaceFileReferenceMockFns, } from '@sim/testing/mocks/workspace-file-reference.mock' -import JSZip from 'jszip' import { NextRequest } from 'next/server' import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest' import { functionExecuteBodySchema } from '@/lib/api/contracts' @@ -1135,75 +1134,6 @@ describe('Function execution request', () => { ) }) - it('excludes short compiled plaintext before JSON escaping even with a protected secret in scope', async () => { - const shortValue = '""""' - envFlagsMock.isRemoteSandboxEnabled = true - mockExecuteInSandbox.mockResolvedValueOnce({ - result: 'done', - stdout: '', - sandboxId: 'sandbox-123', - exportedFiles: { - '/home/user/short.json': JSON.stringify({ value: shortValue }), - '/home/user/protected.txt': 'hunter22', - '/home/user/archive.zip': 'UEsDBA==', - }, - }) - - const response = await POST( - createMockRequest('POST', { - code: 'print({{SHORT_VALUE}}, {{API_KEY}})', - language: 'python', - workspaceId: 'workspace-1', - envVars: { SHORT_VALUE: shortValue, API_KEY: 'hunter22' }, - outputs: { - files: [ - { - path: 'files/short.json', - sandboxPath: '/home/user/short.json', - mimeType: 'application/json', - }, - { - path: 'files/protected.txt', - sandboxPath: '/home/user/protected.txt', - mimeType: 'text/plain', - }, - { - path: 'files/archive.zip', - sandboxPath: '/home/user/archive.zip', - mimeType: 'application/zip', - }, - ], - }, - }) - ) - - expect(response.status).toBe(200) - expect(mockWriteWorkspaceFileByPath).toHaveBeenCalledWith( - expect.objectContaining({ - target: expect.objectContaining({ path: 'files/short.json' }), - secretProvenance: { status: 'exact', entries: [] }, - }) - ) - for (const path of ['files/protected.txt', 'files/archive.zip']) { - expect(mockWriteWorkspaceFileByPath).toHaveBeenCalledWith( - expect.objectContaining({ - target: expect.objectContaining({ path }), - secretProvenance: { - status: 'exact', - entries: [ - { - name: 'API_KEY', - encryptedValue: 'encrypted:hunter22', - sourceUserId: 'user-123', - sourceWorkspaceId: 'workspace-1', - }, - ], - }, - }) - ) - } - }) - it('classifies exports exact-empty when the only compiled secret is exempt, still reporting its name', async () => { envFlagsMock.isRemoteSandboxEnabled = true mockExecuteInSandbox.mockResolvedValueOnce({ @@ -1266,58 +1196,6 @@ describe('Function execution request', () => { expect(data.__resolvedSecretNames).toEqual(['API_KEY']) }) - it.each(['txt', 'zip'])( - 'keeps the non-exempt owner for a %s export with a shared plaintext', - async (extension) => { - envFlagsMock.isRemoteSandboxEnabled = true - mockExecuteInSandbox.mockResolvedValueOnce({ - result: 'done', - stdout: '', - sandboxId: 'sandbox-123', - exportedFiles: { - [`/home/user/secret.${extension}`]: - extension === 'txt' ? 'Bearer shared-value' : 'UEsDBA==', - }, - }) - - const response = await POST( - createMockRequest('POST', { - code: 'print("{{EXEMPT_KEY}}", "{{OTHER_KEY}}")', - language: 'python', - workspaceId: 'workspace-1', - envVars: { EXEMPT_KEY: 'shared-value', OTHER_KEY: 'shared-value' }, - unredactedSecretNames: ['EXEMPT_KEY'], - outputs: { - files: [ - { - path: `files/secret.${extension}`, - sandboxPath: `/home/user/secret.${extension}`, - mimeType: extension === 'txt' ? 'text/plain' : 'application/zip', - }, - ], - }, - }) - ) - - expect(response.status).toBe(200) - expect(mockWriteWorkspaceFileByPath).toHaveBeenCalledWith( - expect.objectContaining({ - secretProvenance: { - status: 'exact', - entries: [ - { - name: 'OTHER_KEY', - encryptedValue: 'encrypted:shared-value', - sourceUserId: 'user-123', - sourceWorkspaceId: 'workspace-1', - }, - ], - }, - }) - ) - } - ) - it('classifies text exports against private mounted-file provenance', async () => { envFlagsMock.isRemoteSandboxEnabled = true mockExecuteInSandbox.mockResolvedValueOnce({ @@ -1410,63 +1288,6 @@ describe('Function execution request', () => { expect(mockExecuteInSandbox).not.toHaveBeenCalled() }) - it.each(['txt', 'zip'])( - 'keeps authenticated incomplete mount provenance unknown for %s exports', - async (extension) => { - envFlagsMock.isRemoteSandboxEnabled = true - mockExecuteInSandbox.mockResolvedValueOnce({ - result: 'raw result', - stdout: '', - sandboxId: 'sandbox-123', - exportedFiles: { - [`/home/user/output.${extension}`]: - extension === 'txt' ? 'raw output' : Buffer.from('raw output').toString('base64'), - }, - }) - - const response = await POST( - createMockRequest( - 'POST', - { - code: 'print("done")', - language: 'python', - workspaceId: 'workspace-1', - outputs: { - files: [ - { - path: `files/output.${extension}`, - sandboxPath: `/home/user/output.${extension}`, - mimeType: extension === 'txt' ? 'text/plain' : 'application/zip', - }, - ], - }, - [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.exported.files).toEqual([ - expect.objectContaining({ - fileId: `wf_output_${extension}`, - vfsPath: `files/output.${extension}`, - }), - ]) - 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({ @@ -1570,133 +1391,6 @@ describe('Function execution request', () => { ) }) - it.each([ - { plaintext: 'mounted-secret', expectedStatus: 'exact', protected: true }, - { plaintext: 'false', expectedStatus: 'exact', protected: false }, - { plaintext: '""""', expectedStatus: 'exact', protected: false }, - { plaintext: '', expectedStatus: 'unknown', protected: false }, - ])( - 'classifies binary exports $expectedStatus with mounted plaintext $plaintext', - async ({ plaintext, expectedStatus, protected: hasProtectedEntry }) => { - mockDecryptSecret.mockResolvedValueOnce({ decrypted: plaintext }) - envFlagsMock.isRemoteSandboxEnabled = true - mockExecuteInSandbox.mockResolvedValueOnce({ - result: 'done', - stdout: '', - sandboxId: 'sandbox-123', - exportedFiles: { '/home/user/small.jpg': '/9j/4AAQ' }, - }) - - const response = await POST( - createMockRequest( - 'POST', - { - code: 'print("done")', - language: 'python', - workspaceId: 'workspace-1', - outputs: { - files: [ - { - path: 'files/small.jpg', - sandboxPath: '/home/user/small.jpg', - mimeType: 'image/jpeg', - }, - ], - }, - [PRIVATE_SECRET_PROVENANCE_FIELD]: { - version: 1, - complete: true, - selections: [ - { - key: MOUNTED_WORKSPACE_FILES_PROVENANCE_KEY, - provenance: { - version: 1, - complete: true, - entries: [{ encryptedValue: 'encrypted:mounted-secret' }], - scope: { userId: 'user-123', workspaceId: 'workspace-1' }, - }, - }, - ], - }, - }, - { - [PRIVATE_SECRET_PROVENANCE_HEADER]: PRIVATE_SECRET_PROVENANCE_BUNDLE_V1, - } - ) - ) - - expect(response.status).toBe(200) - expect(mockWriteWorkspaceFileByPath).toHaveBeenCalledWith( - expect.objectContaining({ - buffer: Buffer.from('/9j/4AAQ', 'base64'), - secretProvenance: - expectedStatus === 'exact' - ? { - status: 'exact', - entries: hasProtectedEntry - ? [ - { - name: 'MOUNTED_FILE_SECRET', - encryptedValue: 'encrypted:mounted-secret', - sourceUserId: 'user-123', - sourceWorkspaceId: 'workspace-1', - }, - ] - : [], - } - : { status: 'unknown' }, - }) - ) - } - ) - - it('retains compiled secret lineage beside unchanged binary exports', async () => { - envFlagsMock.isRemoteSandboxEnabled = true - mockExecuteInSandbox.mockResolvedValueOnce({ - result: 'done', - stdout: '', - sandboxId: 'sandbox-123', - exportedFiles: { '/home/user/archive.zip': 'UEsDBA==' }, - }) - - const response = await POST( - createMockRequest('POST', { - code: 'print("{{API_KEY}}")', - language: 'python', - workspaceId: 'workspace-1', - envVars: { API_KEY: 'secret-value' }, - outputs: { - files: [ - { - path: 'files/archive.zip', - sandboxPath: '/home/user/archive.zip', - mimeType: 'application/zip', - }, - ], - }, - }) - ) - - expect(response.status).toBe(200) - expect(mockWriteWorkspaceFileByPath).toHaveBeenCalledWith( - expect.objectContaining({ - buffer: Buffer.from('UEsDBA==', 'base64'), - secretProvenance: { - status: 'exact', - entries: [ - { - name: 'API_KEY', - encryptedValue: 'encrypted:secret-value', - sourceUserId: 'user-123', - sourceWorkspaceId: 'workspace-1', - }, - ], - }, - }) - ) - expect(JSON.stringify(await response.json())).not.toContain('encrypted:secret-value') - }) - it('rejects one oversized sandbox output before creating a workspace file buffer', async () => { envFlagsMock.isRemoteSandboxEnabled = true mockExecuteInSandbox.mockResolvedValueOnce({ @@ -2067,109 +1761,6 @@ describe('Function execution request', () => { expect(data.error).toContain('21 files') }) - it.each([ - { name: 'report.zip', secret: undefined }, - { name: 'report.zip', secret: 'super-secret-value' }, - { name: 'report.txt', secret: 'super-secret-value' }, - ])( - 'preserves binary provenance for harvested $name with secret=$secret', - async ({ name, secret }) => { - envFlagsMock.isRemoteSandboxEnabled = true - const zip = new JSZip() - zip.file('report.txt', secret ?? 'ordinary report') - const buffer = await zip.generateAsync({ type: 'nodebuffer', compression: 'DEFLATE' }) - expect(buffer.includes('super-secret-value')).toBe(false) - mockExecuteInSandbox.mockResolvedValueOnce({ - result: null, - stdout: '', - sandboxId: 'sbx', - collectedFiles: [ - { - relativePath: name, - path: `/tmp/sim/outputs/${name}`, - contentBase64: buffer.toString('base64'), - byteLength: buffer.length, - }, - ], - }) - - const response = await POST( - createMockRequest('POST', { - code: secret ? 'token = {{MY_SECRET}}' : 'x = 1', - language: 'python', - workspaceId: 'workspace-1', - workflowId: 'workflow-1', - executionId: 'execution-1', - ...(secret ? { envVars: { MY_SECRET: secret } } : {}), - }) - ) - - expect(response.status).toBe(200) - expect(mockUploadExecutionFile).toHaveBeenCalledWith( - expect.any(Object), - buffer, - name, - expect.any(String), - 'user-123', - { - status: 'exact', - entries: secret - ? [ - { - name: 'MY_SECRET', - encryptedValue: `encrypted:${secret}`, - sourceUserId: 'user-123', - sourceWorkspaceId: 'workspace-1', - }, - ] - : [], - } - ) - const data = await response.json() - expect(data.output.files[0]).not.toHaveProperty('secretProvenance') - } - ) - - it('keeps text-looking bytes opaque when their declared format is an archive', async () => { - envFlagsMock.isRemoteSandboxEnabled = true - const buffer = Buffer.from('ASCII archive placeholder') - mockExecuteInSandbox.mockResolvedValueOnce({ - result: null, - stdout: '', - sandboxId: 'sbx', - collectedFiles: [ - { - relativePath: 'report.zip', - path: '/tmp/sim/outputs/report.zip', - contentBase64: buffer.toString('base64'), - byteLength: buffer.length, - }, - ], - }) - const response = await POST( - createMockRequest('POST', { - code: 'token = {{MY_SECRET}}', - language: 'python', - workspaceId: 'workspace-1', - workflowId: 'workflow-1', - executionId: 'execution-1', - envVars: { MY_SECRET: 'super-secret-value' }, - }) - ) - expect(response.status).toBe(200) - expect(mockUploadExecutionFile.mock.calls[0][5]).toEqual({ - status: 'exact', - entries: [ - { - name: 'MY_SECRET', - encryptedValue: 'encrypted:super-secret-value', - sourceUserId: 'user-123', - sourceWorkspaceId: 'workspace-1', - }, - ], - }) - }) - it('refuses an unknown tracked execution mount before running code', async () => { envFlagsMock.isRemoteSandboxEnabled = true const updatedAt = new Date('2026-01-01T00:00:00Z') @@ -2206,49 +1797,92 @@ describe('Function execution request', () => { expect(mockUploadExecutionFile).not.toHaveBeenCalled() }) - it('imports exact mount secrets into the trusted result registry and binary export classifier', async () => { + it('refuses a plaintext harvest carrying a secret from context variables', async () => { + envFlagsMock.isRemoteSandboxEnabled = true + const plaintext = 'table-input-secret-value' + const registry = new ResolvedSecretTraceRegistry( + [ + { + name: 'API_KEY', + plaintext, + encryptedValue: plaintext, + scope: 'workspace', + }, + ], + { userId: 'user-123', workspaceId: 'workspace-1' } + ) + registry.recordResolvedAtInputPath('API_KEY', plaintext, ['contextVariables', 'token']) + const buffer = Buffer.from(plaintext) + mockExecuteInSandbox.mockResolvedValueOnce({ + result: null, + stdout: '', + sandboxId: 'sbx', + collectedFiles: [ + { + relativePath: 'report.txt', + path: '/tmp/sim/outputs/report.txt', + contentBase64: buffer.toString('base64'), + byteLength: buffer.length, + }, + ], + }) + + const response = await POST( + createMockRequest('POST', { + code: 'x = token', + language: 'python', + workspaceId: 'workspace-1', + workflowId: 'workflow-1', + executionId: 'execution-1', + contextVariables: { token: plaintext }, + }), + registry + ) + const data = await response.json() + + expect(response.status).toBe(400) + expect(data.success).toBe(false) + expect(data.error).toContain('report.txt') + expect(data.error).toContain('contains a resolved secret value and was not returned') + expect(data.output.result).toBeNull() + expect(data.output).not.toHaveProperty('files') + expect(JSON.stringify(data)).not.toContain(plaintext) + }) + + it('refuses a harvested file when runtime provenance has a decryption fault', async () => { envFlagsMock.isRemoteSandboxEnabled = true - const updatedAt = new Date('2026-01-01T00:00:00Z') const registry = new ResolvedSecretTraceRegistry([], { userId: 'user-123', workspaceId: 'workspace-1', }) - const completePending = registry.beginPendingActivation() + registry.markIncomplete('entry-decrypt-failed') + const contentUpdatedAt = new Date('2026-01-01T00:00:00Z') mockMountContributors.mockReturnValue([ { - fileId: 'execution-file-1', + fileId: 'legacy-file', key: 'execution/workspace-1/workflow-1/execution-1/a/input.txt', context: 'execution', - contentUpdatedAt: updatedAt, + contentUpdatedAt, }, ]) dbChainMockFns.limit.mockResolvedValue([ { - fileContentUpdatedAt: updatedAt, - secretProvenanceVersion: 1, - provenanceContentUpdatedAt: updatedAt, - status: 'exact', - entries: [ - { - name: 'API_KEY', - encryptedValue: 'encrypted:mounted-secret', - sourceUserId: 'user-123', - sourceWorkspaceId: 'workspace-1', - }, - ], + fileContentUpdatedAt: contentUpdatedAt, + secretProvenanceVersion: null, + provenanceContentUpdatedAt: null, + status: null, + entries: null, }, ]) - const zip = new JSZip() - zip.file('result.txt', 'mounted-secret') - const buffer = await zip.generateAsync({ type: 'nodebuffer', compression: 'DEFLATE' }) - mockExecuteInSandbox.mockResolvedValueOnce({ - result: 'mounted-secret', + const buffer = Buffer.from('ordinary file') + mockExecuteInSandbox.mockResolvedValue({ + result: null, stdout: '', sandboxId: 'sbx', collectedFiles: [ { - relativePath: 'result.zip', - path: '/tmp/sim/outputs/result.zip', + relativePath: 'report.zip', + path: '/tmp/sim/outputs/report.zip', contentBase64: buffer.toString('base64'), byteLength: buffer.length, }, @@ -2264,216 +1898,15 @@ describe('Function execution request', () => { }), registry ) - completePending() - expect(response.status).toBe(200) - expect(registry.exportProvenance().entries).toEqual([ - expect.objectContaining({ encryptedValue: 'encrypted:mounted-secret' }), - ]) - expect(mockUploadExecutionFile.mock.calls[0][5]).toEqual({ - status: 'exact', - entries: [ - { - name: 'API_KEY', - encryptedValue: 'encrypted:mounted-secret', - sourceUserId: 'user-123', - sourceWorkspaceId: 'workspace-1', - }, - ], - }) - }) - - it.each([ - { input: 'contextVariables', archive: true, unredacted: false }, - { input: 'params', archive: true, unredacted: false }, - { input: 'contextVariables', archive: false, unredacted: false }, - { input: 'contextVariables', archive: true, unredacted: true }, - ] as const)( - 'classifies secret-bearing $input with archive=$archive and unredacted=$unredacted', - async ({ input, archive, unredacted }) => { - envFlagsMock.isRemoteSandboxEnabled = true - const plaintext = 'table-input-secret-value' - const registry = new ResolvedSecretTraceRegistry( - [ - { - name: 'API_KEY', - plaintext, - encryptedValue: plaintext, - scope: 'workspace', - ...(unredacted ? { unredacted: true as const } : {}), - }, - ], - { userId: 'user-123', workspaceId: 'workspace-1' } - ) - registry.recordResolvedAtInputPath('API_KEY', plaintext, [input, 'token']) - const zip = new JSZip() - zip.file('report.txt', plaintext) - const buffer = archive - ? await zip.generateAsync({ type: 'nodebuffer', compression: 'DEFLATE' }) - : Buffer.from(plaintext) - const name = archive ? 'report.zip' : 'report.txt' - mockExecuteInSandbox.mockResolvedValueOnce({ - result: null, - stdout: '', - sandboxId: 'sbx', - collectedFiles: [ - { - relativePath: name, - path: `/tmp/sim/outputs/${name}`, - contentBase64: buffer.toString('base64'), - byteLength: buffer.length, - }, - ], - }) - - const response = await POST( - createMockRequest('POST', { - code: input === 'params' ? "x = params['token']" : 'x = token', - language: 'python', - workspaceId: 'workspace-1', - workflowId: 'workflow-1', - executionId: 'execution-1', - [input]: { token: plaintext }, - }), - registry - ) - - expect(response.status).toBe(archive || unredacted ? 200 : 400) - if (archive) { - expect(mockUploadExecutionFile.mock.calls[0][5]).toEqual({ - status: 'exact', - entries: unredacted - ? [] - : [ - { - name: 'API_KEY', - encryptedValue: plaintext, - sourceUserId: 'user-123', - sourceWorkspaceId: 'workspace-1', - }, - ], - }) - } else { - expect(mockUploadExecutionFile).not.toHaveBeenCalled() - } - } - ) + const data = await response.json() - it.each([ - { reason: 'source-provenance-incomplete', status: 200, knownMount: false, text: false }, - { reason: 'source-provenance-incomplete', status: 200, knownMount: true, text: false }, - { reason: 'source-provenance-incomplete', status: 200, knownMount: true, text: true }, - { reason: 'entry-decrypt-failed', status: 400, knownMount: false, text: false }, - ] as const)( - 'distinguishes historical absence from provenance faults: $reason knownMount=$knownMount text=$text', - async ({ reason, status, knownMount, text }) => { - envFlagsMock.isRemoteSandboxEnabled = true - const registry = new ResolvedSecretTraceRegistry([], { - userId: 'user-123', - workspaceId: 'workspace-1', - }) - registry.markIncomplete(reason) - const contentUpdatedAt = new Date('2026-01-01T00:00:00Z') - mockMountContributors.mockReturnValue([ - { - fileId: knownMount ? 'known-file' : 'legacy-file', - key: 'execution/workspace-1/workflow-1/execution-1/a/input.txt', - context: 'execution', - contentUpdatedAt, - }, - ]) - dbChainMockFns.limit.mockResolvedValue([ - { - fileContentUpdatedAt: contentUpdatedAt, - secretProvenanceVersion: knownMount ? 1 : null, - provenanceContentUpdatedAt: knownMount ? contentUpdatedAt : null, - status: knownMount ? 'exact' : null, - entries: knownMount - ? [ - { - name: 'API_KEY', - encryptedValue: 'encrypted:mounted-secret', - sourceUserId: 'user-123', - sourceWorkspaceId: 'workspace-1', - }, - ] - : null, - }, - ]) - const buffer = Buffer.from(text ? 'Bearer mounted-secret' : 'ordinary file') - mockExecuteInSandbox.mockResolvedValue({ - result: null, - stdout: '', - sandboxId: 'sbx', - ...(text - ? { exportedFiles: { '/home/user/report.txt': buffer.toString('utf8') } } - : { - collectedFiles: [ - { - relativePath: 'report.zip', - path: '/tmp/sim/outputs/report.zip', - contentBase64: buffer.toString('base64'), - byteLength: buffer.length, - }, - ], - }), - }) - const response = await POST( - createMockRequest('POST', { - code: 'x = 1', - language: 'python', - workspaceId: 'workspace-1', - workflowId: 'workflow-1', - executionId: 'execution-1', - ...(text - ? { - outputs: { - files: [ - { - path: 'files/report.txt', - sandboxPath: '/home/user/report.txt', - mimeType: 'text/plain', - }, - ], - }, - } - : {}), - }), - registry - ) - expect(response.status).toBe(status) - if (status === 200) { - if (text) { - expect(mockWriteWorkspaceFileByPath.mock.calls[0][0].secretProvenance).toEqual({ - status: 'exact', - entries: [ - { - name: 'API_KEY', - encryptedValue: 'encrypted:mounted-secret', - sourceUserId: 'user-123', - sourceWorkspaceId: 'workspace-1', - }, - ], - }) - } else { - expect(mockUploadExecutionFile.mock.calls[0][5]).toEqual( - knownMount - ? { - status: 'exact', - entries: [ - { - name: 'API_KEY', - encryptedValue: 'encrypted:mounted-secret', - sourceUserId: 'user-123', - sourceWorkspaceId: 'workspace-1', - }, - ], - } - : { status: 'unrecorded' } - ) - } - } else expect(mockUploadExecutionFile).not.toHaveBeenCalled() - } - ) + expect(response.status).toBe(400) + expect(data.success).toBe(false) + expect(data.error).toContain('report.zip') + expect(data.error).toContain('was not returned') + expect(data.output.result).toBeNull() + expect(data.output).not.toHaveProperty('files') + }) it('does not taint a secret-free mounted file with unrelated secrets from an earlier block', async () => { envFlagsMock.isRemoteSandboxEnabled = true diff --git a/apps/sim/lib/knowledge/__integration__/execution-archive-provenance.integration.ts b/apps/sim/lib/knowledge/__integration__/execution-archive-provenance.integration.ts index 3fa7181a256..399d6319b20 100644 --- a/apps/sim/lib/knowledge/__integration__/execution-archive-provenance.integration.ts +++ b/apps/sim/lib/knowledge/__integration__/execution-archive-provenance.integration.ts @@ -1,10 +1,12 @@ -/** Real execution-file storage, ZIP extraction, durable provenance, and KB indexing. */ +/** Real Function file exports, ZIP extraction, durable provenance, and KB indexing. */ import { mkdtempSync } from 'node:fs' import { rm } from 'node:fs/promises' import { tmpdir } from 'node:os' import path from 'node:path' +import * as audit from '@sim/audit' import { db } from '@sim/db' import { + auditLog, document, documentSecretProvenance, knowledgeBase, @@ -14,12 +16,26 @@ import { workspace, workspaceFiles, } from '@sim/db/schema' +import { remoteSandboxMock, remoteSandboxMockFns } from '@sim/testing/mocks/remote-sandbox.mock' import { generateId } from '@sim/utils/id' import { eq, inArray, sql } from 'drizzle-orm' import JSZip from 'jszip' import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest' const fixtureStorage = vi.hoisted(() => ({ root: '' })) +const sandboxEnvironment = vi.hoisted(() => { + const fixture = { + SANDBOX_PROVIDER: 'e2b', + E2B_ENABLED: 'true', + E2B_API_KEY: 'integration-provider-fixture-not-a-real-key', + E2B_FUNCTION_TEMPLATE_ID: 'fixture:11111111-1111-4111-8111-111111111111', + E2B_FUNCTION_TEMPLATE_GENERATION: '1785792000000', + } + const previous = Object.entries(fixture).map(([key]) => [key, process.env[key]] as const) + Object.assign(process.env, fixture) + return previous +}) +vi.mock('@/lib/execution/remote-sandbox', () => remoteSandboxMock) vi.mock('@/lib/uploads/core/setup.server', () => ({ get UPLOAD_DIR_SERVER() { return fixtureStorage.root @@ -38,10 +54,20 @@ vi.mock('@/lib/embeddings', async () => ({ }), })) +import { functionExecuteBodySchema } from '@/lib/api/contracts' import { fileManageDecompressBodySchema } from '@/lib/api/contracts/tools/file' import { processOutboxEventById } from '@/lib/core/outbox/service' -import { encryptSecret } from '@/lib/core/security/encryption' +import { decryptSecret, encryptSecret } from '@/lib/core/security/encryption' import { isUserFile } from '@/lib/core/utils/user-file' +import { + PRIVATE_SECRET_PROVENANCE_BUNDLE_V1, + PRIVATE_SECRET_PROVENANCE_FIELD, + PRIVATE_SECRET_PROVENANCE_HEADER, + PRIVATE_TOOL_METADATA_REQUEST_HEADER, + RESOLVED_SECRET_NAMES_FIELD, + RESOLVED_SECRET_NAMES_METADATA_V1, +} from '@/lib/execution/private-tool-metadata' +import { executeFunctionRequest } from '@/lib/function-execution/execute-request' import { executeFileManageOperation } from '@/lib/internal/file/operations' import { createKnowledgeAclFixtureIds, @@ -66,6 +92,7 @@ import { isModelSafeWorkspaceFileKey, isOpaqueWorkspaceFileEgressSafe, type WorkspaceFileSecretProvenance, + type WorkspaceFileSecretProvenanceIdentity, } from '@/lib/uploads/contexts/workspace/workspace-file-secret-provenance' import { deleteFile, downloadFile } from '@/lib/uploads/core/storage-service' import { createWorkspaceFileDelegatedPrincipal } from '@/lib/workspace-files/application/delegated-principal' @@ -75,6 +102,8 @@ import { ResolvedSecretTraceRegistry } from '@/executor/utils/resolved-secret-tr const fixtures: ReturnType[] = [] const trackedEventIds: string[] = [] +const fixtureAuditCounts = new Map() +let restoreAuditObservation: (() => void) | undefined const REPORT_TEXT = 'Orion archive import retains verified source bytes through every durable surface.' const REPORT_CSV = `name,description\nOrion,${REPORT_TEXT}\n` @@ -83,6 +112,7 @@ const FIXTURE_SECRET = 'fixture-resolved-secret-not-a-live-key' async function seed() { const ids = createKnowledgeAclFixtureIds() fixtures.push(ids) + fixtureAuditCounts.set(ids.workspaceId, 0) await seedKnowledgeAclFixture(ids) return { ...ids, workflowId: generateId(), executionId: generateId() } } @@ -169,19 +199,448 @@ async function assertBlockedConsumers(ids: Fixture, source: Awaited { fixtureStorage.root = mkdtempSync(path.join(tmpdir(), 'sim-execution-archive-provenance-')) + const observe = (entry: audit.AuditLogParams) => { + if (!entry.workspaceId) return + const count = fixtureAuditCounts.get(entry.workspaceId) + if (count !== undefined) fixtureAuditCounts.set(entry.workspaceId, count + 1) + } + const recordAudit = audit.recordAudit + const recordAuditBatch = audit.recordAuditBatch + const observation = vi.spyOn(audit, 'recordAudit').mockImplementation((entry) => { + observe(entry) + recordAudit(entry) + }) + const batchObservation = vi.spyOn(audit, 'recordAuditBatch').mockImplementation((entries) => { + for (const entry of entries) observe(entry) + recordAuditBatch(entries) + }) + restoreAuditObservation = () => { + observation.mockRestore() + batchObservation.mockRestore() + } }) afterAll(async () => { - if (trackedEventIds.length) { - await db.delete(outboxEvent).where(inArray(outboxEvent.id, trackedEventIds)) + try { + /** Drain actual asynchronous inserts before deleting fixture ownership rows. */ + for (const [workspaceId, expected] of fixtureAuditCounts) { + await vi.waitFor(async () => + expect( + await db.select().from(auditLog).where(eq(auditLog.workspaceId, workspaceId)) + ).toHaveLength(expected) + ) + } + } finally { + restoreAuditObservation?.() + try { + if (trackedEventIds.length) { + await db.delete(outboxEvent).where(inArray(outboxEvent.id, trackedEventIds)) + } + for (const ids of fixtures) { + await db.delete(auditLog).where(eq(auditLog.workspaceId, ids.workspaceId)) + await db.delete(knowledgeBase).where(eq(knowledgeBase.id, ids.knowledgeBaseId)) + await db.delete(workspace).where(eq(workspace.id, ids.workspaceId)) + await db.delete(organization).where(eq(organization.id, ids.organizationId)) + await db.delete(user).where(inArray(user.id, [ids.aliceId, ids.bobId])) + } + await rm(fixtureStorage.root, { recursive: true, force: true }) + } finally { + for (const [key, value] of sandboxEnvironment) { + if (value === undefined) Reflect.deleteProperty(process.env, key) + else process.env[key] = value + } + await db.$client.end() + } + } +}) + +async function executeFunction( + ids: Fixture, + body: Record, + headers = new Headers(), + registry = new ResolvedSecretTraceRegistry([], { + userId: ids.aliceId, + workspaceId: ids.workspaceId, + }) +) { + return executeFunctionRequest( + { headers, signal: AbortSignal.timeout(15_000) }, + functionExecuteBodySchema.parse({ + workspaceId: ids.workspaceId, + workflowId: ids.workflowId, + executionId: ids.executionId, + language: 'python', + ...body, + }), + { + attributedUserId: ids.aliceId, + fileAccessUserId: ids.aliceId, + principal: createWorkspaceFileDelegatedPrincipal({ + serviceId: 'executor', + subjectUserId: ids.aliceId, + workspaceId: ids.workspaceId, + delegationId: generateId(), + executionId: ids.executionId, + }), + resolvedSecretTraceRegistry: registry, + } + ) +} + +async function readFunctionFile(ids: Fixture, fileId: string) { + const [record] = await db.select().from(workspaceFiles).where(eq(workspaceFiles.id, fileId)) + if (!record || record.workspaceId !== ids.workspaceId) { + throw new Error('Function export has no canonical record in its workspace') + } + if (record.context !== 'workspace' && record.context !== 'execution') { + throw new Error('Function export has an unexpected storage context') } - for (const ids of fixtures) { - await db.delete(knowledgeBase).where(eq(knowledgeBase.id, ids.knowledgeBaseId)) - await db.delete(workspace).where(eq(workspace.id, ids.workspaceId)) - await db.delete(organization).where(eq(organization.id, ids.organizationId)) - await db.delete(user).where(inArray(user.id, [ids.aliceId, ids.bobId])) + const identity: WorkspaceFileSecretProvenanceIdentity = { + fileId: record.id, + key: record.key, + context: record.context, + contentUpdatedAt: record.contentUpdatedAt, } - await rm(fixtureStorage.root, { recursive: true, force: true }) - await db.$client.end() + const provenance = await getBoundWorkspaceFileSecretProvenance(ids.workspaceId, identity) + const bytes = await downloadFile({ key: record.key, context: record.context, maxBytes: 8192 }) + const registry = new ResolvedSecretTraceRegistry([], { + userId: ids.aliceId, + workspaceId: ids.workspaceId, + }) + const imported = await importWorkspaceFileSecretProvenanceForRuntime({ + workspaceId: ids.workspaceId, + identity, + registry, + }) + return { identity, provenance, bytes, registry, imported } +} + +async function readArchiveReport(bytes: Buffer): Promise { + const archive = await JSZip.loadAsync(bytes) + const report = archive.file('report.txt') + if (!report) throw new Error('Stored archive is missing report.txt') + return report.async('string') +} + +/** The provider supplies bytes; Function classification, both writers and consumer admission stay real. */ +describe('Function export provenance in PostgreSQL', () => { + it.each([false, true])( + 'persists compiled binary candidates without broadening short or exempt values (protected=%s)', + async (protectedValues) => { + const ids = await seed() + const boundary = 'eight888' + const short = 'short77' + const shortEscaped = '""""' + const shortJson = JSON.stringify({ value: shortEscaped }) + const content = `${FIXTURE_SECRET}\n${boundary}\n${short}` + const zip = new JSZip() + zip.file('report.txt', content) + const bytes = await zip.generateAsync({ type: 'nodebuffer', compression: 'DEFLATE' }) + expect(bytes.includes(FIXTURE_SECRET)).toBe(false) + remoteSandboxMockFns.mockExecuteInSandbox.mockResolvedValueOnce({ + result: 'done', + stdout: '', + sandboxId: 'fixture', + exportedFiles: { + '/home/user/report.zip': bytes.toString('base64'), + ...(protectedValues + ? { '/home/user/narrow.txt': boundary, '/home/user/short.json': shortJson } + : {}), + }, + }) + const response = await executeFunction( + ids, + { + code: `exempt = {{EXEMPT_KEY}}\nshort = {{SHORT}}\nshort_escaped = {{SHORT_ESCAPED}}${ + protectedValues ? '\nprotected = {{PROTECTED_KEY}}\nboundary = {{BOUNDARY}}' : '' + }`, + envVars: { + EXEMPT_KEY: FIXTURE_SECRET, + SHORT: short, + SHORT_ESCAPED: shortEscaped, + ...(protectedValues ? { PROTECTED_KEY: FIXTURE_SECRET, BOUNDARY: boundary } : {}), + }, + unredactedSecretNames: ['EXEMPT_KEY'], + outputs: { + files: [ + { + path: 'files/report.zip', + sandboxPath: '/home/user/report.zip', + mimeType: 'application/zip', + }, + ...(protectedValues + ? [ + { path: 'files/narrow.txt', sandboxPath: '/home/user/narrow.txt' }, + { + path: 'files/short.json', + sandboxPath: '/home/user/short.json', + mimeType: 'application/json', + }, + ] + : []), + ], + }, + }, + new Headers({ [PRIVATE_TOOL_METADATA_REQUEST_HEADER]: RESOLVED_SECRET_NAMES_METADATA_V1 }) + ) + const body = await response.json() + expect(response.status, JSON.stringify(body)).toBe(200) + expect(body[RESOLVED_SECRET_NAMES_FIELD].sort()).toEqual( + protectedValues + ? ['BOUNDARY', 'EXEMPT_KEY', 'PROTECTED_KEY', 'SHORT', 'SHORT_ESCAPED'] + : ['EXEMPT_KEY', 'SHORT', 'SHORT_ESCAPED'] + ) + expect(JSON.stringify(body)).not.toContain('encryptedValue') + const exported = body.output.exported.files + expect(exported).toHaveLength(protectedValues ? 3 : 1) + const archive = await readFunctionFile(ids, exported[0].fileId) + expect(archive.bytes).toEqual(bytes) + expect(archive.provenance.status).toBe('exact') + if (archive.provenance.status !== 'exact') + throw new Error('Function export lost known lineage') + const decrypted = await Promise.all( + archive.provenance.entries.map(async (entry) => ({ + name: entry.name, + value: (await decryptSecret(entry.encryptedValue)).decrypted, + })) + ) + expect(decrypted).toEqual( + protectedValues + ? [ + { name: 'BOUNDARY', value: boundary }, + { name: 'PROTECTED_KEY', value: FIXTURE_SECRET }, + ] + : [] + ) + expect(archive.imported).toBe(true) + const readback = await readArchiveReport(archive.bytes) + expect(readback).toBe(content) + expect(projectResolvedSecretModelContent(readback, archive.registry)).toMatchObject({ + safe: true, + value: protectedValues ? `{{PROTECTED_KEY}}\n{{BOUNDARY}}\n${short}` : content, + }) + expect(await isOpaqueWorkspaceFileEgressSafe(ids.workspaceId, archive.identity)).toBe( + !protectedValues + ) + if (protectedValues) { + const narrowed = await readFunctionFile(ids, exported[1].fileId) + expect(narrowed.bytes.toString()).toBe(boundary) + expect(narrowed.provenance).toMatchObject({ + status: 'exact', + entries: [{ name: 'BOUNDARY' }], + }) + expect(narrowed.imported).toBe(true) + expect( + projectResolvedSecretModelContent(narrowed.bytes.toString(), narrowed.registry) + ).toMatchObject({ + safe: true, + value: '{{BOUNDARY}}', + }) + const shortOutput = await readFunctionFile(ids, exported[2].fileId) + expect(shortOutput.bytes.toString()).toBe(shortJson) + expect(shortOutput.provenance).toEqual({ status: 'exact', entries: [] }) + expect(shortOutput.imported).toBe(true) + expect( + projectResolvedSecretModelContent(shortOutput.bytes.toString(), shortOutput.registry) + ).toMatchObject({ safe: true, value: shortJson }) + } + } + ) + + it.each([ + { name: 'report.zip', compressed: true, known: true, absent: false }, + { name: 'report.txt', compressed: true, known: true, absent: false }, + { name: 'report.zip', compressed: false, known: true, absent: false }, + { name: 'report.zip', compressed: true, known: true, absent: true }, + { name: 'report.zip', compressed: true, known: false, absent: true }, + ])( + 'persists harvested $name lineage (compressed=$compressed, known=$known, absent=$absent)', + async ({ name, compressed, known, absent }) => { + const ids = await seed() + const provenance: WorkspaceFileSecretProvenance = { + status: 'exact', + entries: [ + { + name: 'MOUNT_TOKEN', + encryptedValue: (await encryptSecret(FIXTURE_SECRET)).encrypted, + sourceUserId: ids.aliceId, + sourceWorkspaceId: ids.workspaceId, + }, + ], + } + const input = await uploadExecutionFile( + ids, + Buffer.from(FIXTURE_SECRET), + 'input.txt', + 'text/plain', + ids.aliceId, + known ? provenance : undefined + ) + const zip = new JSZip() + zip.file('report.txt', FIXTURE_SECRET) + const bytes = compressed + ? await zip.generateAsync({ type: 'nodebuffer', compression: 'DEFLATE' }) + : Buffer.from('Ordinary UTF-8 bytes declared as an archive') + expect(bytes.includes(FIXTURE_SECRET)).toBe(false) + remoteSandboxMockFns.mockExecuteInSandbox.mockResolvedValueOnce({ + result: 'done', + stdout: '', + sandboxId: 'fixture', + collectedFiles: [ + { + relativePath: name, + path: `/tmp/sim/outputs/${name}`, + contentBase64: bytes.toString('base64'), + byteLength: bytes.length, + }, + ], + }) + const registry = new ResolvedSecretTraceRegistry([], { + userId: ids.aliceId, + workspaceId: ids.workspaceId, + }) + if (absent) registry.markIncomplete('source-provenance-incomplete') + const finishActivation = registry.beginPendingActivation() + let response: Awaited> + try { + response = await executeFunction( + ids, + { + code: 'print("done")', + files: [input], + fileKeys: [input.key], + }, + new Headers(), + registry + ) + } finally { + finishActivation() + } + const body = await response.json() + expect(response.status, JSON.stringify(body)).toBe(200) + expect(body.output.files).toHaveLength(1) + expect(JSON.stringify(body)).not.toContain('encryptedValue') + const exported = await readFunctionFile(ids, body.output.files[0].id) + expect(exported.identity.context).toBe('execution') + expect(exported.bytes).toEqual(bytes) + expect(exported.provenance).toEqual(known ? provenance : { status: 'unrecorded' }) + expect(exported.imported).toBe(true) + const value = compressed ? await readArchiveReport(exported.bytes) : exported.bytes.toString() + expect(projectResolvedSecretModelContent(value, exported.registry)).toMatchObject({ + safe: true, + value: compressed ? (known ? '{{MOUNT_TOKEN}}' : FIXTURE_SECRET) : bytes.toString(), + }) + expect(await isOpaqueWorkspaceFileEgressSafe(ids.workspaceId, exported.identity)).toBe(!known) + } + ) + + it.each([ + { input: 'params', unredacted: false }, + { input: 'contextVariables', unredacted: false }, + { input: 'contextVariables', unredacted: true }, + ] as const)( + 'persists runtime $input candidates with trusted exemption=$unredacted', + async ({ input, unredacted }) => { + const ids = await seed() + const registry = new ResolvedSecretTraceRegistry( + [ + { + name: 'INPUT_TOKEN', + plaintext: FIXTURE_SECRET, + encryptedValue: (await encryptSecret(FIXTURE_SECRET)).encrypted, + ...(unredacted ? { unredacted: true as const } : {}), + }, + ], + { userId: ids.aliceId, workspaceId: ids.workspaceId } + ) + expect( + registry.recordResolvedAtInputPath('INPUT_TOKEN', FIXTURE_SECRET, [input, 'token']) + ).toBe(true) + const zip = new JSZip() + zip.file('report.txt', FIXTURE_SECRET) + const bytes = await zip.generateAsync({ type: 'nodebuffer', compression: 'DEFLATE' }) + remoteSandboxMockFns.mockExecuteInSandbox.mockResolvedValueOnce({ + result: 'done', + stdout: '', + sandboxId: 'fixture', + collectedFiles: [ + { + relativePath: 'report.zip', + path: '/tmp/sim/outputs/report.zip', + contentBase64: bytes.toString('base64'), + byteLength: bytes.length, + }, + ], + }) + const response = await executeFunction( + ids, + { + code: input === 'params' ? 'value = params["token"]' : 'value = token', + [input]: { token: FIXTURE_SECRET }, + }, + new Headers(), + registry + ) + const body = await response.json() + expect(response.status, JSON.stringify(body)).toBe(200) + const exported = await readFunctionFile(ids, body.output.files[0].id) + expect(exported.bytes).toEqual(bytes) + expect(exported.provenance).toMatchObject({ + status: 'exact', + entries: unredacted ? [] : [{ name: 'INPUT_TOKEN' }], + }) + expect(exported.imported).toBe(true) + const readback = await readArchiveReport(exported.bytes) + expect(projectResolvedSecretModelContent(readback, exported.registry)).toMatchObject({ + safe: true, + value: unredacted ? FIXTURE_SECRET : '{{INPUT_TOKEN}}', + }) + expect(await isOpaqueWorkspaceFileEgressSafe(ids.workspaceId, exported.identity)).toBe( + unredacted + ) + } + ) + + it.each(['txt', 'zip'])( + 'persists incomplete mounted evidence for a declared %s export', + async (extension) => { + const ids = await seed() + const bytes = Buffer.from('ordinary output') + remoteSandboxMockFns.mockExecuteInSandbox.mockResolvedValueOnce({ + result: 'done', + stdout: '', + sandboxId: 'fixture', + exportedFiles: { + [`/home/user/output.${extension}`]: bytes.toString( + extension === 'zip' ? 'base64' : 'utf8' + ), + }, + }) + const response = await executeFunction( + ids, + { + code: 'print("done")', + outputs: { + files: [ + { path: `files/output.${extension}`, sandboxPath: `/home/user/output.${extension}` }, + ], + }, + [PRIVATE_SECRET_PROVENANCE_FIELD]: { + version: 1, + complete: false, + selections: [], + }, + }, + new Headers({ [PRIVATE_SECRET_PROVENANCE_HEADER]: PRIVATE_SECRET_PROVENANCE_BUNDLE_V1 }) + ) + const body = await response.json() + expect(response.status, JSON.stringify(body)).toBe(200) + const output = await readFunctionFile(ids, body.output.exported.files[0].fileId) + expect(output.bytes).toEqual(bytes) + expect(output.provenance).toEqual({ status: 'unknown' }) + expect(output.imported).toBe(false) + expect(await isOpaqueWorkspaceFileEgressSafe(ids.workspaceId, output.identity)).toBe(false) + } + ) }) describe('execution archive durable provenance', () => { From 247f849d2e5c847fae3536dd8503d5aadfdb0b10 Mon Sep 17 00:00:00 2001 From: Vikhyath Mondreti Date: Sun, 4 Oct 2026 13:09:16 -0700 Subject: [PATCH 3/3] chore(tests): cover private mount envelopes through real storage --- ...xecution-archive-provenance.integration.ts | 67 ++++++++++++++++--- 1 file changed, 59 insertions(+), 8 deletions(-) diff --git a/apps/sim/lib/knowledge/__integration__/execution-archive-provenance.integration.ts b/apps/sim/lib/knowledge/__integration__/execution-archive-provenance.integration.ts index 399d6319b20..b72f5b74471 100644 --- a/apps/sim/lib/knowledge/__integration__/execution-archive-provenance.integration.ts +++ b/apps/sim/lib/knowledge/__integration__/execution-archive-provenance.integration.ts @@ -60,6 +60,7 @@ import { processOutboxEventById } from '@/lib/core/outbox/service' import { decryptSecret, encryptSecret } from '@/lib/core/security/encryption' import { isUserFile } from '@/lib/core/utils/user-file' import { + MOUNTED_WORKSPACE_FILES_PROVENANCE_KEY, PRIVATE_SECRET_PROVENANCE_BUNDLE_V1, PRIVATE_SECRET_PROVENANCE_FIELD, PRIVATE_SECRET_PROVENANCE_HEADER, @@ -600,11 +601,27 @@ describe('Function export provenance in PostgreSQL', () => { } ) - it.each(['txt', 'zip'])( - 'persists incomplete mounted evidence for a declared %s export', - async (extension) => { + it.each([ + { extension: 'txt', evidence: 'incomplete' }, + { extension: 'zip', evidence: 'incomplete' }, + { extension: 'zip', evidence: 'complete' }, + { extension: 'zip', evidence: 'corrupt' }, + ] as const)( + 'persists $evidence private mounted evidence for a declared $extension export', + async ({ extension, evidence }) => { const ids = await seed() - const bytes = Buffer.from('ordinary output') + const { encrypted } = await encryptSecret(FIXTURE_SECRET) + const encryptedValue = + evidence === 'corrupt' + ? `${encrypted.slice(0, -1)}${encrypted.endsWith('0') ? '1' : '0'}` + : encrypted + const zip = new JSZip() + zip.file('report.txt', FIXTURE_SECRET) + const bytes = + extension === 'zip' + ? await zip.generateAsync({ type: 'nodebuffer', compression: 'DEFLATE' }) + : Buffer.from('ordinary output') + expect(bytes.includes(FIXTURE_SECRET)).toBe(false) remoteSandboxMockFns.mockExecuteInSandbox.mockResolvedValueOnce({ result: 'done', stdout: '', @@ -626,19 +643,53 @@ describe('Function export provenance in PostgreSQL', () => { }, [PRIVATE_SECRET_PROVENANCE_FIELD]: { version: 1, - complete: false, - selections: [], + complete: evidence !== 'incomplete', + selections: + evidence === 'incomplete' + ? [] + : [ + { + key: MOUNTED_WORKSPACE_FILES_PROVENANCE_KEY, + provenance: { + version: 1, + complete: true, + entries: [{ encryptedValue }], + scope: { userId: ids.aliceId, workspaceId: ids.workspaceId }, + }, + }, + ], }, }, new Headers({ [PRIVATE_SECRET_PROVENANCE_HEADER]: PRIVATE_SECRET_PROVENANCE_BUNDLE_V1 }) ) const body = await response.json() expect(response.status, JSON.stringify(body)).toBe(200) + expect(JSON.stringify(body)).not.toContain(encryptedValue) const output = await readFunctionFile(ids, body.output.exported.files[0].fileId) expect(output.bytes).toEqual(bytes) - expect(output.provenance).toEqual({ status: 'unknown' }) - expect(output.imported).toBe(false) + expect(output.imported).toBe(evidence === 'complete') expect(await isOpaqueWorkspaceFileEgressSafe(ids.workspaceId, output.identity)).toBe(false) + if (evidence === 'complete') { + expect(output.provenance).toEqual({ + status: 'exact', + entries: [ + { + name: 'MOUNTED_FILE_SECRET', + encryptedValue, + sourceUserId: ids.aliceId, + sourceWorkspaceId: ids.workspaceId, + }, + ], + }) + const readback = await readArchiveReport(output.bytes) + expect(readback).toBe(FIXTURE_SECRET) + expect(projectResolvedSecretModelContent(readback, output.registry)).toMatchObject({ + safe: true, + value: '{{MOUNTED_FILE_SECRET}}', + }) + } else { + expect(output.provenance).toEqual({ status: 'unknown' }) + } } ) })