diff --git a/.github/workflows/test-build.yml b/.github/workflows/test-build.yml index ca01e27127f..84a18b3fb2d 100644 --- a/.github/workflows/test-build.yml +++ b/.github/workflows/test-build.yml @@ -9,7 +9,7 @@ permissions: jobs: oauth-postgres: - name: OAuth and SCIM PostgreSQL (${{ matrix.provision }}) + name: PostgreSQL integration (${{ matrix.provision }}) runs-on: ${{ (vars.CI_PROVIDER == '' || vars.CI_PROVIDER == 'blacksmith') && 'blacksmith-8vcpu-ubuntu-2404' || 'ubuntu-latest' }} timeout-minutes: 15 strategy: @@ -144,6 +144,16 @@ jobs: if-no-files-found: ignore retention-days: 7 + - name: Verify durable provenance bindings and concurrent memory writes + working-directory: apps/sim + env: + TABLE_PROVENANCE_TEST_DATABASE_URL: postgresql://postgres:postgres@127.0.0.1:5432/sim_auth_scim + MEMORY_PROVENANCE_TEST_DATABASE_URL: postgresql://postgres:postgres@127.0.0.1:5432/sim_auth_scim + run: >- + bunx vitest run + lib/table/rows/secret-provenance.postgres.test.ts + lib/memory/message-provenance.postgres.test.ts + test-build: name: Lint and Test runs-on: ${{ (vars.CI_PROVIDER == '' || vars.CI_PROVIDER == 'blacksmith') && 'blacksmith-8vcpu-ubuntu-2404' || 'ubuntu-latest' }} diff --git a/apps/docs/openapi-v2-knowledge.json b/apps/docs/openapi-v2-knowledge.json index 3433f615d29..eddf2f2f991 100644 --- a/apps/docs/openapi-v2-knowledge.json +++ b/apps/docs/openapi-v2-knowledge.json @@ -1317,7 +1317,7 @@ "post": { "operationId": "searchKnowledge", "summary": "Search Knowledge", - "description": "Search one or more knowledge bases with semantic vector retrieval, optional hybrid full-text retrieval, and structured tag filters. Every result names the `knowledgeBaseId` it came from. A request body over 2 MiB is a `413`.\n\nOAuth scope: `api:read`.", + "description": "Search one or more knowledge bases with semantic vector retrieval, optional hybrid full-text retrieval, and structured tag filters. Every result names the `knowledgeBaseId` it came from. A request body over 2 MiB is a `413`. Reranking returns `409` when the stored results cannot pass secret-provenance enforcement.\n\nOAuth scope: `api:read`.", "x-sim-operation": "knowledge.search", "x-oauth-scope": "api:read", "tags": ["Knowledge Bases"], @@ -1369,6 +1369,9 @@ "404": { "$ref": "#/components/responses/NotFound" }, + "409": { + "$ref": "#/components/responses/Conflict" + }, "413": { "$ref": "#/components/responses/PayloadTooLarge" }, diff --git a/apps/sim/app/api/v2/knowledge/search/route.provenance.test.ts b/apps/sim/app/api/v2/knowledge/search/route.provenance.test.ts new file mode 100644 index 00000000000..e18e32b78e3 --- /dev/null +++ b/apps/sim/app/api/v2/knowledge/search/route.provenance.test.ts @@ -0,0 +1,347 @@ +/** + * @vitest-environment node + */ + +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ + resolveWorkspace: vi.fn(), + resolvePermission: vi.fn(), + getKnowledgeBase: vi.fn(), + resolveBilling: vi.fn(), + checkUsage: vi.fn(), + checkActorUsage: vi.fn(), + generateEmbedding: vi.fn(), + executeSearch: vi.fn(), + getDocumentMetadata: vi.fn(), + getTagDefinitions: vi.fn(), + recordEmbeddingUsage: vi.fn(), +})) + +vi.mock('@sim/platform-authz/workspace', () => ({ + permissionSatisfies: (actual: string | null, required: string) => { + const rank = { read: 1, write: 2, admin: 3 } as const + return ( + actual !== null && rank[actual as keyof typeof rank] >= rank[required as keyof typeof rank] + ) + }, + resolveEffectiveWorkspacePermission: mocks.resolvePermission, +})) + +vi.mock('@/lib/billing/core/billing-attribution', () => ({ + resolveBillingAttribution: mocks.resolveBilling, + resolveSystemBillingAttribution: mocks.resolveBilling, + checkAttributedUsageLimits: mocks.checkUsage, +})) + +/** Retrieval defaults are the flag's concern; here the flag is off so the search stays as configured. */ +vi.mock('@/lib/knowledge/access/availability', () => ({ + isKnowledgeMemberAccessAvailable: async () => false, +})) + +vi.mock('@/lib/billing/calculations/usage-monitor', () => ({ + checkActorUsageLimits: mocks.checkActorUsage, +})) + +vi.mock('@/lib/knowledge/application/contexts', () => ({ + resolveKnowledgeWorkspaceContext: mocks.resolveWorkspace, +})) + +vi.mock('@/lib/knowledge/service', () => ({ + getKnowledgeBaseById: mocks.getKnowledgeBase, +})) + +vi.mock('@/lib/knowledge/embeddings', () => ({ + generateSearchEmbedding: mocks.generateEmbedding, + recordSearchEmbeddingUsage: mocks.recordEmbeddingUsage, +})) + +vi.mock('@/lib/knowledge/search/queries', () => ({ + generateSearchEmbedding: mocks.generateEmbedding, + executeKnowledgeSearch: mocks.executeSearch, + getDocumentMetadataByIds: mocks.getDocumentMetadata, +})) + +vi.mock('@/lib/knowledge/tags/service', () => ({ + getDocumentTagDefinitions: mocks.getTagDefinitions, +})) + +vi.mock('@/lib/knowledge/tags/utils', () => ({ + buildUndefinedTagsError: (tags: string[]) => `Undefined tags: ${tags.join(', ')}`, + validateTagValue: () => null, +})) + +import { searchKnowledge } from '@/lib/knowledge/application/search' + +const workspace = { + workspaceId: 'workspace-1', + workspaceOrganizationId: null, + allowPersonalApiKeys: true, + billedAccountUserId: 'billing-owner-1', +} + +const knowledgeBase = { + id: 'knowledge-1', + userId: 'user-1', + name: 'Docs', + workspaceId: 'workspace-1', + embeddingModel: 'text-embedding-3-small', + embeddingDimension: 1536, +} + +import { document, embedding } from '@sim/db/schema' +import { sha256Hex } from '@sim/security/hash' +import { + queueTableRows, + resetDbChainMock, + V2_OPERATION_RATE_LIMIT_ALLOWED, + V2_PREAUTH_RATE_LIMIT_ALLOWED, + v2ApiKeyAuthModuleMock, + v2RateLimiterModuleMock, + v2RouteMocks, +} from '@sim/testing' +import { NextRequest } from 'next/server' +import { env } from '@/lib/core/config/env' +import { + isDurableSecretProvenanceEnforced, + resetDurableSecretProvenanceEnforcementCache, +} from '@/lib/execution/durable-secret-provenance-enforcement' +import { createKnowledgeDocumentSourceValue } from '@/lib/knowledge/secret-provenance' +import { POST } from '@/app/api/v2/knowledge/search/route' +import { ResolvedSecretTraceRegistry } from '@/executor/utils/resolved-secret-trace-registry' + +const provider = vi.hoisted(() => ({ fetch: vi.fn(), decrypt: vi.fn() })) +vi.mock('@/lib/api/server/routes/v2-api-key-auth', () => v2ApiKeyAuthModuleMock) +vi.mock('@/lib/core/rate-limiter', () => v2RateLimiterModuleMock) +vi.mock('@/lib/api-key/byok', () => ({ getBYOKKey: async () => null })) +vi.mock('@/lib/core/security/encryption', () => ({ decryptSecret: provider.decrypt })) + +const SECRET = 'synthetic-audit-secret-7b88a2' +const CONTENT = `Stored knowledge contains ${SECRET} in this synthetic fixture.` +const HASH = sha256Hex(CONTENT) +const PRINCIPAL = { kind: 'personal_api_key', userId: 'user-1', keyId: 'key-1' } as const +const requestInput = { + workspaceId: 'workspace-1', + knowledgeBaseIds: ['knowledge-1'], + query: 'find the fixture', + topK: 1, + rerankerEnabled: true, + rerankerModel: 'rerank-v4.0-fast', +} +const source = createKnowledgeDocumentSourceValue({ + filename: 'synthetic.txt', + fileUrl: 'https://example.invalid/synthetic.txt', +}) +const row = { + id: 'embedding-1', + documentId: 'document-1', + knowledgeBaseId: 'knowledge-1', + content: CONTENT, + chunkIndex: 0, + distance: 0.2, + tag1: null, + tag2: null, + tag3: null, + tag4: null, + tag5: null, + tag6: null, + tag7: null, + number1: null, + number2: null, + number3: null, + number4: null, + number5: null, + date1: null, + date2: null, + boolean1: null, + boolean2: null, + boolean3: null, +} + +function seedSidecar(status: 'exact' | 'unknown' | 'legacy' | 'missing' | 'stale' | 'malformed') { + queueTableRows(embedding, [ + { + ...row, + secretProvenanceVersion: status === 'legacy' ? null : 1, + chunkHash: HASH, + provenanceContentHash: status === 'stale' ? 'old-hash' : HASH, + status: status === 'missing' ? null : status === 'unknown' ? 'unknown' : 'exact', + entries: + status === 'malformed' + ? [{ encryptedValue: 123 }] + : status === 'exact' + ? [ + { + name: 'TOKEN', + encryptedValue: 'synthetic-encrypted-token', + sourceUserId: 'user-1', + sourceWorkspaceId: 'workspace-1', + }, + ] + : [], + }, + ]) + queueTableRows(document, [ + { + id: 'document-1', + ...source, + secretProvenanceVersion: null, + provenanceSourceHash: null, + status: null, + entries: null, + }, + ]) +} + +function providerPayload() { + expect(provider.fetch).toHaveBeenCalledTimes(1) + expect(provider.fetch.mock.calls[0][0]).toBe('https://api.cohere.com/v2/rerank') + return JSON.parse(provider.fetch.mock.calls[0][1].body) +} + +function enforceKnowledge(enforced: boolean) { + env.DURABLE_SECRET_PROVENANCE_ENFORCED_SURFACES = enforced ? 'all' : '' + resetDurableSecretProvenanceEnforcementCache() + expect(isDurableSecretProvenanceEnforced('knowledge')).toBe(enforced) +} + +async function requestSearch(overrides: Partial = {}) { + return POST( + new NextRequest('http://localhost/api/v2/knowledge/search', { + method: 'POST', + headers: { 'content-type': 'application/json', 'x-api-key': 'synthetic-key' }, + body: JSON.stringify({ ...requestInput, ...overrides }), + }) + ) +} + +beforeEach(() => { + vi.clearAllMocks() + resetDbChainMock() + enforceKnowledge(true) + env.COHERE_API_KEY = 'synthetic-cohere-key' + provider.decrypt.mockResolvedValue({ decrypted: SECRET }) + provider.fetch.mockResolvedValue( + new Response(JSON.stringify({ results: [{ index: 0, relevance_score: 0.9 }] }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }) + ) + vi.stubGlobal('fetch', provider.fetch) + mocks.resolveWorkspace.mockResolvedValue(workspace) + mocks.resolvePermission.mockResolvedValue('read') + mocks.getKnowledgeBase.mockResolvedValue(knowledgeBase) + mocks.resolveBilling.mockResolvedValue({ actorUserId: 'user-1', workspaceId: 'workspace-1' }) + mocks.checkUsage.mockResolvedValue({ isExceeded: false }) + mocks.checkActorUsage.mockResolvedValue({ isExceeded: false }) + mocks.generateEmbedding.mockResolvedValue({ embedding: [0.1], isBYOK: false }) + mocks.executeSearch.mockResolvedValue([row]) + mocks.getDocumentMetadata.mockResolvedValue({ + 'document-1': { filename: 'synthetic.txt', sourceUrl: null }, + }) + mocks.getTagDefinitions.mockResolvedValue([]) + v2RouteMocks.preauthRate.mockResolvedValue(V2_PREAUTH_RATE_LIMIT_ALLOWED) + v2RouteMocks.operationRate.mockResolvedValue(V2_OPERATION_RATE_LIMIT_ALLOWED) + v2RouteMocks.authenticate.mockResolvedValue({ + principal: PRINCIPAL, + rateLimitSubjectIds: ['api-key:key-1', 'workspace:workspace-1'], + rateLimitSubscription: null, + keyType: 'personal', + }) +}) + +/** The route, use case, sidecar binding/import, registry, projection and provider request builder are real. */ +describe('Knowledge search provenance through the V2 route and reranker HTTP boundary', () => { + it.each([false, true])( + 'redacts current known-secret chunks with enforcement=%s', + async (enforced) => { + enforceKnowledge(enforced) + seedSidecar('exact') + const response = await requestSearch() + const body = await response.json() + expect(response.status).toBe(200) + expect(body.data.rerankerStatus).toBe('applied') + expect(providerPayload().documents).toEqual([CONTENT.replace(SECRET, '{{TOKEN}}')]) + expect(body.data.results[0].content).toBe(CONTENT) + expect(provider.decrypt).toHaveBeenCalledWith('synthetic-encrypted-token') + expect(mocks.generateEmbedding).toHaveBeenCalledWith( + requestInput.query, + expect.anything(), + 'workspace-1' + ) + } + ) + + it('does not assign a billing owner secret name to a workspace-key caller', async () => { + seedSidecar('exact') + v2RouteMocks.authenticate.mockResolvedValue({ + principal: { kind: 'workspace_api_key', workspaceId: 'workspace-1', keyId: 'key-1' }, + rateLimitSubjectIds: ['api-key:key-1', 'workspace:workspace-1'], + rateLimitSubscription: null, + keyType: 'workspace', + }) + const response = await requestSearch() + expect(response.status).toBe(200) + expect(providerPayload().documents).toEqual([CONTENT.replace(SECRET, '[REDACTED_SECRET]')]) + }) + + it('keeps a trusted incoming registry for existing internal and tool callers', async () => { + seedSidecar('exact') + const registry = new ResolvedSecretTraceRegistry([], { + userId: 'user-1', + workspaceId: 'workspace-1', + }) + const result = await searchKnowledge.execute({ + principal: PRINCIPAL, + input: { ...requestInput, resultSecretRegistry: registry }, + }) + expect(result.rerankerStatus).toBe('applied') + expect(result.resultSecretRegistry).toBe(registry) + expect(providerPayload().documents).toEqual([CONTENT.replace(SECRET, '{{TOKEN}}')]) + }) + + it.each(['unknown', 'missing', 'stale', 'malformed'] as const)( + 'refuses %s tracked provenance before provider HTTP when enforcement is enabled', + async (status) => { + seedSidecar(status) + const response = await requestSearch() + expect(response.status).toBe(409) + expect(await response.json()).toMatchObject({ + error: { code: 'CONFLICT', message: 'Knowledge result secret provenance is unavailable' }, + }) + expect(provider.fetch).not.toHaveBeenCalled() + } + ) + + it.each(['unknown', 'missing', 'stale', 'malformed'] as const)( + 'preserves existing flag-off compatibility for %s sidecars', + async (status) => { + enforceKnowledge(false) + seedSidecar(status) + const response = await requestSearch() + expect(response.status).toBe(200) + expect(providerPayload().documents).toEqual([CONTENT]) + } + ) + + it.each([false, true])( + 'keeps pre-tracking NULL rows readable with enforcement=%s', + async (enforced) => { + enforceKnowledge(enforced) + seedSidecar('legacy') + const response = await requestSearch() + expect(response.status).toBe(200) + expect(providerPayload().documents).toEqual([CONTENT]) + } + ) + + it('does not subject a raw public read without reranking to durable-model enforcement', async () => { + seedSidecar('unknown') + const response = await requestSearch({ rerankerEnabled: false }) + const body = await response.json() + expect(response.status).toBe(200) + expect(body.data.results[0].content).toBe(CONTENT) + expect(provider.fetch).not.toHaveBeenCalled() + expect(provider.decrypt).not.toHaveBeenCalled() + }) +}) diff --git a/apps/sim/content/blog/secret-provenance/index.mdx b/apps/sim/content/blog/secret-provenance/index.mdx index 20a843d2055..d09961f08e2 100644 --- a/apps/sim/content/blog/secret-provenance/index.mdx +++ b/apps/sim/content/blog/secret-provenance/index.mdx @@ -21,7 +21,7 @@ faq: - q: "Does Sim use regular expressions to detect secrets in logs?" a: "No. Sim resolved the secret itself, so it can compare content against the exact values activated in that run. Pattern matching is useful when a scanner does not know which secrets exist; the execution runtime has stronger context." - q: "What happens if Sim cannot determine whether content contains a secret?" - a: "The boundary fails closed. The payload is withheld, while non-sensitive structure such as timing, block identity, and status remains available for debugging." + a: "Incomplete provenance at a run's own egress boundaries withholds the payload while preserving safe diagnostic structure. Durable reads follow the surface's enforcement policy: legacy content remains readable, and unknown stored provenance can be observed before enforcement is enabled." - q: "Are secrets tracked across files and tables, or only within one execution?" a: "Across both. Durable content such as workspace files, table cells, knowledge documents, and agent memory carries encrypted provenance so a later run does not mistake secret-bearing data for clean data." - q: "Is the model treated as an internal component or an egress boundary?" diff --git a/apps/sim/executor/handlers/agent/memory.ts b/apps/sim/executor/handlers/agent/memory.ts index 2498aba5dd0..cb12dddfca6 100644 --- a/apps/sim/executor/handlers/agent/memory.ts +++ b/apps/sim/executor/handlers/agent/memory.ts @@ -7,7 +7,6 @@ import { and, eq, sql } from 'drizzle-orm' import { bindDurableSecretProvenanceToValue, durableSecretProvenanceFromRegistry, - filterDurableSecretProvenanceBySourceValues, importDurableSecretProvenance, mergeDurableSecretProvenance, } from '@/lib/execution/durable-secret-provenance' @@ -16,7 +15,9 @@ import { reportUnrecordedDurableProvenance, } from '@/lib/execution/durable-secret-provenance-enforcement' import { redactObjectStrings } from '@/lib/logs/execution/pii-redaction' +import { lockMemoryConversationInTx } from '@/lib/memory/locks' import { + createMemorySecretProvenanceSelector, readBoundMemorySecretProvenance, replaceMemorySecretProvenanceInTx, } from '@/lib/memory/secret-provenance' @@ -75,10 +76,62 @@ export class Memory { messages = stored.messages } - const selectedProvenance = filterDurableSecretProvenanceBySourceValues( + const selection = await createMemorySecretProvenanceSelector( stored.provenance, - messages + stored.messages, + workspaceId ) + let includeRecovered = false + if (selection.recoveredEntryCount > 0 && ctx.resolvedSecretTraceRegistry) { + const scope = ctx.resolvedSecretTraceRegistry.exportProvenance().scope + const staged = new ResolvedSecretTraceRegistry([], scope, { staged: true }) + staged.mergeToolCallRegistry(ctx.resolvedSecretTraceRegistry) + includeRecovered = + (await importDurableSecretProvenance( + staged, + selection.select(messages, true), + messages, + 'memory' + )) && staged.getModelEgressSnapshot().complete + if (includeRecovered) { + for (const message of messages) { + const stagedMessage = new ResolvedSecretTraceRegistry([], scope, { staged: true }) + if ( + !(await importDurableSecretProvenance( + stagedMessage, + selection.select([message], true), + message, + 'memory' + )) || + !stagedMessage.getModelEgressSnapshot().complete + ) { + includeRecovered = false + break + } + } + } + if (includeRecovered) { + /** Recheck and merge synchronously after the preflight's awaits, including sibling work. */ + const current = new ResolvedSecretTraceRegistry([], scope, { staged: true }) + current.mergeToolCallRegistry(ctx.resolvedSecretTraceRegistry) + current.mergeToolCallRegistry(staged) + includeRecovered = current.getModelEgressSnapshot().complete + if (includeRecovered) ctx.resolvedSecretTraceRegistry.mergeToolCallRegistry(staged) + } + } + if (selection.recoveredEntryCount > 0 && !includeRecovered) { + logger.error('Historical memory secret provenance recovery was skipped', { + surface: 'memory', + cause: ctx.resolvedSecretTraceRegistry + ? 'legacy-recovery-capacity-exceeded' + : 'legacy-recovery-context-unavailable', + entryCount: selection.recoveredEntryCount, + workspaceId, + }) + } + const selectProvenance = (values: readonly unknown[]) => + selection.select(values, includeRecovered) + const selectedProvenance = selectProvenance(messages) /** * Unrecorded provenance is checked through the same policy the shared import uses, so stored * memory written by a run that could not vouch does not permanently refuse every later turn. @@ -115,9 +168,7 @@ export class Memory { return Promise.all( messages.map(async (message) => { - const messageProvenance = filterDurableSecretProvenanceBySourceValues(selectedProvenance, [ - message, - ]) + const messageProvenance = selectProvenance([message]) const modelRegistry = new ResolvedSecretTraceRegistry( [], ctx.resolvedSecretTraceRegistry?.exportProvenance().scope @@ -168,7 +219,7 @@ export class Memory { const workspaceId = this.requireWorkspaceId(ctx) this.validateConversationId(inputs.conversationId) - message = await this.maskContentForStorage(ctx, message) + message = this.sanitizeMessageForStorage(await this.maskContentForStorage(ctx, message)) this.validateContent(message.content) @@ -217,7 +268,9 @@ export class Memory { } messagesToStore = await Promise.all( - messagesToStore.map((message) => this.maskContentForStorage(ctx, message)) + messagesToStore.map(async (message) => + this.sanitizeMessageForStorage(await this.maskContentForStorage(ctx, message)) + ) ) const provenance = ctx.resolvedSecretTraceRegistry @@ -503,6 +556,7 @@ export class Memory { const sanitizedMessages = messages.map((message) => this.sanitizeMessageForStorage(message)) await db.transaction(async (tx) => { + await lockMemoryConversationInTx(tx, workspaceId, key) const id = generateId() const [inserted] = await tx .insert(memory) @@ -534,6 +588,7 @@ export class Memory { const sanitizedMessage = this.sanitizeMessageForStorage(message) await db.transaction(async (tx) => { + await lockMemoryConversationInTx(tx, workspaceId, key) const [existing] = await tx .select({ id: memory.id, @@ -586,11 +641,17 @@ export class Memory { }) .where(eq(memory.id, existing.id)) if (messageProvenance) { + const nextProvenance = mergeDurableSecretProvenance(previousProvenance, messageProvenance) await replaceMemorySecretProvenanceInTx( tx, existing.id, nextData, - mergeDurableSecretProvenance(previousProvenance, messageProvenance) + nextProvenance, + previousProvenance.status === 'unknown' + ? 'inherited-provenance-unknown' + : messageProvenance.status === 'exact' && nextProvenance.status === 'unknown' + ? 'merge-provenance-limit' + : undefined ) } }) diff --git a/apps/sim/lib/api/contracts/v2/openapi/knowledge.ts b/apps/sim/lib/api/contracts/v2/openapi/knowledge.ts index e9b951dc677..9831dd9cd4e 100644 --- a/apps/sim/lib/api/contracts/v2/openapi/knowledge.ts +++ b/apps/sim/lib/api/contracts/v2/openapi/knowledge.ts @@ -573,8 +573,14 @@ const declaredRoutes = [ operationId: 'searchKnowledge', summary: 'Search Knowledge', description: - 'Search one or more knowledge bases with semantic vector retrieval, optional hybrid full-text retrieval, and structured tag filters. Every result names the `knowledgeBaseId` it came from. A request body over 2 MiB is a `413`.', - errors: [...WORKSPACE_ERRORS, 'UsageLimitExceeded', 'NotFound', 'PayloadTooLarge'], + 'Search one or more knowledge bases with semantic vector retrieval, optional hybrid full-text retrieval, and structured tag filters. Every result names the `knowledgeBaseId` it came from. A request body over 2 MiB is a `413`. Reranking returns `409` when the stored results cannot pass secret-provenance enforcement.', + errors: [ + ...WORKSPACE_ERRORS, + 'UsageLimitExceeded', + 'NotFound', + 'PayloadTooLarge', + 'Conflict', + ], success: { description: 'Matching document chunks ordered by relevance.', }, diff --git a/apps/sim/lib/copilot/request/tools/client-completion-seal.server.ts b/apps/sim/lib/copilot/request/tools/client-completion-seal.server.ts index de26b1d9897..b9d674fc173 100644 --- a/apps/sim/lib/copilot/request/tools/client-completion-seal.server.ts +++ b/apps/sim/lib/copilot/request/tools/client-completion-seal.server.ts @@ -32,6 +32,59 @@ interface SealClientToolContextInput extends ClientToolBinding { toolInput: unknown } +export type ClientToolUnsealFailureReason = + | 'missing-envelope' + | 'malformed-envelope' + | 'decrypt-failed' + | 'invalid-json' + | 'invalid-content' + | 'binding-mismatch' + | 'registry-mismatch' + | 'invalid-provenance' + +type ReportUnsealFailure = (reason: ClientToolUnsealFailureReason) => void + +/** Reads a sealed record while exposing only the guard that refused it, never its contents. */ +async function readSealedRecord( + value: unknown, + field: typeof SEALED_CLIENT_TOOL_COMPLETION_FIELD | typeof SEALED_CLIENT_TOOL_CONTEXT_FIELD, + reportFailure?: ReportUnsealFailure +): Promise | null> { + if (!isPlainRecord(value)) { + reportFailure?.(value == null ? 'missing-envelope' : 'malformed-envelope') + return null + } + const sealed = value[field] + if (sealed === undefined) { + reportFailure?.('missing-envelope') + return null + } + if (typeof sealed !== 'string' || sealed.length === 0) { + reportFailure?.('malformed-envelope') + return null + } + let decrypted: string + try { + const result = await decryptSecret(sealed) + decrypted = result.decrypted + } catch { + reportFailure?.('decrypt-failed') + return null + } + let content: unknown + try { + content = JSON.parse(decrypted) + } catch { + reportFailure?.('invalid-json') + return null + } + if (!isPlainRecord(content)) { + reportFailure?.('invalid-content') + return null + } + return content +} + type ClientCompletionSealGlobal = typeof globalThis & { _clientToolRegistryInstanceIds?: WeakMap } @@ -66,26 +119,24 @@ export async function sealClientToolCompletion( export async function unsealClientToolCompletion( value: unknown, - expected: ClientToolBinding + expected: ClientToolBinding, + reportFailure?: ReportUnsealFailure ): Promise { - if (!isPlainRecord(value)) return null - const sealed = value[SEALED_CLIENT_TOOL_COMPLETION_FIELD] - if (typeof sealed !== 'string' || sealed.length === 0) return null - - try { - const { decrypted } = await decryptSecret(sealed) - const content: unknown = JSON.parse(decrypted) - if (!isPlainRecord(content)) return null - if (!bindingMatches(content, expected)) return null - if (content.message !== undefined && typeof content.message !== 'string') return null - return { - ...expected, - ...(content.message !== undefined ? { message: content.message } : {}), - ...(Object.hasOwn(content, 'data') ? { data: content.data } : {}), - } - } catch { + const content = await readSealedRecord(value, SEALED_CLIENT_TOOL_COMPLETION_FIELD, reportFailure) + if (!content) return null + if (!bindingMatches(content, expected)) { + reportFailure?.('binding-mismatch') return null } + if (content.message !== undefined && typeof content.message !== 'string') { + reportFailure?.('invalid-content') + return null + } + return { + ...expected, + ...(content.message !== undefined ? { message: content.message } : {}), + ...(Object.hasOwn(content, 'data') ? { data: content.data } : {}), + } } export async function sealClientToolContext( @@ -114,24 +165,26 @@ export function retainSealedClientToolContext( export async function unsealClientToolContext( value: unknown, expected: ClientToolBinding, - registry: ResolvedSecretTraceRegistry + registry: ResolvedSecretTraceRegistry, + reportFailure?: ReportUnsealFailure ): Promise { - if (!isPlainRecord(value)) return null - const sealed = value[SEALED_CLIENT_TOOL_CONTEXT_FIELD] - if (typeof sealed !== 'string' || sealed.length === 0) return null - - try { - const { decrypted } = await decryptSecret(sealed) - const context: unknown = JSON.parse(decrypted) - if (!isPlainRecord(context) || !bindingMatches(context, expected)) return null - if (context.registryInstanceId !== getRegistryInstanceId(registry)) return null - if (!isResolvedSecretTraceProvenanceV1(context.provenance)) return null - return { - ...expected, - registryInstanceId: context.registryInstanceId, - provenance: context.provenance, - } - } catch { + const context = await readSealedRecord(value, SEALED_CLIENT_TOOL_CONTEXT_FIELD, reportFailure) + if (!context) return null + if (!bindingMatches(context, expected)) { + reportFailure?.('binding-mismatch') return null } + if (context.registryInstanceId !== getRegistryInstanceId(registry)) { + reportFailure?.('registry-mismatch') + return null + } + if (!isResolvedSecretTraceProvenanceV1(context.provenance)) { + reportFailure?.('invalid-provenance') + return null + } + return { + ...expected, + registryInstanceId: context.registryInstanceId, + provenance: context.provenance, + } } diff --git a/apps/sim/lib/copilot/request/tools/client.test.ts b/apps/sim/lib/copilot/request/tools/client.test.ts index 8e0456dae08..26a0e82a60b 100644 --- a/apps/sim/lib/copilot/request/tools/client.test.ts +++ b/apps/sim/lib/copilot/request/tools/client.test.ts @@ -10,12 +10,18 @@ const { waitForToolConfirmation, replaceTerminalAsyncToolCallResult, getTrustedWorkflowToolExecution, + mockError, } = vi.hoisted(() => ({ encryptSecret: vi.fn(), decryptSecret: vi.fn(), waitForToolConfirmation: vi.fn(), replaceTerminalAsyncToolCallResult: vi.fn(), getTrustedWorkflowToolExecution: vi.fn(), + mockError: vi.fn(), +})) + +vi.mock('@sim/logger', () => ({ + createLogger: () => ({ error: mockError, warn: vi.fn(), info: vi.fn(), debug: vi.fn() }), })) vi.mock('@/lib/core/security/encryption', () => ({ @@ -934,4 +940,120 @@ describe('generic client tool completion', () => { }) expect(JSON.stringify(completion)).not.toContain('raw') }) + + it.each([ + { kind: 'missing-completion', completionFailure: 'missing-envelope' }, + { kind: 'missing-context', contextFailure: 'missing-envelope' }, + { kind: 'malformed-completion', completionFailure: 'malformed-envelope' }, + { kind: 'decrypt-failure', completionFailure: 'decrypt-failed' }, + { kind: 'invalid-json', completionFailure: 'invalid-json' }, + { kind: 'invalid-content', completionFailure: 'invalid-content' }, + { kind: 'wrong-binding', completionFailure: 'binding-mismatch' }, + { kind: 'wrong-registry', contextFailure: 'registry-mismatch' }, + { kind: 'invalid-provenance', contextFailure: 'invalid-provenance' }, + ])( + 'attributes $kind before replacing the result without logging sealed content', + async ({ kind, ...failures }) => { + const registry = createClientRegistry() + const binding = { toolCallId: 'tool-1', runId: 'run-1', userId: 'user-1' } + const sealedContext = await sealClientToolContext({ + ...binding, + registry, + toolInput: { query: 'resolved-secret' }, + }) + const data: Record = { + ...sealedContext, + __sealedClientToolCompletionV1: JSON.stringify({ + ...binding, + data: { content: 'resolved-secret' }, + }), + } + switch (kind) { + case 'missing-completion': + data.__sealedClientToolCompletionV1 = undefined + break + case 'missing-context': + data.__sealedClientToolContextV1 = undefined + break + case 'malformed-completion': + data.__sealedClientToolCompletionV1 = 1 + break + case 'decrypt-failure': + data.__sealedClientToolCompletionV1 = 'sensitive-ciphertext' + decryptSecret.mockImplementation(async (encrypted: string) => { + if (encrypted === 'sensitive-ciphertext') throw new Error('sensitive-decrypt-error') + return { decrypted: encrypted } + }) + break + case 'invalid-json': + data.__sealedClientToolCompletionV1 = 'sensitive-invalid-json' + break + case 'invalid-content': + data.__sealedClientToolCompletionV1 = JSON.stringify({ ...binding, message: 1 }) + break + case 'wrong-binding': + data.__sealedClientToolCompletionV1 = JSON.stringify({ + ...binding, + toolCallId: 'different-tool', + }) + break + case 'wrong-registry': + Object.assign( + data, + await sealClientToolContext({ + ...binding, + registry: createClientRegistry(), + toolInput: {}, + }) + ) + break + case 'invalid-provenance': { + const context = JSON.parse(sealedContext.__sealedClientToolContextV1) + data.__sealedClientToolContextV1 = JSON.stringify({ + ...context, + provenance: { version: 999 }, + }) + break + } + } + waitForToolConfirmation.mockResolvedValue({ status: 'success', data }) + + await waitForClientToolCompletion({ ...binding, registry, timeoutMs: 1_000 }) + + const diagnostics = mockError.mock.calls.filter( + ([message]) => message === 'Client tool provenance could not be restored' + ) + expect(diagnostics).toEqual([ + [ + 'Client tool provenance could not be restored', + { + toolCallId: binding.toolCallId, + runId: binding.runId, + ...failures, + }, + ], + ]) + expect(mockError.mock.invocationCallOrder[0]).toBeLessThan( + replaceTerminalAsyncToolCallResult.mock.invocationCallOrder[0] + ) + expect(JSON.stringify(diagnostics)).not.toMatch(/sensitive-|resolved-secret|SECRET|__sealed/) + expect(registry.isPermanentlyIncomplete()).toBe(false) + } + ) + + it('does not report an unseal fault when no run binding was supplied', async () => { + waitForToolConfirmation.mockResolvedValue({ status: 'error', data: {} }) + await waitForClientToolCompletion({ + toolCallId: 'tool-1', + userId: 'user-1', + registry: createClientRegistry(), + timeoutMs: 1_000, + }) + expect( + mockError.mock.calls.filter( + ([message]) => message === 'Client tool provenance could not be restored' + ) + ).toEqual([]) + expect(decryptSecret).not.toHaveBeenCalled() + }) }) diff --git a/apps/sim/lib/copilot/request/tools/client.ts b/apps/sim/lib/copilot/request/tools/client.ts index 663482dc028..4765bc6c84a 100644 --- a/apps/sim/lib/copilot/request/tools/client.ts +++ b/apps/sim/lib/copilot/request/tools/client.ts @@ -10,6 +10,7 @@ import { replaceTerminalAsyncToolCallResult } from '@/lib/copilot/async-runs/rep import { MothershipStreamV1ToolOutcome } from '@/lib/copilot/generated/mothership-stream-v1' import { waitForToolConfirmation } from '@/lib/copilot/persistence/tool-confirm' import { + type ClientToolUnsealFailureReason, unsealClientToolCompletion, unsealClientToolContext, } from '@/lib/copilot/request/tools/client-completion-seal.server' @@ -91,6 +92,8 @@ export async function waitForClientToolCompletion({ const registryCanImport = toolRegistry !== undefined && !toolRegistry.isPermanentlyIncomplete() const finishPendingActivation = toolRegistry?.beginPendingActivation() let content: Awaited> = null + let completionFailure: ClientToolUnsealFailureReason | undefined + let contextFailure: ClientToolUnsealFailureReason | undefined try { /** * A tool invoked without a run id has no binding to unseal against, which is a configuration @@ -101,12 +104,25 @@ export async function waitForClientToolCompletion({ const [sealedContent, sealedContext] = sealingAttempted && binding && registry ? await Promise.all([ - unsealClientToolCompletion(completion.data, binding), - unsealClientToolContext(completion.data, binding, registry), + unsealClientToolCompletion(completion.data, binding, (reason) => { + completionFailure = reason + }), + unsealClientToolContext(completion.data, binding, registry, (reason) => { + contextFailure = reason + }), ]) : [null, null] if (toolRegistry && registryCanImport) { if (!sealedContent || !sealedContext) { + if (sealingAttempted) { + /** The durable row is replaced below, so report the failing guard before it is lost. */ + logger.error('Client tool provenance could not be restored', { + toolCallId, + runId, + ...(completionFailure ? { completionFailure } : {}), + ...(contextFailure ? { contextFailure } : {}), + }) + } toolRegistry.markIncomplete( sealingAttempted ? 'client-tool-seal-failed' : 'client-tool-seal-absent' ) @@ -125,6 +141,11 @@ export async function waitForClientToolCompletion({ } } } catch { + logger.error('Client tool provenance could not be restored', { + toolCallId, + runId, + cause: 'unexpected-unseal-error', + }) toolRegistry?.markIncomplete('client-tool-seal-failed', { origin: 'copilotToolClient.sealedContext', }) diff --git a/apps/sim/lib/core/security/encryption.test.ts b/apps/sim/lib/core/security/encryption.test.ts index fef678cf98f..1db89af3bbc 100644 --- a/apps/sim/lib/core/security/encryption.test.ts +++ b/apps/sim/lib/core/security/encryption.test.ts @@ -1,6 +1,9 @@ import { createEnvMock } from '@sim/testing' import { afterEach, describe, expect, it, vi } from 'vitest' +const { mockError } = vi.hoisted(() => ({ mockError: vi.fn() })) +vi.mock('@sim/logger', () => ({ createLogger: () => ({ error: mockError }) })) + vi.mock('@/lib/core/config/env', () => createEnvMock({ ENCRYPTION_KEY: '0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef', @@ -8,7 +11,7 @@ vi.mock('@/lib/core/config/env', () => ) import { env } from '@/lib/core/config/env' -import { decryptSecret, encryptSecret, generatePassword } from './encryption' +import { decryptSecret, encryptSecret, generatePassword } from '@/lib/core/security/encryption' describe('encryptSecret', () => { it('should encrypt a secret and return encrypted value with IV', async () => { @@ -56,6 +59,20 @@ describe('encryptSecret', () => { }) describe('decryptSecret', () => { + it('logs and throws decryption failures by default', async () => { + mockError.mockClear() + await expect(decryptSecret('invalid')).rejects.toThrow('Invalid encrypted value format') + expect(mockError).toHaveBeenCalledOnce() + }) + + it('still throws when a caller owns aggregate failure reporting', async () => { + mockError.mockClear() + await expect(decryptSecret('invalid', { logFailure: false })).rejects.toThrow( + 'Invalid encrypted value format' + ) + expect(mockError).not.toHaveBeenCalled() + }) + it('should decrypt an encrypted secret back to original value', async () => { const originalSecret = 'my-secret-value' const { encrypted } = await encryptSecret(originalSecret) diff --git a/apps/sim/lib/core/security/encryption.ts b/apps/sim/lib/core/security/encryption.ts index 2ffaff4ef23..064218b863c 100644 --- a/apps/sim/lib/core/security/encryption.ts +++ b/apps/sim/lib/core/security/encryption.ts @@ -27,11 +27,16 @@ export async function encryptSecret(secret: string): Promise<{ encrypted: string * Decrypts a secret previously produced by {@link encryptSecret}. Logs and * rethrows on malformed input or tampered ciphertext. */ -export async function decryptSecret(encryptedValue: string): Promise<{ decrypted: string }> { +export async function decryptSecret( + encryptedValue: string, + options: { logFailure?: boolean } = {} +): Promise<{ decrypted: string }> { try { return await decrypt(encryptedValue, getEncryptionKey()) } catch (error) { - logger.error('Decryption error:', { error: toError(error).message }) + if (options.logFailure !== false) { + logger.error('Decryption error:', { error: toError(error).message }) + } throw error } } diff --git a/apps/sim/lib/execution/durable-secret-provenance-enforcement.test.ts b/apps/sim/lib/execution/durable-secret-provenance-enforcement.test.ts index e25aeea752f..e4b1c092356 100644 --- a/apps/sim/lib/execution/durable-secret-provenance-enforcement.test.ts +++ b/apps/sim/lib/execution/durable-secret-provenance-enforcement.test.ts @@ -3,14 +3,18 @@ */ import { beforeEach, describe, expect, it, vi } from 'vitest' -const { mockEnv, mockLogger, mockRecordAudit } = vi.hoisted(() => ({ +const { mockEnv, mockLogger, mockPersistenceLogger, mockRecordAudit } = vi.hoisted(() => ({ mockEnv: {} as Record, mockLogger: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() }, + mockPersistenceLogger: { error: vi.fn() }, mockRecordAudit: vi.fn(), })) vi.mock('@/lib/core/config/env', () => ({ env: mockEnv })) -vi.mock('@sim/logger', () => ({ createLogger: () => mockLogger })) +vi.mock('@sim/logger', () => ({ + createLogger: (module: string) => + module === 'DurableSecretProvenancePersistence' ? mockPersistenceLogger : mockLogger, +})) /** Literal values rather than the real constants: these reach the database and the trail. */ vi.mock('@sim/audit', () => ({ recordAudit: mockRecordAudit, @@ -21,6 +25,8 @@ vi.mock('@sim/audit', () => ({ import { DURABLE_SECRET_PROVENANCE_SURFACES, isDurableSecretProvenanceEnforced, + reportDurableSecretProvenanceRefusal, + reportDurableSecretProvenanceWrite, reportUnrecordedDurableProvenance, resetDurableSecretProvenanceEnforcementCache, } from '@/lib/execution/durable-secret-provenance-enforcement' @@ -147,4 +153,50 @@ describe('durable secret provenance enforcement', () => { expect(mockRecordAudit).not.toHaveBeenCalled() expect(mockLogger.error).toHaveBeenCalled() }) + + it('separates non-exact write telemetry from permissive reads and copies only safe fields', () => { + const report = { + surface: 'knowledge' as const, + status: 'unknown' as const, + cause: 'source-provenance-unknown' as const, + recordCount: 2, + workspaceId: 'workspace-1', + resourceId: 'document-1', + content: 'private document content', + entries: [{ secretName: 'PRIVATE_TOKEN', ciphertext: 'private encrypted value' }], + } + reportDurableSecretProvenanceWrite(report) + + expect(mockPersistenceLogger.error).toHaveBeenCalledWith( + 'Writing non-exact durable secret provenance', + { + surface: 'knowledge', + status: 'unknown', + cause: 'source-provenance-unknown', + recordCount: 2, + workspaceId: 'workspace-1', + resourceId: 'document-1', + } + ) + expect(mockLogger.error).not.toHaveBeenCalled() + expect(mockRecordAudit).not.toHaveBeenCalled() + expect(isDurableSecretProvenanceEnforced('knowledge')).toBe(false) + }) + + it('reports existing refusals without resolving or changing enforcement', () => { + configure('workspace-file') + reportDurableSecretProvenanceRefusal({ + surface: 'workspace-file', + cause: 'workspace-file-opaque-secret-content', + }) + + expect(mockPersistenceLogger.error).toHaveBeenCalledWith( + 'Refusing unavailable durable secret provenance', + { surface: 'workspace-file', cause: 'workspace-file-opaque-secret-content' } + ) + expect(mockLogger.error).not.toHaveBeenCalled() + expect(mockRecordAudit).not.toHaveBeenCalled() + expect(isDurableSecretProvenanceEnforced('workspace-file')).toBe(true) + expect(isDurableSecretProvenanceEnforced('memory')).toBe(false) + }) }) diff --git a/apps/sim/lib/execution/durable-secret-provenance-enforcement.ts b/apps/sim/lib/execution/durable-secret-provenance-enforcement.ts index 1d0891e2ffa..c8b9c382055 100644 --- a/apps/sim/lib/execution/durable-secret-provenance-enforcement.ts +++ b/apps/sim/lib/execution/durable-secret-provenance-enforcement.ts @@ -3,6 +3,7 @@ import { createLogger } from '@sim/logger' import { env } from '@/lib/core/config/env' const logger = createLogger('DurableSecretProvenanceEnforcement') +const persistenceLogger = createLogger('DurableSecretProvenancePersistence') /** * Durable stores that can hand a run a value whose secret provenance was never recorded. @@ -167,6 +168,69 @@ export function reportUnrecordedDurableProvenance(report: UnrecordedDurableProve }) } +export type DurableSecretProvenanceWriteCause = + | 'source-provenance-unknown' + | 'invalid-provenance-entries' + | 'source-hash-unavailable' + | 'workspace-file-write-unknown' + | 'workspace-file-write-unrecorded' + +export interface DurableSecretProvenanceWriteReport { + surface: DurableSecretProvenanceSurface + status: 'unknown' | 'unrecorded' + cause: DurableSecretProvenanceWriteCause + recordCount?: number + workspaceId?: string + resourceId?: string +} + +export type DurableSecretProvenanceRefusalCause = + | 'knowledge-document-source-unavailable' + | 'knowledge-result-provenance-unavailable' + | 'knowledge-chunk-source-unavailable' + | 'knowledge-workspace-file-source-unavailable' + | 'workspace-file-provenance-unavailable' + | 'workspace-file-opaque-secret-content' + | 'workspace-file-registry-unavailable' + | 'workspace-file-unrecorded-enforced' + +export interface DurableSecretProvenanceRefusalReport { + surface: DurableSecretProvenanceSurface + cause: DurableSecretProvenanceRefusalCause + workspaceId?: string + resourceId?: string +} + +/** + * Reports a non-exact sidecar write without claiming its enclosing transaction committed. + * Kept separate from permissive-read telemetry so writer defects and affected reads can be counted + * independently. Callers report only tracked writes; ordinary legacy records are not a fault. + */ +export function reportDurableSecretProvenanceWrite( + report: DurableSecretProvenanceWriteReport +): void { + persistenceLogger.error('Writing non-exact durable secret provenance', { + surface: report.surface, + status: report.status, + cause: report.cause, + ...(report.recordCount !== undefined ? { recordCount: report.recordCount } : {}), + ...(report.workspaceId ? { workspaceId: report.workspaceId } : {}), + ...(report.resourceId ? { resourceId: report.resourceId } : {}), + }) +} + +/** Reports an existing refusal decision without changing the surface's compatibility policy. */ +export function reportDurableSecretProvenanceRefusal( + report: DurableSecretProvenanceRefusalReport +): void { + persistenceLogger.error('Refusing unavailable durable secret provenance', { + surface: report.surface, + cause: report.cause, + ...(report.workspaceId ? { workspaceId: report.workspaceId } : {}), + ...(report.resourceId ? { resourceId: report.resourceId } : {}), + }) +} + /** Test seam: forces the next read to re-resolve the env-configured surfaces. */ export function resetDurableSecretProvenanceEnforcementCache(): void { enforcedSurfaces = undefined diff --git a/apps/sim/lib/execution/durable-secret-provenance.test.ts b/apps/sim/lib/execution/durable-secret-provenance.test.ts index 500dd5b386a..3180c24db5c 100644 --- a/apps/sim/lib/execution/durable-secret-provenance.test.ts +++ b/apps/sim/lib/execution/durable-secret-provenance.test.ts @@ -19,7 +19,13 @@ import { filterDurableSecretProvenanceBySourceValues, hashDurableSecretProvenanceValue, importDurableSecretProvenance, + mergeDurableSecretProvenance, + normalizeDurableSecretProvenanceEntries, } from '@/lib/execution/durable-secret-provenance' +import { + PROVENANCE_MAX_ENTRIES, + PROVENANCE_MAX_SERIALIZED_BYTES, +} from '@/lib/execution/provenance-limits' import { ResolvedSecretTraceRegistry } from '@/executor/utils/resolved-secret-trace-registry' function privateBundle(scope?: { userId: string; workspaceId?: string }) { @@ -41,6 +47,17 @@ function privateBundle(scope?: { userId: string; workspaceId?: string }) { } describe('durable secret provenance hashing', () => { + it('hashes many small values within the byte budget without a separate node cutoff', () => { + const messages = Array.from({ length: 17_000 }, (_, index) => ({ + role: 'user', + content: `message ${index}`, + })) + const hash = hashDurableSecretProvenanceValue(messages) + expect(hash).toMatch(/^[a-f0-9]{64}$/) + expect(hashDurableSecretProvenanceValue(structuredClone(messages))).toBe(hash) + expect(hashDurableSecretProvenanceValue('x'.repeat(16 * 1024 * 1024))).toBeUndefined() + }) + it('hashes equivalent plain JSON deterministically without key-order sensitivity', () => { expect(hashDurableSecretProvenanceValue({ b: [true, null], a: 'value' })).toBe( hashDurableSecretProvenanceValue({ a: 'value', b: [true, null] }) @@ -83,6 +100,101 @@ describe('durable secret provenance hashing', () => { }) }) +describe('durable provenance binding capacity', () => { + it('folds duplicates while preserving more than 10,000 bindings of one secret', () => { + const entries = Array.from({ length: PROVENANCE_MAX_ENTRIES + 1 }, (_, index) => ({ + encryptedValue: 'ciphertext', + sourceValueHash: `hash-${index}`, + })) + const normalized = normalizeDurableSecretProvenanceEntries(entries) + expect(normalized).toHaveLength(entries.length) + expect( + mergeDurableSecretProvenance({ status: 'exact', entries }, { status: 'exact', entries }) + ).toEqual({ status: 'exact', entries: normalized }) + expect(normalizeDurableSecretProvenanceEntries(Array(entries.length).fill(entries[0]))).toEqual( + [entries[0]] + ) + }) + + it('still refuses more than 10,000 distinct secrets', () => { + const entries = Array.from({ length: PROVENANCE_MAX_ENTRIES + 1 }, (_, index) => ({ + encryptedValue: `ciphertext-${index}`, + })) + expect(normalizeDurableSecretProvenanceEntries(entries.slice(0, -1))).toHaveLength( + PROVENANCE_MAX_ENTRIES + ) + expect(normalizeDurableSecretProvenanceEntries(entries)).toBeUndefined() + }) + + it('measures escaped UTF-8 JSON bytes including array separators', () => { + const entry = { encryptedValue: 'ciphertext', name: 'é\n' } + const overhead = Buffer.byteLength(JSON.stringify([entry]), 'utf8') + const exact = { + ...entry, + encryptedValue: entry.encryptedValue + 'x'.repeat(PROVENANCE_MAX_SERIALIZED_BYTES - overhead), + } + expect(normalizeDurableSecretProvenanceEntries([exact])).toEqual([exact]) + expect( + normalizeDurableSecretProvenanceEntries([ + { ...exact, encryptedValue: `${exact.encryptedValue}x` }, + ]) + ).toBeUndefined() + expect(normalizeDurableSecretProvenanceEntries([exact, entry])).toBeUndefined() + }) + + it('preserves distinct bindings whose fields contain delimiter characters', () => { + const entries = [ + { encryptedValue: 'ciphertext', name: 'name', sourceValueHash: 'hash\u0000part' }, + { encryptedValue: 'ciphertext', name: 'part\u0000name', sourceValueHash: 'hash' }, + ] + expect(normalizeDurableSecretProvenanceEntries(entries)).toHaveLength(2) + }) + + it('folds message hashes only after selection while retaining source scope and names on import', async () => { + const registry = new ResolvedSecretTraceRegistry() + const imported = vi.spyOn(registry, 'importProvenance').mockResolvedValue(true) + const entries = Array.from({ length: PROVENANCE_MAX_ENTRIES + 1 }, (_, index) => ({ + encryptedValue: 'ciphertext', + name: 'TOKEN', + sourceUserId: 'source-user', + sourceWorkspaceId: 'source-workspace', + sourceValueHash: `hash-${index}`, + })) + await expect( + importDurableSecretProvenance(registry, { + status: 'exact', + entries: [ + ...entries, + { ...entries[0], name: 'ALIAS' }, + { ...entries[0], sourceUserId: 'other-user' }, + ], + }) + ).resolves.toBe(true) + expect(imported).toHaveBeenCalledTimes(2) + expect(imported).toHaveBeenCalledWith( + { + version: 1, + complete: true, + scope: { userId: 'source-user', workspaceId: 'source-workspace' }, + entries: [ + { encryptedValue: 'ciphertext', name: 'ALIAS' }, + { encryptedValue: 'ciphertext', name: 'TOKEN' }, + ], + }, + { trusted: true, origin: 'durableProvenance.envelope' } + ) + expect(imported).toHaveBeenCalledWith( + { + version: 1, + complete: true, + scope: { userId: 'other-user', workspaceId: 'source-workspace' }, + entries: [{ encryptedValue: 'ciphertext', name: 'TOKEN' }], + }, + { trusted: true, origin: 'durableProvenance.envelope' } + ) + }) +}) + describe('private durable provenance scope admission', () => { it('accepts a different source user in the authorized destination workspace', () => { expect( diff --git a/apps/sim/lib/execution/durable-secret-provenance.ts b/apps/sim/lib/execution/durable-secret-provenance.ts index e4424a47ec7..6fbdc24feed 100644 --- a/apps/sim/lib/execution/durable-secret-provenance.ts +++ b/apps/sim/lib/execution/durable-secret-provenance.ts @@ -9,17 +9,14 @@ import { isPrivateSecretProvenanceBundleV1, type PrivateSecretProvenanceBundleV1, } from '@/lib/execution/model-input-provenance' -import { - PROVENANCE_MAX_ENTRIES, - PROVENANCE_MAX_SERIALIZED_BYTES, -} from '@/lib/execution/provenance-limits' +import { SecretProvenanceBudget } from '@/lib/execution/provenance-budget' +import { PROVENANCE_MAX_SERIALIZED_BYTES } from '@/lib/execution/provenance-limits' import { type ResolvedSecretTraceProvenanceV1, ResolvedSecretTraceRegistry, type ResolvedSecretTraceScopeV1, } from '@/executor/utils/resolved-secret-trace-registry' -const MAX_DURABLE_HASH_NODES = 50_000 const MAX_DURABLE_HASH_DEPTH = 100 const MAX_DURABLE_HASH_BYTES = 16 * 1024 * 1024 @@ -40,13 +37,15 @@ function compareStrings(left: string, right: string): number { export function normalizeDurableSecretProvenanceEntries( value: unknown ): DurableSecretProvenanceEntry[] | undefined { - if (!Array.isArray(value) || value.length > PROVENANCE_MAX_ENTRIES) { - return undefined - } + return Array.isArray(value) ? normalizeDurableSecretProvenanceBindings(value) : undefined +} +function normalizeDurableSecretProvenanceBindings( + candidates: Iterable +): DurableSecretProvenanceEntry[] | undefined { const entries = new Map() - let bytes = 0 - for (const candidate of value) { + const budget = new SecretProvenanceBudget() + for (const candidate of candidates) { if (!candidate || typeof candidate !== 'object' || Array.isArray(candidate)) return undefined const record = candidate as Record if ( @@ -73,14 +72,18 @@ export function normalizeDurableSecretProvenanceEntries( ? { sourceValueHash: record.sourceValueHash } : {}), } - const key = `${entry.sourceUserId ?? ''}\u0000${entry.sourceWorkspaceId ?? ''}\u0000${entry.sourceValueHash ?? ''}\u0000${entry.name ?? ''}\u0000${entry.encryptedValue}` + let minimumBytes = 0 + for (const field of Object.values(entry)) { + minimumBytes += Buffer.byteLength(field, 'utf8') + if (minimumBytes > PROVENANCE_MAX_SERIALIZED_BYTES) return undefined + } + const key = JSON.stringify(entry) if (entries.has(key)) continue - bytes += Buffer.byteLength(key, 'utf8') - if (bytes > PROVENANCE_MAX_SERIALIZED_BYTES) return undefined + if (!budget.add(entry.encryptedValue, Buffer.byteLength(key, 'utf8'))) return undefined entries.set(key, entry) } - const normalized = [...entries.values()].sort( + return [...entries.values()].sort( (left, right) => compareStrings(left.sourceUserId ?? '', right.sourceUserId ?? '') || compareStrings(left.sourceWorkspaceId ?? '', right.sourceWorkspaceId ?? '') || @@ -88,10 +91,6 @@ export function normalizeDurableSecretProvenanceEntries( compareStrings(left.name ?? '', right.name ?? '') || compareStrings(left.encryptedValue, right.encryptedValue) ) - if (Buffer.byteLength(JSON.stringify(normalized), 'utf8') > PROVENANCE_MAX_SERIALIZED_BYTES) { - return undefined - } - return normalized } /** Converts a complete transport envelope into its scope-preserving durable representation. */ @@ -165,8 +164,12 @@ export function mergeDurableSecretProvenance( ...values: readonly DurableSecretProvenance[] ): DurableSecretProvenance { if (values.some((value) => value.status === 'unknown')) return { status: 'unknown' } - const normalized = normalizeDurableSecretProvenanceEntries( - values.flatMap((value) => (value.status === 'exact' ? value.entries : [])) + const normalized = normalizeDurableSecretProvenanceBindings( + (function* () { + for (const value of values) { + if (value.status === 'exact') yield* value.entries + } + })() ) return normalized ? { status: 'exact', entries: normalized } : { status: 'unknown' } } @@ -244,32 +247,40 @@ export async function importDurableSecretProvenance( return false } - const grouped = new Map() + const grouped = new Map< + string, + { + scope?: ResolvedSecretTraceScopeV1 + entries: Map + } + >() for (const entry of entries) { - const key = `${entry.sourceUserId ?? ''}\u0000${entry.sourceWorkspaceId ?? ''}` - const group = grouped.get(key) ?? [] - group.push(entry) - grouped.set(key, group) + const scope = entry.sourceUserId + ? { + userId: entry.sourceUserId, + ...(entry.sourceWorkspaceId ? { workspaceId: entry.sourceWorkspaceId } : {}), + } + : undefined + const key = JSON.stringify(scope ?? null) + let group = grouped.get(key) + if (!group) { + group = { scope, entries: new Map() } + grouped.set(key, group) + } + const transportEntry = { + encryptedValue: entry.encryptedValue, + ...(entry.name ? { name: entry.name } : {}), + } + group.entries.set(JSON.stringify(transportEntry), transportEntry) } let complete = true for (const group of grouped.values()) { - const first = group[0] const envelope: ResolvedSecretTraceProvenanceV1 = { version: 1, complete: true, - entries: group.map((entry) => ({ - encryptedValue: entry.encryptedValue, - ...(entry.name ? { name: entry.name } : {}), - })), - ...(first.sourceUserId - ? { - scope: { - userId: first.sourceUserId, - ...(first.sourceWorkspaceId ? { workspaceId: first.sourceWorkspaceId } : {}), - }, - } - : {}), + entries: [...group.entries.values()], + ...(group.scope ? { scope: group.scope } : {}), } const imported = value === undefined @@ -309,7 +320,6 @@ export async function createDurableSecretProvenanceRegistry( export function hashDurableSecretProvenanceValue(value: unknown): string | undefined { const hash = createHash('sha256') const ancestors = new WeakSet() - let nodes = 0 let bytes = 0 const append = (chunk: string): boolean => { @@ -320,8 +330,7 @@ export function hashDurableSecretProvenanceValue(value: unknown): string | undef } const visit = (candidate: unknown, depth: number): boolean => { - nodes++ - if (nodes > MAX_DURABLE_HASH_NODES || depth > MAX_DURABLE_HASH_DEPTH) return false + if (depth > MAX_DURABLE_HASH_DEPTH) return false if (candidate === null) return append('null') if (typeof candidate === 'string') return append(JSON.stringify(candidate)) if (typeof candidate === 'boolean') return append(candidate ? 'true' : 'false') diff --git a/apps/sim/lib/execution/provenance-budget.test.ts b/apps/sim/lib/execution/provenance-budget.test.ts new file mode 100644 index 00000000000..bb831c4406f --- /dev/null +++ b/apps/sim/lib/execution/provenance-budget.test.ts @@ -0,0 +1,35 @@ +/** + * @vitest-environment node + */ +import { describe, expect, it } from 'vitest' +import { SecretProvenanceBudget } from '@/lib/execution/provenance-budget' +import { + PROVENANCE_MAX_ENTRIES, + PROVENANCE_MAX_SERIALIZED_BYTES, +} from '@/lib/execution/provenance-limits' + +describe('incremental provenance budget', () => { + it('charges repeated bindings against bytes but only distinct ciphertexts against secrets', () => { + const budget = new SecretProvenanceBudget() + for (let index = 0; index < PROVENANCE_MAX_ENTRIES + 1; index++) { + expect(budget.add('same-ciphertext', 100)).toBe(true) + } + }) + + it('refuses an additional distinct secret without refusing another binding of a known secret', () => { + const budget = new SecretProvenanceBudget() + for (let index = 0; index < PROVENANCE_MAX_ENTRIES; index++) { + expect(budget.add(`ciphertext-${index}`, 100)).toBe(true) + } + expect(budget.add('additional-secret', 100)).toBe(false) + expect(budget.add('ciphertext-0', 100)).toBe(true) + }) + + it('includes brackets and commas and does not consume capacity on a rejected binding', () => { + const budget = new SecretProvenanceBudget() + expect(budget.add('first', PROVENANCE_MAX_SERIALIZED_BYTES - 5)).toBe(true) + expect(budget.add('too-large', 3)).toBe(false) + expect(budget.add('fits', 2)).toBe(true) + expect(budget.add('fits', 2)).toBe(false) + }) +}) diff --git a/apps/sim/lib/execution/provenance-budget.ts b/apps/sim/lib/execution/provenance-budget.ts new file mode 100644 index 00000000000..13e785c0c70 --- /dev/null +++ b/apps/sim/lib/execution/provenance-budget.ts @@ -0,0 +1,27 @@ +import { + PROVENANCE_MAX_ENTRIES, + PROVENANCE_MAX_SERIALIZED_BYTES, +} from '@/lib/execution/provenance-limits' + +/** Tracks distinct secrets and serialized bytes while callers fold durable bindings incrementally. */ +export class SecretProvenanceBudget { + private readonly encryptedValues = new Set() + private serializedBytes = 2 + private bindingCount = 0 + + /** Admits one deduplicated binding, including its JSON array separator in the byte budget. */ + add(encryptedValue: string, serializedEntryBytes: number): boolean { + const nextBytes = this.serializedBytes + serializedEntryBytes + (this.bindingCount > 0 ? 1 : 0) + if ( + nextBytes > PROVENANCE_MAX_SERIALIZED_BYTES || + (!this.encryptedValues.has(encryptedValue) && + this.encryptedValues.size >= PROVENANCE_MAX_ENTRIES) + ) { + return false + } + this.encryptedValues.add(encryptedValue) + this.serializedBytes = nextBytes + this.bindingCount++ + return true + } +} diff --git a/apps/sim/lib/internal/file/operations.provenance.test.ts b/apps/sim/lib/internal/file/operations.provenance.test.ts new file mode 100644 index 00000000000..9e129d0f1bd --- /dev/null +++ b/apps/sim/lib/internal/file/operations.provenance.test.ts @@ -0,0 +1,397 @@ +/** + * @vitest-environment node + */ +import { type StoredWorkspaceFileSecretProvenanceEntry, workspaceFiles } from '@sim/db/schema' +import { + auditMock, + createMockRequest, + dbChainMock, + dbChainMockFns, + queueTableRows, + resetDbChainMock, +} from '@sim/testing' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { + mockEnforced, + mockAssertActiveWorkspaceAccess, + mockFetchWorkspaceFileBuffer, + mockLoadActiveWorkspaceContext, + mockLoadActiveWorkspaceFileContext, + mockResolveEffectiveWorkspacePermission, + mockGetWorkspaceFile, + mockResolveWorkspaceFileReference, + mockUpdateWorkspaceFileContent, +} = vi.hoisted(() => ({ + mockEnforced: vi.fn(() => false), + mockAssertActiveWorkspaceAccess: vi.fn(), + mockFetchWorkspaceFileBuffer: vi.fn(), + mockLoadActiveWorkspaceContext: vi.fn(), + mockLoadActiveWorkspaceFileContext: vi.fn(), + mockResolveEffectiveWorkspacePermission: vi.fn(), + mockGetWorkspaceFile: vi.fn(), + mockResolveWorkspaceFileReference: vi.fn(), + mockUpdateWorkspaceFileContent: vi.fn(), +})) + +vi.mock('@/lib/uploads/archive', () => ({ + ArchiveError: class ArchiveError extends Error {}, + decompressArchiveBufferToWorkspaceFiles: vi.fn(), + MAX_ARCHIVE_BYTES: 104_857_600, + statusForArchiveError: () => 400, +})) + +vi.mock('@/lib/file-parsers', () => ({ + isSupportedFileType: vi.fn(() => false), + parseBuffer: vi.fn(), +})) + +vi.mock('@sim/audit', () => auditMock) + +vi.mock('@/lib/realtime/notify', () => ({ + notifyWorkspaceFilesChanged: vi.fn(async () => undefined), +})) + +vi.mock('@/lib/public-shares/share-manager', () => ({ + getShareForResource: vi.fn().mockResolvedValue(null), + getSharesForResources: vi.fn().mockResolvedValue(new Map()), + getWorkspaceSharesForResources: vi.fn().mockResolvedValue(new Map()), + ShareValidationError: class ShareValidationError extends Error {}, +})) + +vi.mock('@sim/platform-authz/workspace', () => ({ + permissionSatisfies: (permission: string | null, required: string) => + permission === 'admin' || + permission === required || + (permission === 'write' && required === 'read'), + resolveEffectiveWorkspacePermission: (...args: unknown[]) => + mockResolveEffectiveWorkspacePermission(...args), +})) + +vi.mock('@/lib/uploads/contexts/workspace/workspace-file-manager', () => ({ + fetchWorkspaceFileBuffer: (...args: unknown[]) => mockFetchWorkspaceFileBuffer(...args), + getWorkspaceFileByName: vi.fn(), + getWorkspaceFile: (...args: unknown[]) => mockGetWorkspaceFile(...args), + loadActiveWorkspaceContext: (...args: unknown[]) => mockLoadActiveWorkspaceContext(...args), + loadActiveWorkspaceFileContext: (...args: unknown[]) => + mockLoadActiveWorkspaceFileContext(...args), + normalizeWorkspaceFileItemName: (name: string) => { + const trimmed = name.trim() + if (!trimmed || trimmed === '.' || trimmed === '..' || /[/\\]/.test(trimmed)) { + throw new Error('Invalid file name') + } + return trimmed + }, + resolveWorkspaceFileReference: (...args: unknown[]) => mockResolveWorkspaceFileReference(...args), + updateWorkspaceFileContent: (...args: unknown[]) => mockUpdateWorkspaceFileContent(...args), + uploadWorkspaceFile: vi.fn(), +})) + +vi.mock('@/lib/uploads/contexts/workspace', () => ({ + FileConflictError: class FileConflictError extends Error {}, + ContentVersionConflictError: class ContentVersionConflictError extends Error {}, + fetchWorkspaceFileBuffer: (...args: unknown[]) => mockFetchWorkspaceFileBuffer(...args), + getWorkspaceFileByName: vi.fn(), + getWorkspaceFile: (...args: unknown[]) => mockGetWorkspaceFile(...args), + loadActiveWorkspaceContext: (...args: unknown[]) => mockLoadActiveWorkspaceContext(...args), + updateWorkspaceFileContent: (...args: unknown[]) => mockUpdateWorkspaceFileContent(...args), + uploadWorkspaceFile: vi.fn(), +})) + +vi.mock('@/lib/workspace-files/application/workspace-file-folders', () => ({ + ensureWorkspaceFileFolderPathOperation: { + execute: vi.fn(), + }, + listWorkspaceFileFoldersOperation: { + execute: vi.fn(), + }, + createWorkspaceFileFolderOperation: { + execute: vi.fn(), + }, + updateWorkspaceFileFolderOperation: { + execute: vi.fn(), + }, + deleteWorkspaceFileFolderOperation: { + execute: vi.fn(), + }, + restoreWorkspaceFileFolderOperation: { + execute: vi.fn(), + }, +})) + +vi.mock('@/lib/workspace-files/application/edit-workspace-file-content', () => ({ + editWorkspaceFileContent: { + execute: vi.fn(), + }, +})) + +vi.mock('@/lib/workspace-files/application/list-workspace-files', () => ({ + listWorkspaceFilesInFolderScope: { + execute: vi.fn(), + }, + queryWorkspaceFilePage: { + execute: vi.fn(), + }, +})) + +vi.mock('@/lib/workspace-files/application/move-workspace-file-items', () => ({ + moveWorkspaceFileItemsOperation: { + execute: vi.fn(), + }, +})) + +vi.mock('@/lib/core/config/redis', () => ({ + acquireLock: vi.fn(async () => true), + releaseLock: vi.fn(async () => undefined), +})) + +vi.mock('@/lib/uploads/server/metadata', () => ({ + getFileMetadataByKey: vi.fn(), +})) + +vi.mock('@/lib/uploads/utils/file-utils.server', () => ({ + downloadFileFromStorage: vi.fn(), + downloadServableFileFromStorage: vi.fn(), +})) + +vi.mock('@/lib/workspaces/permissions/utils', () => ({ + assertActiveWorkspaceAccess: (...args: unknown[]) => mockAssertActiveWorkspaceAccess(...args), + getUserEntityPermissions: vi.fn(), + isWorkspaceAccessDeniedError: vi.fn(() => false), +})) + +vi.mock('@/app/api/files/authorization', () => ({ + verifyFileAccess: vi.fn(), +})) + +vi.mock('@/lib/execution/durable-secret-provenance-enforcement', () => ({ + isDurableSecretProvenanceEnforced: mockEnforced, + reportUnrecordedDurableProvenance: vi.fn(), + reportDurableSecretProvenanceWrite: vi.fn(), + reportDurableSecretProvenanceRefusal: vi.fn(), +})) +vi.mock('@/lib/core/security/encryption', () => ({ + decryptSecret: vi.fn(async () => ({ decrypted: 'synthetic-known-secret-123' })), + encryptSecret: vi.fn(async () => ({ encrypted: 'synthetic-ciphertext' })), +})) + +import { fileManageBodySchema } from '@/lib/api/contracts/tools/file' +import type { DbTransaction } from '@/lib/db/types' +import { executeFileManageOperation } from '@/lib/internal/file/operations' +import { + importWorkspaceFileSecretProvenanceForModelView, + isOpaqueWorkspaceFileEgressSafe, + replaceWorkspaceFileSecretProvenanceInTx, + type WorkspaceFileSecretProvenance, +} from '@/lib/uploads/contexts/workspace/workspace-file-secret-provenance' +import { createWorkspaceFileDelegatedPrincipal } from '@/lib/workspace-files/application/delegated-principal' +import { projectResolvedSecretModelContent } from '@/executor/utils/resolved-secret-content-projection' +import { ResolvedSecretTraceRegistry } from '@/executor/utils/resolved-secret-trace-registry' + +async function executeAppend(request: Request): Promise { + const parsed = fileManageBodySchema.parse(await request.json()) + const workspaceId = parsed.workspaceId || 'workspace-1' + return executeFileManageOperation(parsed, { + principal: createWorkspaceFileDelegatedPrincipal({ + serviceId: 'executor', + subjectUserId: 'user-1', + workspaceId, + delegationId: 'test-file-operation', + }), + workspaceId, + attributedUserId: 'user-1', + fileAccessUserId: 'user-1', + workflowId: 'workflow-1', + headers: request.headers, + requestId: 'request-1', + signal: request.signal, + }) +} + +const PRIVATE_SECRET_PROVENANCE_HEADER = { + 'x-sim-private-secret-provenance': 'private-secret-provenance-bundle-v1', +} +const CONTENT_UPDATED_AT = new Date('2026-08-04T00:00:00.000Z') +const NEXT_CONTENT_UPDATED_AT = new Date('2026-08-04T00:00:01.000Z') + +function workspaceFile(id: string, ownerUserId = 'user-1') { + return { + id, + workspaceId: 'workspace-1', + name: `${id}.txt`, + key: `workspace/workspace-1/${id}.txt`, + path: `/api/files/serve/${id}`, + size: id.length, + type: 'text/plain', + uploadedBy: ownerUserId, + uploadedAt: CONTENT_UPDATED_AT, + updatedAt: CONTENT_UPDATED_AT, + contentUpdatedAt: CONTENT_UPDATED_AT, + } +} + +const SECRET = 'synthetic-known-secret-123' +const SCOPE = { userId: 'user-1', workspaceId: 'workspace-1' } +const IDENTITY = { + fileId: 'file-1', + key: 'workspace/workspace-1/file-1.txt', + context: 'workspace' as const, +} +function joinedRow(status: string, entries: unknown[] = [], contentUpdatedAt = CONTENT_UPDATED_AT) { + return { + fileContentUpdatedAt: contentUpdatedAt, + secretProvenanceVersion: 1, + provenanceContentUpdatedAt: contentUpdatedAt, + status, + entries, + } +} + +/** + * Runs the append adapter, authorized use cases, sidecar writer, bound readers and model projector. + * Storage/context/auth lookups and SQL transport are mocked; captured sidecar insertion values + * supply the reader fixture so the production merge and classification remain under test. + */ +describe('appended file provenance', () => { + beforeEach(() => { + vi.clearAllMocks() + resetDbChainMock() + mockEnforced.mockReturnValue(false) + dbChainMockFns.returning.mockResolvedValue([{ id: 'file-1' }]) + mockResolveEffectiveWorkspacePermission.mockResolvedValue('write') + mockLoadActiveWorkspaceContext.mockResolvedValue({ + workspaceId: 'workspace-1', + workspaceOrganizationId: null, + allowPersonalApiKeys: true, + billedAccountUserId: 'user-1', + }) + mockLoadActiveWorkspaceFileContext.mockResolvedValue({ + fileId: 'file-1', + workspaceId: 'workspace-1', + workspaceOrganizationId: null, + allowPersonalApiKeys: true, + billedAccountUserId: 'user-1', + }) + mockAssertActiveWorkspaceAccess.mockResolvedValue(undefined) + mockGetWorkspaceFile.mockResolvedValue(workspaceFile('file-1')) + mockResolveWorkspaceFileReference.mockResolvedValue(workspaceFile('file-1')) + mockFetchWorkspaceFileBuffer.mockResolvedValue(Buffer.from('before:')) + mockUpdateWorkspaceFileContent.mockImplementation( + async ( + _ws: string, + fileId: string, + _user: string, + _buffer: Buffer, + _mime: string | undefined, + options: { secretProvenancePolicy: { provenance: WorkspaceFileSecretProvenance } } + ) => { + await replaceWorkspaceFileSecretProvenanceInTx( + dbChainMock.db as unknown as DbTransaction, + fileId, + NEXT_CONTENT_UPDATED_AT, + options.secretProvenancePolicy.provenance + ) + return { ...workspaceFile('file-1'), contentUpdatedAt: NEXT_CONTENT_UPDATED_AT } + } + ) + }) + + it.each([ + { predecessor: 'unrecorded', secret: true, expectedStatus: 'unknown' }, + { predecessor: 'exact', secret: true, expectedStatus: 'exact' }, + { predecessor: 'legacy', secret: true, expectedStatus: 'exact' }, + { predecessor: 'unrecorded', secret: false, expectedStatus: 'unrecorded' }, + { predecessor: 'legacy', secret: false, expectedStatus: 'exact' }, + { predecessor: 'unknown', secret: false, expectedStatus: 'unknown' }, + ] as const)( + 'appends secret=$secret to $predecessor without losing known lineage or changing absence policy', + async ({ predecessor, secret, expectedStatus }) => { + queueTableRows(workspaceFiles, [ + { + ...joinedRow(predecessor), + secretProvenanceVersion: predecessor === 'legacy' ? null : 1, + }, + ]) + const content = secret ? SECRET : 'ordinary text' + const response = await executeAppend( + createMockRequest( + 'POST', + { + operation: 'append', + workspaceId: 'workspace-1', + fileName: 'file-1.txt', + content, + __privateSecretProvenance: { + version: 1, + complete: true, + selections: [ + { + key: 'content', + provenance: { + version: 1, + complete: true, + entries: secret + ? [{ name: 'TOKEN', encryptedValue: 'synthetic-ciphertext' }] + : [], + scope: SCOPE, + }, + }, + ], + }, + }, + PRIVATE_SECRET_PROVENANCE_HEADER + ) + ) + expect(response.status).toBe(200) + const call = mockUpdateWorkspaceFileContent.mock.calls[0] + expect(call[3]).toEqual(Buffer.from(`before:${content}`)) + expect(call[5].expectedUpdatedAt).toEqual(CONTENT_UPDATED_AT) + const persisted = dbChainMockFns.values.mock.calls + .map( + ([value]) => + value as { + fileId: string + status: string + contentUpdatedAt: Date + entries: StoredWorkspaceFileSecretProvenanceEntry[] + } + ) + .find((value) => value.fileId === 'file-1') + expect(persisted).toBeDefined() + if (!persisted) throw new Error('Expected the sidecar writer to store provenance') + expect(persisted.status).toBe(expectedStatus) + expect(persisted.contentUpdatedAt).toEqual(NEXT_CONTENT_UPDATED_AT) + expect(persisted.entries).toHaveLength(expectedStatus === 'exact' && secret ? 1 : 0) + expect(dbChainMockFns.set).toHaveBeenCalledWith({ secretProvenanceVersion: 1 }) + for (const enforced of [false, true]) { + mockEnforced.mockReturnValue(enforced) + queueTableRows(workspaceFiles, [ + joinedRow(persisted.status, persisted.entries, persisted.contentUpdatedAt), + ]) + const registry = new ResolvedSecretTraceRegistry([], SCOPE) + const permitted = await importWorkspaceFileSecretProvenanceForModelView({ + workspaceId: 'workspace-1', + identity: IDENTITY, + registry, + view: 'complete', + value: `before:${content}`, + }) + expect(permitted).toBe( + expectedStatus === 'exact' || (expectedStatus === 'unrecorded' && !enforced) + ) + if (permitted) { + expect(projectResolvedSecretModelContent(`before:${content}`, registry)).toEqual({ + safe: true, + value: secret ? 'before:{{TOKEN}}' : `before:${content}`, + }) + } + queueTableRows(workspaceFiles, [ + joinedRow(persisted.status, persisted.entries, persisted.contentUpdatedAt), + ]) + expect(await isOpaqueWorkspaceFileEgressSafe('workspace-1', IDENTITY)).toBe( + (expectedStatus === 'exact' && !secret) || (expectedStatus === 'unrecorded' && !enforced) + ) + } + } + ) +}) diff --git a/apps/sim/lib/knowledge/api/route-policies.ts b/apps/sim/lib/knowledge/api/route-policies.ts index 291585938dd..07bce348043 100644 --- a/apps/sim/lib/knowledge/api/route-policies.ts +++ b/apps/sim/lib/knowledge/api/route-policies.ts @@ -118,6 +118,9 @@ const v2KnowledgeUsageErrorPolicy = { if (error instanceof KnowledgeUsageLimitExceededError) { return v2Error('USAGE_LIMIT_EXCEEDED', error.message) } + if (error instanceof KnowledgeSearchProvenanceUnavailableError) { + return v2Error('CONFLICT', error.message) + } return v2OrchestrationErrorPolicy.render(error) }, } satisfies V2ErrorPolicy diff --git a/apps/sim/lib/knowledge/application/add-workspace-files.ts b/apps/sim/lib/knowledge/application/add-workspace-files.ts index 584f9f52834..17c9a7f3dab 100644 --- a/apps/sim/lib/knowledge/application/add-workspace-files.ts +++ b/apps/sim/lib/knowledge/application/add-workspace-files.ts @@ -5,6 +5,7 @@ import { checkAttributedUsageLimits } from '@/lib/billing/core/billing-attributi import { authorizeWorkspaceOperation } from '@/lib/core/application' import { asOrchestrationError, OrchestrationError } from '@/lib/core/orchestration/types' import { generateRequestId } from '@/lib/core/utils/request' +import { reportDurableSecretProvenanceRefusal } from '@/lib/execution/durable-secret-provenance-enforcement' import { knowledgeDelegationPolicy } from '@/lib/knowledge/application/authorization' import { defineAuthorizedKnowledgeUseCase } from '@/lib/knowledge/application/authorized-knowledge-use-case' import { @@ -106,6 +107,12 @@ async function prepareWorkspaceFile( context: 'workspace', }) if (provenance.status !== 'exact' || provenance.entries.length > 0) { + reportDurableSecretProvenanceRefusal({ + surface: 'knowledge', + cause: 'knowledge-workspace-file-source-unavailable', + workspaceId: context.workspaceId, + resourceId: file.id, + }) throw new OrchestrationError( 'validation', 'Workspace file secret provenance prevents knowledge ingestion' diff --git a/apps/sim/lib/knowledge/application/chunks.ts b/apps/sim/lib/knowledge/application/chunks.ts index 9da76b789f8..31fdb1bb6f9 100644 --- a/apps/sim/lib/knowledge/application/chunks.ts +++ b/apps/sim/lib/knowledge/application/chunks.ts @@ -7,6 +7,7 @@ import { createDurableSecretProvenanceRegistry, type DurableSecretProvenance, } from '@/lib/execution/durable-secret-provenance' +import { reportDurableSecretProvenanceRefusal } from '@/lib/execution/durable-secret-provenance-enforcement' import { defineAuthorizedKnowledgeUseCase } from '@/lib/knowledge/application/authorized-knowledge-use-case' import { resolveKnowledgeAttributedUserId } from '@/lib/knowledge/application/billing' import { KnowledgeDocumentNotReadyError } from '@/lib/knowledge/application/chunk-errors' @@ -159,6 +160,12 @@ export const createKnowledgeChunk = defineAuthorizedKnowledgeUseCase({ const userId = resolveKnowledgeAttributedUserId(principal, context) const provenance = input.resolveContentProvenance({ userId, workspaceId: context.workspaceId }) if (provenance?.status === 'unknown') { + reportDurableSecretProvenanceRefusal({ + surface: 'knowledge', + cause: 'knowledge-chunk-source-unavailable', + workspaceId: context.workspaceId, + resourceId: context.documentId, + }) throw new OrchestrationError('validation', 'Knowledge chunk secret provenance is unavailable') } const registry = provenance @@ -224,6 +231,12 @@ export const updateKnowledgeChunk = defineAuthorizedKnowledgeUseCase({ const userId = resolveKnowledgeAttributedUserId(principal, context) const provenance = input.resolveContentProvenance({ userId, workspaceId: context.workspaceId }) if (provenance?.status === 'unknown') { + reportDurableSecretProvenanceRefusal({ + surface: 'knowledge', + cause: 'knowledge-chunk-source-unavailable', + workspaceId: context.workspaceId, + resourceId: context.documentId, + }) throw new OrchestrationError('validation', 'Knowledge chunk secret provenance is unavailable') } const registry = provenance diff --git a/apps/sim/lib/knowledge/application/search.ts b/apps/sim/lib/knowledge/application/search.ts index 000e0758587..2fec2d5fc79 100644 --- a/apps/sim/lib/knowledge/application/search.ts +++ b/apps/sim/lib/knowledge/application/search.ts @@ -18,6 +18,7 @@ import { generateRequestId } from '@/lib/core/utils/request' import { importDurableSecretProvenance } from '@/lib/execution/durable-secret-provenance' import { isDurableSecretProvenanceEnforced, + reportDurableSecretProvenanceRefusal, reportUnrecordedDurableProvenance, } from '@/lib/execution/durable-secret-provenance-enforcement' import { createKnowledgeAccessProvider } from '@/lib/knowledge/access/scope' @@ -352,13 +353,17 @@ export const searchKnowledge = defineAuthorizedKnowledgeUseCase({ structuredFilters: structuredFilters.length > 0 ? structuredFilters : undefined, }) + /** Public callers have no input envelope, but persisted reranker inputs still need provenance. */ + const registrySubjectUserId = resolvePrincipalSubjectUserId(principal) const registry = resultSecretRegistry ?? - (input.prepareModelInputProvenance - ? new ResolvedSecretTraceRegistry([], { - userId, - workspaceId: context.workspaceId, - }) + (input.prepareModelInputProvenance || useReranker + ? new ResolvedSecretTraceRegistry( + [], + registrySubjectUserId + ? { userId: registrySubjectUserId, workspaceId: context.workspaceId } + : undefined + ) : undefined) let provenanceSnapshot: Awaited< ReturnType @@ -370,7 +375,14 @@ export const searchKnowledge = defineAuthorizedKnowledgeUseCase({ }) if (!provenanceSnapshot.imported) { registry.markIncomplete('knowledge-result-provenance-unavailable') - if (useReranker) throw new KnowledgeSearchProvenanceUnavailableError() + if (useReranker) { + reportDurableSecretProvenanceRefusal({ + surface: 'knowledge', + cause: 'knowledge-result-provenance-unavailable', + workspaceId: context.workspaceId, + }) + throw new KnowledgeSearchProvenanceUnavailableError() + } } } @@ -445,6 +457,14 @@ export const searchKnowledge = defineAuthorizedKnowledgeUseCase({ rows = rows.slice(0, input.topK) rerankerStatus = 'unavailable' } + logger.info('Knowledge reranker completed', { + status: rerankerStatus, + candidateCount, + resultCount: rows.length, + unrecordedChunkCount: provenanceSnapshot?.unrecordedCount ?? 0, + enforced: isDurableSecretProvenanceEnforced('knowledge'), + workspaceId: context.workspaceId, + }) } else if (useReranker) { rows = rows.slice(0, input.topK) } diff --git a/apps/sim/lib/knowledge/secret-provenance.test.ts b/apps/sim/lib/knowledge/secret-provenance.test.ts index 08d4ba6bb36..4236fe27e66 100644 --- a/apps/sim/lib/knowledge/secret-provenance.test.ts +++ b/apps/sim/lib/knowledge/secret-provenance.test.ts @@ -2,23 +2,31 @@ * @vitest-environment node */ import { document, embedding } from '@sim/db/schema' -import { queueTableRows, resetDbChainMock } from '@sim/testing' +import { dbChainMock, queueTableRows, resetDbChainMock } from '@sim/testing' import { beforeEach, describe, expect, it, vi } from 'vitest' -import { hashDurableSecretProvenanceValue } from '@/lib/execution/durable-secret-provenance' +import type { DbTransaction } from '@/lib/db/types' +import { + type DurableSecretProvenance, + hashDurableSecretProvenanceValue, +} from '@/lib/execution/durable-secret-provenance' import { createKnowledgeDocumentSourceValue, importKnowledgePersistedResponseSecretProvenance, importKnowledgeSearchResultSecretProvenance, loadKnowledgeDocumentSecretRegistry, readBoundKnowledgeDocumentSecretProvenance, + replaceKnowledgeDocumentSecretProvenanceInTx, } from '@/lib/knowledge/secret-provenance' import { ResolvedSecretTraceRegistry } from '@/executor/utils/resolved-secret-trace-registry' -const { mockDecryptSecret, mockIsEnforced, mockReport } = vi.hoisted(() => ({ - mockDecryptSecret: vi.fn(), - mockIsEnforced: vi.fn(() => false), - mockReport: vi.fn(), -})) +const { mockDecryptSecret, mockIsEnforced, mockReport, mockReportWrite, mockReportRefusal } = + vi.hoisted(() => ({ + mockDecryptSecret: vi.fn(), + mockIsEnforced: vi.fn(() => false), + mockReport: vi.fn(), + mockReportWrite: vi.fn(), + mockReportRefusal: vi.fn(), + })) vi.mock('@/lib/core/security/encryption', () => ({ decryptSecret: mockDecryptSecret, @@ -27,6 +35,8 @@ vi.mock('@/lib/core/security/encryption', () => ({ vi.mock('@/lib/execution/durable-secret-provenance-enforcement', () => ({ isDurableSecretProvenanceEnforced: mockIsEnforced, reportUnrecordedDurableProvenance: mockReport, + reportDurableSecretProvenanceWrite: mockReportWrite, + reportDurableSecretProvenanceRefusal: mockReportRefusal, })) const DOCUMENT_SOURCE = createKnowledgeDocumentSourceValue({ @@ -126,6 +136,43 @@ describe('knowledge durable secret provenance', () => { { status: 'unknown' } ) ).rejects.toThrow('Knowledge document secret provenance is unavailable') + expect(mockReportRefusal).toHaveBeenCalledWith({ + surface: 'knowledge', + cause: 'knowledge-document-source-unavailable', + workspaceId: 'workspace-1', + resourceId: DOCUMENT_ROW.id, + }) + }) + + it.each([ + [{ status: 'unknown' }, 'source-provenance-unknown'], + [{ status: 'exact', entries: [{ encryptedValue: '' }] }, 'invalid-provenance-entries'], + ] satisfies [DurableSecretProvenance, string][])( + 'reports a non-exact document write without source values', + async (provenance, cause) => { + await replaceKnowledgeDocumentSecretProvenanceInTx( + dbChainMock.db as unknown as DbTransaction, + DOCUMENT_ROW.id, + DOCUMENT_SOURCE, + provenance + ) + expect(mockReportWrite).toHaveBeenCalledExactlyOnceWith({ + surface: 'knowledge', + status: 'unknown', + cause, + resourceId: DOCUMENT_ROW.id, + }) + } + ) + + it('does not report an exact-empty document write as a writer failure', async () => { + await replaceKnowledgeDocumentSecretProvenanceInTx( + dbChainMock.db as unknown as DbTransaction, + DOCUMENT_ROW.id, + DOCUMENT_SOURCE, + { status: 'exact', entries: [] } + ) + expect(mockReportWrite).not.toHaveBeenCalled() }) it('marks a fresh exact-empty source as tracked without creating a registry', async () => { diff --git a/apps/sim/lib/knowledge/secret-provenance.ts b/apps/sim/lib/knowledge/secret-provenance.ts index 6a708951da8..f2fff65491d 100644 --- a/apps/sim/lib/knowledge/secret-provenance.ts +++ b/apps/sim/lib/knowledge/secret-provenance.ts @@ -20,6 +20,8 @@ import { } from '@/lib/execution/durable-secret-provenance' import { isDurableSecretProvenanceEnforced, + reportDurableSecretProvenanceRefusal, + reportDurableSecretProvenanceWrite, reportUnrecordedDurableProvenance, } from '@/lib/execution/durable-secret-provenance-enforcement' import { @@ -314,6 +316,7 @@ async function replaceSidecarInTx(options: { identity: Record hashField: Record provenance: DurableSecretProvenance + cause?: 'source-hash-unavailable' }): Promise { const entries = options.provenance.status === 'exact' @@ -334,12 +337,24 @@ async function replaceSidecarInTx(options: { target: documentSecretProvenance.documentId, set: values, }) - return + } else { + await options.tx + .insert(embeddingSecretProvenance) + .values(values as typeof embeddingSecretProvenance.$inferInsert) + .onConflictDoUpdate({ target: embeddingSecretProvenance.embeddingId, set: values }) + } + if (values.status === 'unknown') { + reportDurableSecretProvenanceWrite({ + surface: 'knowledge', + status: 'unknown', + cause: + options.cause ?? + (options.provenance.status === 'unknown' + ? 'source-provenance-unknown' + : 'invalid-provenance-entries'), + resourceId: options.identity.documentId ?? options.identity.embeddingId, + }) } - await options.tx - .insert(embeddingSecretProvenance) - .values(values as typeof embeddingSecretProvenance.$inferInsert) - .onConflictDoUpdate({ target: embeddingSecretProvenance.embeddingId, set: values }) } /** Atomically tracks one document ingestion source. */ @@ -355,6 +370,7 @@ export async function replaceKnowledgeDocumentSecretProvenanceInTx( identity: { documentId }, hashField: { sourceHash: sourceHash ?? 'unavailable' }, provenance: sourceHash ? provenance : { status: 'unknown' }, + ...(sourceHash ? {} : { cause: 'source-hash-unavailable' }), }) await tx.update(document).set({ secretProvenanceVersion: 1 }).where(eq(document.id, documentId)) } @@ -405,6 +421,12 @@ export async function loadKnowledgeDocumentSecretRegistry( ) : persistedProvenance if (provenance.status === 'unknown') { + reportDurableSecretProvenanceRefusal({ + surface: 'knowledge', + cause: 'knowledge-document-source-unavailable', + workspaceId: scope.workspaceId, + resourceId: documentId, + }) throw new Error('Knowledge document secret provenance is unavailable') } if (provenance.entries.length === 0) diff --git a/apps/sim/lib/memory/application/use-cases.ts b/apps/sim/lib/memory/application/use-cases.ts index 441405fcee7..f7d57f561d9 100644 --- a/apps/sim/lib/memory/application/use-cases.ts +++ b/apps/sim/lib/memory/application/use-cases.ts @@ -22,7 +22,9 @@ import { } from '@/lib/execution/durable-secret-provenance-enforcement' import { memoryDelegationPolicy } from '@/lib/memory/application/authorization' import { memoryOperations } from '@/lib/memory/application/operations' +import { lockMemoryConversationInTx } from '@/lib/memory/locks' import { + bindMemorySecretProvenanceToMessages, readBoundMemorySecretProvenance, replaceMemorySecretProvenanceInTx, } from '@/lib/memory/secret-provenance' @@ -268,16 +270,20 @@ export const appendMemoryUseCase = defineAuthorizedWorkspaceUseCase({ input.resolveBillingAttribution ) : undefined - const writeProvenance = + const incomingProvenance = input.resolveWriteProvenance && provenanceScope ? input.resolveWriteProvenance(provenanceScope) : input.writeProvenance const initialData = Array.isArray(input.data) ? input.data : [input.data] + const writeProvenance = incomingProvenance + ? await bindMemorySecretProvenanceToMessages(initialData, incomingProvenance) + : undefined const now = new Date() const id = `mem_${generateId().replace(/-/g, '')}` try { await db.transaction(async (tx) => { + await lockMemoryConversationInTx(tx, context.workspaceId, input.key) const [existing] = await tx .select({ id: memory.id, @@ -329,13 +335,19 @@ export const appendMemoryUseCase = defineAuthorizedWorkspaceUseCase({ .returning({ id: memory.id, data: memory.data }) if (writeProvenance) { + const nextProvenance = previousProvenance + ? mergeDurableSecretProvenance(previousProvenance, writeProvenance) + : writeProvenance await replaceMemorySecretProvenanceInTx( tx, written.id, written.data, - previousProvenance - ? mergeDurableSecretProvenance(previousProvenance, writeProvenance) - : writeProvenance + nextProvenance, + previousProvenance?.status === 'unknown' + ? 'inherited-provenance-unknown' + : writeProvenance.status === 'exact' && nextProvenance.status === 'unknown' + ? 'merge-provenance-limit' + : undefined ) } }) diff --git a/apps/sim/lib/memory/locks.ts b/apps/sim/lib/memory/locks.ts new file mode 100644 index 00000000000..d8a61dec875 --- /dev/null +++ b/apps/sim/lib/memory/locks.ts @@ -0,0 +1,12 @@ +import { sql } from 'drizzle-orm' +import type { DbTransaction } from '@/lib/db/types' + +/** Serializes first writes too, before an absent conversation has a row that can be locked. */ +export async function lockMemoryConversationInTx( + tx: DbTransaction, + workspaceId: string, + key: string +): Promise { + const lockKey = JSON.stringify(['memory', workspaceId, key]) + await tx.execute(sql`SELECT pg_advisory_xact_lock(hashtextextended(${lockKey}, 0))`) +} diff --git a/apps/sim/lib/memory/message-provenance.postgres.test.ts b/apps/sim/lib/memory/message-provenance.postgres.test.ts new file mode 100644 index 00000000000..e675e8dd343 --- /dev/null +++ b/apps/sim/lib/memory/message-provenance.postgres.test.ts @@ -0,0 +1,272 @@ +/** + * @vitest-environment node + * + * Uses a disposable schema in a local PostgreSQL database. From apps/sim, run: + * `MEMORY_PROVENANCE_TEST_DATABASE_URL=postgresql://user@127.0.0.1:5432/postgres bun run test lib/memory/message-provenance.postgres.test.ts` + */ +import type { WorkflowExecutionDelegatedPrincipal } from '@sim/auth/principal' +import { generateId } from '@sim/utils/id' +import { drizzle, type PostgresJsDatabase } from 'drizzle-orm/postgres-js' +import postgres from 'postgres' +import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest' + +const { database, mockIsEnforced } = vi.hoisted(() => ({ + database: { current: undefined as PostgresJsDatabase | undefined }, + mockIsEnforced: vi.fn(), +})) + +vi.unmock('drizzle-orm') +vi.unmock('@sim/db/schema') +vi.mock('@sim/db', () => ({ + db: { + select: (...args: unknown[]) => { + if (!database.current) throw new Error('PostgreSQL test database is not initialized') + return Reflect.apply(database.current.select, database.current, args) + }, + transaction: (...args: unknown[]) => { + if (!database.current) throw new Error('PostgreSQL test database is not initialized') + return Reflect.apply(database.current.transaction, database.current, args) + }, + }, +})) +vi.mock('@/lib/core/security/encryption', () => ({ + decryptSecret: async (value: string) => ({ decrypted: value.replace('cipher-', 'secret-') }), +})) +vi.mock('@/lib/execution/durable-secret-provenance-enforcement', () => ({ + isDurableSecretProvenanceEnforced: mockIsEnforced, + reportUnrecordedDurableProvenance: vi.fn(), +})) +vi.mock('@/lib/logs/execution/pii-redaction', () => ({ + redactObjectStrings: async (value: unknown) => value, +})) +vi.mock('@/lib/tokenization/accurate', () => ({ + getAccurateTokenCount: (text: string) => text.length, +})) +vi.mock('@/lib/workspaces/application/workspace-context', () => ({ + resolveActiveWorkspaceApplicationContext: async () => ({ + workspaceId: 'workspace-1', + workspaceOrganizationId: null, + allowPersonalApiKeys: true, + billedAccountUserId: 'user-1', + }), +})) + +import { hashDurableSecretProvenanceValue } from '@/lib/execution/durable-secret-provenance' +import { appendMemoryUseCase } from '@/lib/memory/application/use-cases' +import { Memory } from '@/executor/handlers/agent/memory' +import type { AgentInputs } from '@/executor/handlers/agent/types' +import type { ExecutionContext } from '@/executor/types' +import { ResolvedSecretTraceRegistry } from '@/executor/utils/resolved-secret-trace-registry' + +const databaseUrl = process.env.MEMORY_PROVENANCE_TEST_DATABASE_URL +if (databaseUrl && !['localhost', '127.0.0.1', '[::1]'].includes(new URL(databaseUrl).hostname)) { + throw new Error('Memory provenance PostgreSQL tests require a local database') +} +const schemaName = `memory_provenance_${generateId().replaceAll('-', '')}` +const connection = databaseUrl + ? postgres(databaseUrl, { + max: 8, + connection: { search_path: `${schemaName},public` }, + onnotice: () => {}, + }) + : undefined +const SCOPE = { userId: 'user-1', workspaceId: 'workspace-1' } + +function principal(): WorkflowExecutionDelegatedPrincipal { + return { + kind: 'delegated', + serviceId: 'executor', + workspaceId: SCOPE.workspaceId, + delegationId: 'delegation-1', + audience: 'sim:memory', + issuedAt: new Date(Date.now() - 1_000), + expiresAt: new Date(Date.now() + 60_000), + delegationContext: { + kind: 'workflow_execution', + workflowId: 'workflow-1', + executionId: 'execution-1', + principal: { + kind: 'system', + serviceId: 'schedule', + workspaceId: SCOPE.workspaceId, + workflowId: 'workflow-1', + }, + currentWorkflow: { + workflowId: 'workflow-1', + mode: 'deployment', + deploymentVersionId: 'deployment-1', + }, + }, + } +} + +function context(registry = new ResolvedSecretTraceRegistry([], SCOPE)) { + return { + workspaceId: SCOPE.workspaceId, + resolvedSecretTraceRegistry: registry, + } as ExecutionContext +} + +function inputs(key: string): AgentInputs { + return { memoryType: 'conversation', conversationId: key } as AgentInputs +} + +async function toolAppend(key: string, suffix: string) { + return appendMemoryUseCase.execute({ + principal: principal(), + input: { + workspaceId: SCOPE.workspaceId, + key, + data: { role: 'user', content: `secret-${suffix}` }, + writeProvenance: { + status: 'exact', + entries: [ + { + encryptedValue: `cipher-${suffix}`, + name: `TOKEN_${suffix}`, + sourceUserId: SCOPE.userId, + sourceWorkspaceId: SCOPE.workspaceId, + }, + ], + }, + }, + }) +} + +async function nativeAppend(key: string, suffix: string) { + const registry = new ResolvedSecretTraceRegistry( + [ + { + name: `TOKEN_${suffix}`, + plaintext: `secret-${suffix}`, + encryptedValue: `cipher-${suffix}`, + }, + ], + SCOPE + ) + registry.recordResolved(`TOKEN_${suffix}`, `secret-${suffix}`) + return new Memory().appendToMemory(context(registry), inputs(key), { + role: 'user', + content: `secret-${suffix}`, + }) +} + +describe.skipIf(!databaseUrl)('memory provenance in PostgreSQL', () => { + beforeAll(async () => { + if (!connection) throw new Error('PostgreSQL test database is not initialized') + await connection`CREATE SCHEMA ${connection(schemaName)}` + database.current = drizzle(connection) + await connection.unsafe(` + CREATE TABLE memory ( + id text PRIMARY KEY, workspace_id text NOT NULL, key text NOT NULL, data jsonb NOT NULL, + secret_provenance_version integer, created_at timestamp NOT NULL DEFAULT now(), + updated_at timestamp NOT NULL DEFAULT now(), deleted_at timestamp, + UNIQUE(workspace_id, key) + ); + CREATE TABLE memory_secret_provenance ( + memory_id text PRIMARY KEY REFERENCES memory(id) ON DELETE CASCADE, + content_hash text NOT NULL, status text NOT NULL, entries jsonb NOT NULL, + updated_at timestamp NOT NULL DEFAULT now() + ); + CREATE FUNCTION demote_memory() RETURNS trigger LANGUAGE plpgsql AS $body$ + BEGIN + NEW.secret_provenance_version := NULL; + RETURN NEW; + END; + $body$; + CREATE TRIGGER memory_demote BEFORE UPDATE OF data ON memory FOR EACH ROW + WHEN(OLD.data IS DISTINCT FROM NEW.data) EXECUTE FUNCTION demote_memory(); + `) + }) + + afterAll(async () => { + if (!connection) return + try { + await connection`DROP SCHEMA ${connection(schemaName)} CASCADE` + } finally { + database.current = undefined + await connection.end() + } + }) + + describe.each([false, true])('enforcement %s', (enforced) => { + it('keeps a large one-secret conversation exact across tool and native writes and model reads', async () => { + if (!connection) throw new Error('PostgreSQL test database is not initialized') + mockIsEnforced.mockReturnValue(enforced) + const key = `large-conversation-${enforced}` + const messages = Array.from({ length: 17_000 }, (_, index) => ({ + role: 'user', + content: `secret-SHARED message-${index}`, + })) + await appendMemoryUseCase.execute({ + principal: principal(), + input: { + workspaceId: SCOPE.workspaceId, + key, + data: messages, + writeProvenance: { + status: 'exact', + entries: [ + { + encryptedValue: 'cipher-SHARED', + name: 'TOKEN_SHARED', + sourceUserId: SCOPE.userId, + sourceWorkspaceId: SCOPE.workspaceId, + }, + ], + }, + }, + }) + await nativeAppend(key, 'SHARED') + await toolAppend(key, 'SHARED') + const [record] = await connection` + SELECT m.data, m.secret_provenance_version, p.content_hash, p.status, p.entries + FROM memory m JOIN memory_secret_provenance p ON p.memory_id = m.id + WHERE m.key = ${key} + ` + expect(record.data).toHaveLength(messages.length + 2) + expect(record.secret_provenance_version).toBe(1) + expect(record.status).toBe('exact') + expect(record.content_hash).toBe(hashDurableSecretProvenanceValue(record.data)) + expect(record.entries).toHaveLength(messages.length + 1) + const execution = context() + const selected = await new Memory().fetchMemoryMessages(execution, { + ...inputs(key), + memoryType: 'sliding_window', + slidingWindowSize: '2', + }) + expect(selected).toEqual([ + { role: 'user', content: '{{TOKEN_SHARED}}' }, + { role: 'user', content: '{{TOKEN_SHARED}}' }, + ]) + expect(execution.resolvedSecretTraceRegistry?.isComplete()).toBe(true) + }) + + it.each(['tool-tool', 'tool-native', 'native-native'] as const)( + 'preserves both first appends and secret bindings for %s', + async (mode) => { + if (!connection) throw new Error('PostgreSQL test database is not initialized') + mockIsEnforced.mockReturnValue(enforced) + for (let index = 0; index < 8; index++) { + const key = `${enforced}-${mode}-${index}` + await Promise.all([ + mode === 'native-native' ? nativeAppend(key, 'A') : toolAppend(key, 'A'), + mode === 'tool-tool' ? toolAppend(key, 'B') : nativeAppend(key, 'B'), + ]) + const [record] = await connection` + SELECT m.data, m.secret_provenance_version, p.entries + FROM memory m JOIN memory_secret_provenance p ON p.memory_id = m.id + WHERE m.key = ${key} + ` + expect(record.data).toHaveLength(2) + expect(record.secret_provenance_version).toBe(1) + expect(record.entries).toHaveLength(2) + const result = await new Memory().fetchMemoryMessages(context(), inputs(key)) + expect(new Set(result.map((message) => message.content))).toEqual( + new Set(['{{TOKEN_A}}', '{{TOKEN_B}}']) + ) + } + } + ) + }) +}) diff --git a/apps/sim/lib/memory/message-provenance.test.ts b/apps/sim/lib/memory/message-provenance.test.ts new file mode 100644 index 00000000000..0104bb00e1a --- /dev/null +++ b/apps/sim/lib/memory/message-provenance.test.ts @@ -0,0 +1,478 @@ +/** + * @vitest-environment node + */ +import type { WorkflowExecutionDelegatedPrincipal } from '@sim/auth/principal' +import type { DurableSecretProvenanceEntry } from '@sim/db/schema' +import { memory } from '@sim/db/schema' +import { dbChainMockFns, queueTableRows, resetDbChainMock } from '@sim/testing' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ + decrypt: vi.fn(), + isEnforced: vi.fn(), + report: vi.fn(), + logger: { error: vi.fn(), warn: vi.fn(), info: vi.fn(), debug: vi.fn() }, + loadWorkspace: vi.fn(), +})) + +vi.mock('@sim/logger', () => ({ createLogger: () => mocks.logger })) +vi.mock('@/lib/core/security/encryption', () => ({ decryptSecret: mocks.decrypt })) +vi.mock('@/lib/execution/durable-secret-provenance-enforcement', () => ({ + isDurableSecretProvenanceEnforced: mocks.isEnforced, + reportUnrecordedDurableProvenance: mocks.report, +})) +vi.mock('@/lib/logs/execution/pii-redaction', () => ({ + redactObjectStrings: vi.fn(async (value: unknown) => value), +})) +vi.mock('@/lib/tokenization/accurate', () => ({ + getAccurateTokenCount: (text: string) => text.length, +})) +vi.mock('@/lib/workspaces/application/workspace-context', () => ({ + resolveActiveWorkspaceApplicationContext: mocks.loadWorkspace, +})) + +import { + type DurableSecretProvenance, + hashDurableSecretProvenanceValue, + importDurableSecretProvenance, +} from '@/lib/execution/durable-secret-provenance' +import { + PRIVATE_SECRET_PROVENANCE_BUNDLE_V1, + PRIVATE_SECRET_PROVENANCE_FIELD, + PRIVATE_SECRET_PROVENANCE_HEADER, +} from '@/lib/execution/private-tool-metadata' +import { readMemoryWriteProvenance } from '@/lib/internal/memory/provenance' +import { appendMemoryUseCase } from '@/lib/memory/application/use-cases' +import { + bindMemorySecretProvenanceToMessages, + createMemorySecretProvenanceSelector, +} from '@/lib/memory/secret-provenance' +import { Memory } from '@/executor/handlers/agent/memory' +import type { AgentInputs, Message } from '@/executor/handlers/agent/types' +import type { ExecutionContext } from '@/executor/types' +import { ResolvedSecretTraceRegistry } from '@/executor/utils/resolved-secret-trace-registry' +import { memoryAddTool } from '@/tools/memory/add' + +const SCOPE = { userId: 'user-1', workspaceId: 'workspace-1' } +const SECRET = 'known-secret-value' +const ENTRY = { + name: 'TOKEN', + encryptedValue: 'ciphertext', + sourceUserId: SCOPE.userId, + sourceWorkspaceId: SCOPE.workspaceId, +} +const INPUTS = { memoryType: 'conversation', conversationId: 'conversation-1' } as AgentInputs + +function executionContext(registry = new ResolvedSecretTraceRegistry([], SCOPE)) { + return { + workspaceId: SCOPE.workspaceId, + resolvedSecretTraceRegistry: registry, + } as ExecutionContext +} + +function queueStoredMemory(data: Message[], entries: readonly DurableSecretProvenanceEntry[]) { + queueTableRows(memory, [ + { + data, + secretProvenanceVersion: 1, + provenanceContentHash: hashDurableSecretProvenanceValue(data), + provenanceStatus: 'exact', + provenanceEntries: entries, + }, + ]) +} + +interface MemoryWrites { + appendMessage( + workspaceId: string, + key: string, + message: Message, + provenance: DurableSecretProvenance | undefined + ): Promise + seedMemoryRecord( + workspaceId: string, + key: string, + messages: Message[], + provenance: DurableSecretProvenance | undefined + ): Promise +} + +function principal(): WorkflowExecutionDelegatedPrincipal { + return { + kind: 'delegated', + serviceId: 'executor', + workspaceId: SCOPE.workspaceId, + delegationId: 'delegation-1', + audience: 'sim:memory', + issuedAt: new Date(Date.now() - 1_000), + expiresAt: new Date(Date.now() + 60_000), + delegationContext: { + kind: 'workflow_execution', + workflowId: 'workflow-1', + executionId: 'execution-1', + principal: { + kind: 'system', + serviceId: 'schedule', + workspaceId: SCOPE.workspaceId, + workflowId: 'workflow-1', + }, + currentWorkflow: { + workflowId: 'workflow-1', + mode: 'deployment', + deploymentVersionId: 'deployment-1', + }, + }, + } +} + +describe.each([false, true])('memory message provenance with enforcement %s', (enforced) => { + beforeEach(() => { + vi.clearAllMocks() + resetDbChainMock() + mocks.isEnforced.mockReturnValue(enforced) + mocks.decrypt.mockResolvedValue({ decrypted: SECRET }) + mocks.loadWorkspace.mockResolvedValue({ + workspaceId: SCOPE.workspaceId, + workspaceOrganizationId: null, + allowPersonalApiKeys: true, + billedAccountUserId: SCOPE.userId, + }) + }) + + it('carries actual memory_add writes through the application and sidecar into native Agent memory', async () => { + const wire = memoryAddTool.operation.input({ + conversationId: INPUTS.conversationId, + role: 'user', + content: SECRET, + }) as { key: string; data: Message } + const payload = { + ...wire, + [PRIVATE_SECRET_PROVENANCE_FIELD]: { + version: 1, + complete: true, + selections: [ + { + key: 'data', + provenance: { + version: 1, + complete: true, + entries: [{ name: ENTRY.name, encryptedValue: ENTRY.encryptedValue }], + scope: SCOPE, + }, + }, + ], + }, + } + const writeProvenance = readMemoryWriteProvenance( + new Headers({ [PRIVATE_SECRET_PROVENANCE_HEADER]: PRIVATE_SECRET_PROVENANCE_BUNDLE_V1 }), + payload, + SCOPE + ) + const data = [wire.data] + queueTableRows(memory, []) + queueTableRows(memory, [{ id: 'memory-1', key: wire.key, data, secretProvenanceVersion: 1 }]) + dbChainMockFns.returning + .mockResolvedValueOnce([{ id: 'memory-1', data }]) + .mockResolvedValueOnce([{ id: 'memory-1' }]) + + await appendMemoryUseCase.execute({ + principal: principal(), + input: { workspaceId: SCOPE.workspaceId, key: wire.key, data: wire.data, writeProvenance }, + }) + + const sidecar = dbChainMockFns.values.mock.calls + .map(([value]) => value) + .find((value) => value.memoryId === 'memory-1') + expect(sidecar).toMatchObject({ + status: 'exact', + entries: [{ ...ENTRY, sourceValueHash: hashDurableSecretProvenanceValue(wire.data) }], + }) + expect(dbChainMockFns.execute.mock.invocationCallOrder[0]).toBeLessThan( + dbChainMockFns.select.mock.invocationCallOrder[0] + ) + queueStoredMemory(data, sidecar.entries) + const result = await new Memory().fetchMemoryMessages(executionContext(), INPUTS) + expect(result[0].content).toBe('{{TOKEN}}') + expect(mocks.logger.error).not.toHaveBeenCalled() + }) + + it.each(['append', 'seed'] as const)('binds %s messages after removing files', async (mode) => { + const service = new Memory() + const writes = service as unknown as MemoryWrites + const append = vi.spyOn(writes, 'appendMessage').mockResolvedValue(undefined) + const seed = vi.spyOn(writes, 'seedMemoryRecord').mockResolvedValue(undefined) + const registry = new ResolvedSecretTraceRegistry( + [{ name: 'TOKEN', plaintext: SECRET, encryptedValue: 'ciphertext' }], + SCOPE + ) + registry.recordResolved('TOKEN', SECRET) + const message = { + role: 'user', + content: SECRET, + files: [{ id: 'file-1', name: 'document.txt' }], + } as Message + if (mode === 'append') await service.appendToMemory(executionContext(registry), INPUTS, message) + else await service.seedMemory(executionContext(registry), INPUTS, [message]) + + const stored = mode === 'append' ? [append.mock.calls[0][2]] : seed.mock.calls[0][2] + const provenance = mode === 'append' ? append.mock.calls[0][3] : seed.mock.calls[0][3] + expect(stored).toEqual([{ role: 'user', content: SECRET }]) + expect(provenance).toMatchObject({ + status: 'exact', + entries: [{ sourceValueHash: hashDurableSecretProvenanceValue(stored[0]) }], + }) + if (provenance?.status !== 'exact') throw new Error('Expected exact provenance') + queueStoredMemory(stored, provenance.entries) + expect((await service.fetchMemoryMessages(executionContext(), INPUTS))[0].content).toBe( + '{{TOKEN}}' + ) + expect(mocks.logger.error).not.toHaveBeenCalled() + }) + + it.each(['unbound', 'before-file-sanitization'] as const)( + 'redacts historical %s entries without refusing the run or exposing telemetry values', + async (binding) => { + const message: Message = { role: 'user', content: SECRET } + queueStoredMemory( + [message], + [ + { + ...ENTRY, + ...(binding === 'before-file-sanitization' + ? { sourceValueHash: hashDurableSecretProvenanceValue({ ...message, files: [] }) } + : {}), + }, + ] + ) + const context = executionContext() + expect((await new Memory().fetchMemoryMessages(context, INPUTS))[0].content).toBe('{{TOKEN}}') + expect(context.resolvedSecretTraceRegistry?.isComplete()).toBe(true) + expect(mocks.report).not.toHaveBeenCalled() + expect(mocks.logger.error).toHaveBeenCalledExactlyOnceWith( + 'Validated historical memory secret provenance', + { + surface: 'memory', + cause: binding === 'unbound' ? 'unbound-message-entry' : 'unmatched-message-hash', + entryCount: 1, + workspaceId: SCOPE.workspaceId, + } + ) + const telemetry = JSON.stringify(mocks.logger.error.mock.calls) + expect(telemetry).not.toContain(SECRET) + expect(telemetry).not.toContain(ENTRY.encryptedValue) + expect(telemetry).not.toContain(ENTRY.name) + } + ) + + it('does not reactivate a known omitted message while recovering an unrelated old entry', async () => { + const oldMessage: Message = { role: 'user', content: SECRET } + const retained: Message = { role: 'assistant', content: SECRET } + queueStoredMemory( + [oldMessage, retained], + [ + { ...ENTRY, sourceValueHash: hashDurableSecretProvenanceValue(oldMessage) }, + { ...ENTRY, name: 'OTHER', encryptedValue: 'other-ciphertext' }, + ] + ) + mocks.decrypt.mockImplementation(async (ciphertext: string) => ({ + decrypted: ciphertext === 'other-ciphertext' ? 'unrelated-value' : SECRET, + })) + const result = await new Memory().fetchMemoryMessages(executionContext(), { + ...INPUTS, + memoryType: 'sliding_window', + slidingWindowSize: '1', + }) + expect(result).toEqual([retained]) + expect(mocks.decrypt).not.toHaveBeenCalledWith(ENTRY.encryptedValue) + }) + + it('keeps legacy records readable without claiming unrelated current secrets', async () => { + queueTableRows(memory, [ + { data: [{ role: 'user', content: SECRET }], secretProvenanceVersion: null }, + ]) + const result = await new Memory().fetchMemoryMessages(executionContext(), INPUTS) + expect(result[0].content).toBe(SECRET) + expect(mocks.logger.error).not.toHaveBeenCalled() + expect(mocks.decrypt).not.toHaveBeenCalled() + }) + + it('retains foreign-source anonymity when recovering historical bindings', async () => { + queueStoredMemory( + [{ role: 'user', content: SECRET }], + [{ ...ENTRY, sourceUserId: 'other-user', sourceWorkspaceId: 'other-workspace' }] + ) + const result = await new Memory().fetchMemoryMessages(executionContext(), INPUTS) + expect(result[0].content).not.toContain(SECRET) + expect(result[0].content).not.toContain(ENTRY.name) + expect(result[0].content).not.toContain('other-user') + }) + + it('preserves both original source identities when the same ciphertext is supplied twice', async () => { + const message: Message = { role: 'user', content: SECRET } + const foreignEntry = { + ...ENTRY, + name: 'FOREIGN_TOKEN', + sourceUserId: 'other-user', + sourceWorkspaceId: 'other-workspace', + } + const provenance = await bindMemorySecretProvenanceToMessages([message], { + status: 'exact', + entries: [ENTRY, foreignEntry], + }) + expect(provenance).toEqual({ + status: 'exact', + entries: expect.arrayContaining([ + { ...ENTRY, sourceValueHash: hashDurableSecretProvenanceValue(message) }, + { ...foreignEntry, sourceValueHash: hashDurableSecretProvenanceValue(message) }, + ]), + }) + if (provenance.status !== 'exact') throw new Error('Expected exact provenance') + expect(provenance.entries).toHaveLength(2) + }) + + it('retains more than ten thousand message bindings for one secret while selecting only the current window', async () => { + const messages: Message[] = Array.from({ length: 10_001 }, (_, index) => ({ + role: 'user', + content: `${SECRET} message-${index}`, + })) + const provenance = await bindMemorySecretProvenanceToMessages(messages, { + status: 'exact', + entries: [ENTRY, ENTRY], + }) + if (provenance.status !== 'exact') throw new Error('Expected exact provenance') + expect(provenance.entries).toHaveLength(messages.length) + expect(new Set(provenance.entries.map((entry) => entry.encryptedValue))).toEqual( + new Set([ENTRY.encryptedValue]) + ) + const selector = await createMemorySecretProvenanceSelector(provenance, messages) + expect(selector.recoveredEntryCount).toBe(0) + const selected = messages.slice(-2) + expect(selector.select(selected, false)).toEqual({ + status: 'exact', + entries: expect.arrayContaining( + selected.map((message) => ({ + ...ENTRY, + sourceValueHash: hashDurableSecretProvenanceValue(message), + })) + ), + }) + const selection = selector.select(selected, false) + if (selection.status !== 'exact') throw new Error('Expected exact selection') + expect(selection.entries).toHaveLength(2) + const readerRegistry = new ResolvedSecretTraceRegistry([], SCOPE) + expect( + await importDurableSecretProvenance( + readerRegistry, + selector.select(messages, false), + messages, + 'memory' + ) + ).toBe(true) + expect(readerRegistry.exportProvenance().entries).toHaveLength(1) + expect(readerRegistry.isComplete()).toBe(true) + expect(mocks.logger.error).not.toHaveBeenCalled() + }) + + it('still rejects more than ten thousand distinct secrets', async () => { + const provenance = await bindMemorySecretProvenanceToMessages( + [{ role: 'user', content: SECRET }], + { + status: 'exact', + entries: Array.from({ length: 10_001 }, (_, index) => ({ + ...ENTRY, + encryptedValue: `ciphertext-${index}`, + })), + } + ) + expect(provenance).toEqual({ status: 'unknown' }) + expect(mocks.decrypt).not.toHaveBeenCalled() + }) + + it('still rejects message bindings whose serialized sidecar exceeds eight MiB', async () => { + const messages: Message[] = Array.from({ length: 8_000 }, (_, index) => ({ + role: 'user', + content: `${SECRET} message-${index}`, + })) + const provenance = await bindMemorySecretProvenanceToMessages(messages, { + status: 'exact', + entries: [{ ...ENTRY, encryptedValue: 'ciphertext'.repeat(120) }], + }) + expect(provenance).toEqual({ status: 'unknown' }) + expect(mocks.logger.error).toHaveBeenCalledWith( + 'Memory message secret provenance could not be bound', + { surface: 'memory', cause: 'entries-unnormalizable' } + ) + }) + + it('recovers valid historical entries without newly refusing an unreadable old ciphertext', async () => { + queueStoredMemory( + [{ role: 'user', content: SECRET }], + [ENTRY, { ...ENTRY, encryptedValue: 'corrupt-ciphertext' }] + ) + mocks.decrypt.mockImplementation(async (ciphertext: string) => { + if (ciphertext === 'corrupt-ciphertext') throw new Error(`Sensitive failure: ${SECRET}`) + return { decrypted: SECRET } + }) + const execution = executionContext() + const result = await new Memory().fetchMemoryMessages(execution, INPUTS) + expect(result[0].content).toBe('{{TOKEN}}') + expect(execution.resolvedSecretTraceRegistry?.isComplete()).toBe(true) + expect(mocks.decrypt).toHaveBeenCalledWith('corrupt-ciphertext', { logFailure: false }) + expect(mocks.report).not.toHaveBeenCalled() + expect(mocks.logger.error).toHaveBeenCalledWith( + 'Historical memory secret provenance could not be recovered', + { + surface: 'memory', + cause: 'legacy-entry-recovery-failed', + entryCount: 1, + workspaceId: SCOPE.workspaceId, + } + ) + const telemetry = JSON.stringify(mocks.logger.error.mock.calls) + expect(telemetry).not.toContain(SECRET) + expect(telemetry).not.toContain('corrupt-ciphertext') + }) + + it('keeps historical memory readable when optional recoveries exceed the combined matcher budget', async () => { + const values = ['a', 'b', 'c', 'd', 'e'].map((character) => character.repeat(60_000)) + const content = values.join(' ') + queueStoredMemory( + [{ role: 'user', content }], + values.map((_, index) => ({ ...ENTRY, encryptedValue: `large-cipher-${index}` })) + ) + mocks.decrypt.mockImplementation(async (ciphertext: string) => ({ + decrypted: values[Number(ciphertext.replace('large-cipher-', ''))], + })) + const execution = executionContext() + const result = await new Memory().fetchMemoryMessages(execution, INPUTS) + expect(result[0].content).toBe(content) + expect(execution.resolvedSecretTraceRegistry?.isComplete()).toBe(true) + expect(mocks.logger.error).toHaveBeenCalledWith( + 'Historical memory secret provenance recovery was skipped', + { + surface: 'memory', + cause: 'legacy-recovery-capacity-exceeded', + entryCount: 5, + workspaceId: SCOPE.workspaceId, + } + ) + }) + + it('does not newly require a run registry for historical unbound memory', async () => { + queueStoredMemory([{ role: 'user', content: SECRET }], [ENTRY]) + const result = await new Memory().fetchMemoryMessages( + { workspaceId: SCOPE.workspaceId } as ExecutionContext, + INPUTS + ) + expect(result[0].content).toBe(SECRET) + expect(mocks.logger.error).toHaveBeenCalledWith( + 'Historical memory secret provenance recovery was skipped', + { + surface: 'memory', + cause: 'legacy-recovery-context-unavailable', + entryCount: 1, + workspaceId: SCOPE.workspaceId, + } + ) + }) +}) diff --git a/apps/sim/lib/memory/secret-provenance.test.ts b/apps/sim/lib/memory/secret-provenance.test.ts index 979e528bdc1..90fea3b9a84 100644 --- a/apps/sim/lib/memory/secret-provenance.test.ts +++ b/apps/sim/lib/memory/secret-provenance.test.ts @@ -88,7 +88,7 @@ describe('memory secret provenance', () => { expect(inserted[0]).toMatchObject({ status: 'unknown', contentHash: 'unavailable' }) expect(mockLogger.error).toHaveBeenCalledWith( - 'Memory write persisted unrecorded secret provenance', + 'Memory write staged unrecorded secret provenance', { surface: 'memory', cause: 'hash-unavailable', memoryId: 'memory-1' } ) }) @@ -103,13 +103,12 @@ describe('memory secret provenance', () => { expect(inserted[0]).toMatchObject({ status: 'unknown' }) expect(mockLogger.error).toHaveBeenCalledWith( - 'Memory write persisted unrecorded secret provenance', + 'Memory write staged unrecorded secret provenance', { surface: 'memory', cause: 'entries-unnormalizable', memoryId: 'memory-1' } ) }) - /** An incoming unknown was degraded by its producer, which already reported it. */ - it('stays silent when the incoming provenance is already unknown', async () => { + it('counts incoming unknowns even when their originating fault happened in an older run', async () => { const { tx, inserted } = createTxStub() await replaceMemorySecretProvenanceInTx(tx, 'memory-1', [{ role: 'user', content: 'hello' }], { @@ -117,6 +116,9 @@ describe('memory secret provenance', () => { }) expect(inserted[0]).toMatchObject({ status: 'unknown' }) - expect(mockLogger.error).not.toHaveBeenCalled() + expect(mockLogger.error).toHaveBeenCalledWith( + 'Memory write staged unrecorded secret provenance', + { surface: 'memory', cause: 'incoming-provenance-incomplete', memoryId: 'memory-1' } + ) }) }) diff --git a/apps/sim/lib/memory/secret-provenance.ts b/apps/sim/lib/memory/secret-provenance.ts index 29c650c9c98..cc6fd560cf8 100644 --- a/apps/sim/lib/memory/secret-provenance.ts +++ b/apps/sim/lib/memory/secret-provenance.ts @@ -1,16 +1,167 @@ -import { memory, memorySecretProvenance } from '@sim/db/schema' +import { type DurableSecretProvenanceEntry, memory, memorySecretProvenance } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { and, eq, isNull, or } from 'drizzle-orm' +import { decryptSecret } from '@/lib/core/security/encryption' import type { DbTransaction } from '@/lib/db/types' import { type DurableSecretProvenance, EXACT_EMPTY_DURABLE_SECRET_PROVENANCE, hashDurableSecretProvenanceValue, + importDurableSecretProvenance, normalizeDurableSecretProvenanceEntries, } from '@/lib/execution/durable-secret-provenance' +import { SecretProvenanceBudget } from '@/lib/execution/provenance-budget' +import { ResolvedSecretTraceRegistry } from '@/executor/utils/resolved-secret-trace-registry' const logger = createLogger('MemorySecretProvenance') +type MemoryProvenanceRecoveryCause = 'unbound-message-entry' | 'unmatched-message-hash' +type MemoryUnknownWriteCause = + | 'incoming-provenance-incomplete' + | 'inherited-provenance-unknown' + | 'merge-provenance-limit' + +interface MemorySecretProvenanceSelection { + select(messages: readonly unknown[], includeRecovered: boolean): DurableSecretProvenance + recoveredEntryCount: number +} + +/** + * Known message bindings still respect the selected history window. Older writers omitted the + * binding or hashed a message before removing its files; retain those entries for value-filtered + * redaction instead of silently declaring their content public. + */ +export async function createMemorySecretProvenanceSelector( + provenance: DurableSecretProvenance, + storedMessages: readonly unknown[], + workspaceId?: string +): Promise { + if (provenance.status === 'unknown') return { select: () => provenance, recoveredEntryCount: 0 } + + const storedHashes = new Set(storedMessages.map(hashDurableSecretProvenanceValue)) + const recoveryCounts = new Map() + const recoveredEntries: DurableSecretProvenanceEntry[] = [] + const optionalEntries = new Set() + const recoveryByCiphertext = new Map() + let failedEntryCount = 0 + for (const entry of provenance.entries) { + const cause = !entry.sourceValueHash + ? 'unbound-message-entry' + : !storedHashes.has(entry.sourceValueHash) + ? 'unmatched-message-hash' + : undefined + if (cause) { + let recoverable = recoveryByCiphertext.get(entry.encryptedValue) + if (recoverable === undefined) { + try { + const { decrypted } = await decryptSecret(entry.encryptedValue, { logFailure: false }) + const staged = new ResolvedSecretTraceRegistry( + [ + { + name: 'MEMORY_RECOVERY', + plaintext: decrypted, + encryptedValue: entry.encryptedValue, + }, + ], + undefined, + { staged: true } + ) + recoverable = staged.recordResolved('MEMORY_RECOVERY', decrypted) && staged.isComplete() + } catch { + recoverable = false + } + recoveryByCiphertext.set(entry.encryptedValue, recoverable) + } + if (!recoverable) { + failedEntryCount += 1 + continue + } + recoveryCounts.set(cause, (recoveryCounts.get(cause) ?? 0) + 1) + optionalEntries.add(entry) + } + recoveredEntries.push(entry) + } + if (failedEntryCount > 0) { + logger.error('Historical memory secret provenance could not be recovered', { + surface: 'memory', + cause: 'legacy-entry-recovery-failed', + entryCount: failedEntryCount, + ...(workspaceId ? { workspaceId } : {}), + }) + } + for (const [cause, entryCount] of recoveryCounts) { + logger.error('Validated historical memory secret provenance', { + surface: 'memory', + cause, + entryCount, + ...(workspaceId ? { workspaceId } : {}), + }) + } + + return { + recoveredEntryCount: optionalEntries.size, + select(messages, includeRecovered) { + const selectedHashes = new Set(messages.map(hashDurableSecretProvenanceValue)) + return { + status: 'exact', + entries: recoveredEntries.filter((entry) => + optionalEntries.has(entry) ? includeRecovered : selectedHashes.has(entry.sourceValueHash) + ), + } + }, + } +} + +/** Binds a tool's whole-input provenance to the individual messages it actually persists. */ +export async function bindMemorySecretProvenanceToMessages( + messages: readonly unknown[], + provenance: DurableSecretProvenance +): Promise { + if (provenance.status === 'unknown' || provenance.entries.length === 0) return provenance + + const registry = new ResolvedSecretTraceRegistry() + if (!(await importDurableSecretProvenance(registry, provenance, messages, 'memory'))) { + return { status: 'unknown' } + } + const entries = new Map() + const budget = new SecretProvenanceBudget() + for (const message of messages) { + const sourceValueHash = hashDurableSecretProvenanceValue(message) + if (!sourceValueHash) { + logger.error('Memory message secret provenance could not be bound', { + surface: 'memory', + cause: 'hash-unavailable', + }) + return { status: 'unknown' } + } + const selected = registry.exportCommittedProvenanceForValue(message) + if (!selected.complete) return { status: 'unknown' } + const ciphertexts = new Set(selected.entries.map((entry) => entry.encryptedValue)) + for (const entry of provenance.entries) { + if (!ciphertexts.has(entry.encryptedValue)) continue + const bound = { ...entry, sourceValueHash } + const key = JSON.stringify(bound) + if (entries.has(key)) continue + if (!budget.add(entry.encryptedValue, Buffer.byteLength(key, 'utf8'))) { + logger.error('Memory message secret provenance could not be bound', { + surface: 'memory', + cause: 'entries-unnormalizable', + }) + return { status: 'unknown' } + } + entries.set(key, bound) + } + } + const normalized = normalizeDurableSecretProvenanceEntries([...entries.values()]) + if (!normalized) { + logger.error('Memory message secret provenance could not be bound', { + surface: 'memory', + cause: 'entries-unnormalizable', + }) + } + return normalized ? { status: 'exact', entries: normalized } : { status: 'unknown' } +} + interface MemorySecretProvenanceRow { secretProvenanceVersion: number | null data: unknown @@ -44,7 +195,8 @@ export async function replaceMemorySecretProvenanceInTx( tx: DbTransaction, memoryId: string, data: unknown, - provenance: DurableSecretProvenance + provenance: DurableSecretProvenance, + unknownCause: MemoryUnknownWriteCause = 'incoming-provenance-incomplete' ): Promise { const contentHash = hashDurableSecretProvenanceValue(data) const entries = @@ -52,20 +204,6 @@ export async function replaceMemorySecretProvenanceInTx( ? normalizeDurableSecretProvenanceEntries(provenance.entries) : [] const status = contentHash && provenance.status === 'exact' && entries ? 'exact' : 'unknown' - /** - * The one degrade that happens here rather than upstream: exact provenance arrived, and this - * binding could not hold it — the record outgrew the content hash's bounds, or the entries the - * envelope bounds. Every later read of the row proceeds unvouched, so the cause is logged where - * it was decided, the shape the table writer uses. An incoming `unknown` stays silent; its - * producer already reported. - */ - if (provenance.status === 'exact' && status === 'unknown') { - logger.error('Memory write persisted unrecorded secret provenance', { - surface: 'memory', - cause: contentHash ? 'entries-unnormalizable' : 'hash-unavailable', - memoryId, - }) - } await tx .insert(memorySecretProvenance) .values({ @@ -96,4 +234,17 @@ export async function replaceMemorySecretProvenanceInTx( ) .returning({ id: memory.id }) if (!tracked) throw new Error('Memory secret provenance could not bind the persisted version') + /** This transaction can still roll back; report staged writes rather than claiming commitment. */ + if (status === 'unknown') { + logger.error('Memory write staged unrecorded secret provenance', { + surface: 'memory', + cause: + provenance.status === 'unknown' + ? unknownCause + : contentHash + ? 'entries-unnormalizable' + : 'hash-unavailable', + memoryId, + }) + } } diff --git a/apps/sim/lib/table/rows/secret-provenance.postgres.test.ts b/apps/sim/lib/table/rows/secret-provenance.postgres.test.ts new file mode 100644 index 00000000000..c21e5ef0272 --- /dev/null +++ b/apps/sim/lib/table/rows/secret-provenance.postgres.test.ts @@ -0,0 +1,511 @@ +/** + * @vitest-environment node + * + * Runs the real Drizzle queries against temporary PostgreSQL tables. Set + * TABLE_PROVENANCE_TEST_DATABASE_URL to a local test database to include this suite. + * From apps/sim, run: + * `TABLE_PROVENANCE_TEST_DATABASE_URL=postgresql://user@127.0.0.1:5432/postgres bun run test lib/table/rows/secret-provenance.postgres.test.ts` + * CI needs a local PostgreSQL service and this variable; the default unit suite separately + * checks flag policy, stale-snapshot reporting, and write-event attribution without a database. + */ +import { userTableRows } from '@sim/db/schema' +import { eq, sql } from 'drizzle-orm' +import { drizzle, type PostgresJsDatabase } from 'drizzle-orm/postgres-js' +import postgres from 'postgres' +import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest' +import { PROVENANCE_MAX_SERIALIZED_BYTES } from '@/lib/execution/provenance-limits' +import type { DbTransaction } from '@/lib/table/planner' +import { + getTableSnapshotModelMountSafety, + loadTableRowSecretProvenance, + mutateTableRowsWithSecretProvenance, + updateTableRowsWithDerivedSecretProvenance, +} from '@/lib/table/rows/secret-provenance' +import type { RowData, TableRowSecretProvenanceWrite } from '@/lib/table/types' + +const { database, mockIsEnforced, mockReport, mockError } = vi.hoisted(() => ({ + database: { current: undefined as PostgresJsDatabase | undefined }, + mockIsEnforced: vi.fn(() => false), + mockReport: vi.fn(), + mockError: vi.fn(), +})) + +vi.unmock('@sim/db/schema') +vi.unmock('drizzle-orm') +vi.mock('@sim/db', () => ({ + db: { + select: (...args: unknown[]) => { + if (!database.current) throw new Error('PostgreSQL test database is not initialized') + return Reflect.apply(database.current.select, database.current, args) + }, + }, +})) +vi.mock('@sim/logger', () => ({ + createLogger: () => ({ error: mockError, warn: vi.fn() }), +})) +vi.mock('@/lib/execution/durable-secret-provenance-enforcement', () => ({ + isDurableSecretProvenanceEnforced: mockIsEnforced, + reportUnrecordedDurableProvenance: mockReport, +})) + +const databaseUrl = process.env.TABLE_PROVENANCE_TEST_DATABASE_URL +if (databaseUrl && !['localhost', '127.0.0.1', '[::1]'].includes(new URL(databaseUrl).hostname)) { + throw new Error('Table provenance PostgreSQL tests require a local database') +} +const connection = databaseUrl ? postgres(databaseUrl, { max: 1 }) : undefined +const updatedAt = new Date('2026-08-05T00:00:00.123Z') +const secretEntry = { columnId: 'retained', encryptedValue: 'encrypted-secret', name: 'SECRET' } + +interface Fixture { + id?: string + version?: number | null + status?: string + entries?: unknown + stale?: boolean + data?: RowData +} + +async function insertRow({ + id = 'row-1', + version = 1, + status, + entries = [], + stale, + data = { retained: 'value', removed: 'other' }, +}: Fixture) { + if (!connection) throw new Error('PostgreSQL test database is not initialized') + await connection` + INSERT INTO user_table_rows (id, table_id, workspace_id, data, updated_at, secret_provenance_version) + VALUES (${id}, 'table-1', 'workspace-1', ${JSON.stringify(data)}::jsonb, ${updatedAt.toISOString()}, ${version}) + ` + if (status !== undefined) { + await connection` + INSERT INTO user_table_row_secret_provenance (row_id, content_updated_at, status, entries) + VALUES (${id}, ${(stale ? new Date(0) : updatedAt).toISOString()}, ${status}, ${JSON.stringify(entries)}::jsonb) + ` + } +} + +function wideRowFixture() { + const scope = { userId: 'user-1', workspaceId: 'workspace-1' } + const entries = Array.from({ length: 11 }, (_, index) => ({ + encryptedValue: `encrypted-${String(index).padStart(2, '0')}`, + name: `SECRET_${index}`, + })) + const value = entries.map((entry) => entry.name).join(' ') + const data: RowData = {} + const provenance: TableRowSecretProvenanceWrite = { complete: true, columns: {} } + for (let column = 0; column < 1_000; column++) { + const columnId = `column-${String(column).padStart(3, '0')}` + data[columnId] = value + provenance.columns[columnId] = { + version: 1, + complete: true, + entries, + scope: column === 999 ? { ...scope, userId: 'foreign-user' } : scope, + } + } + return { scope, entries, data, provenance } +} + +async function writeWideRow() { + if (!database.current) throw new Error('PostgreSQL test database is not initialized') + const fixture = wideRowFixture() + await database.current.transaction(async (tx) => { + await mutateTableRowsWithSecretProvenance(tx as DbTransaction, { + rows: [{ rowId: 'row-1', provenance: fixture.provenance }], + rowState: 'new', + mode: 'replace', + mutate: async () => { + await tx.execute(sql` + INSERT INTO user_table_rows (id, table_id, workspace_id, data, updated_at) + VALUES ('row-1', 'table-1', 'workspace-1', ${JSON.stringify(fixture.data)}::jsonb, ${updatedAt.toISOString()}::timestamp) + `) + return { value: undefined, affectedRowIds: ['row-1'] } + }, + }) + }) + return fixture +} + +describe.skipIf(!databaseUrl)('table provenance in PostgreSQL', () => { + beforeAll(async () => { + if (!connection) throw new Error('PostgreSQL test database is not initialized') + database.current = drizzle(connection) + await connection.unsafe(` + CREATE TEMP TABLE user_table_definitions (id text PRIMARY KEY, workspace_id text NOT NULL, rows_version integer NOT NULL); + CREATE TEMP TABLE user_table_rows ( + id text PRIMARY KEY, table_id text NOT NULL, workspace_id text NOT NULL, + data jsonb NOT NULL, updated_at timestamp NOT NULL, secret_provenance_version integer + ); + CREATE TEMP TABLE user_table_row_secret_provenance ( + row_id text PRIMARY KEY, content_updated_at timestamp NOT NULL, + status text NOT NULL, entries jsonb NOT NULL, updated_at timestamp DEFAULT now() + ); + CREATE FUNCTION pg_temp.demote_changed_row() RETURNS trigger LANGUAGE plpgsql AS $body$ + BEGIN + IF NEW.data IS DISTINCT FROM OLD.data THEN + NEW.updated_at := clock_timestamp(); + NEW.secret_provenance_version := NULL; + END IF; + RETURN NEW; + END + $body$; + CREATE TRIGGER demote_changed_row BEFORE UPDATE ON user_table_rows + FOR EACH ROW EXECUTE FUNCTION pg_temp.demote_changed_row(); + `) + }) + + beforeEach(async () => { + vi.clearAllMocks() + mockIsEnforced.mockReturnValue(false) + if (!connection) throw new Error('PostgreSQL test database is not initialized') + await connection.unsafe( + 'TRUNCATE user_table_rows, user_table_row_secret_provenance, user_table_definitions' + ) + await connection`INSERT INTO user_table_definitions VALUES ('table-1', 'workspace-1', 7)` + }) + + afterAll(async () => { + await connection?.end() + }) + + it.each([ + { name: 'missing sidecar', fixture: {}, unrecorded: true }, + { name: 'stored unknown', fixture: { status: 'unknown' }, unrecorded: true }, + { name: 'stale binding', fixture: { status: 'exact', stale: true }, unrecorded: true }, + { name: 'unsupported tracked version', fixture: { version: 2 }, unrecorded: true }, + { name: 'legacy row', fixture: { version: null }, unrecorded: false }, + { + name: 'legacy row with an obsolete sidecar', + fixture: { version: null, status: 'unknown', stale: true }, + unrecorded: false, + }, + { name: 'exact-empty', fixture: { status: 'exact' }, unrecorded: false }, + ])('classifies $name explicitly under both flag settings', async ({ fixture, unrecorded }) => { + await insertRow(fixture) + for (const enforced of [false, true]) { + mockIsEnforced.mockReturnValue(enforced) + mockReport.mockClear() + await expect( + getTableSnapshotModelMountSafety({ + tableId: 'table-1', + workspaceId: 'workspace-1', + rowsVersion: 7, + }) + ).resolves.toBe(enforced && unrecorded ? 'unsafe-provenance' : 'safe') + expect(mockReport).toHaveBeenCalledTimes(unrecorded && !enforced ? 1 : 0) + } + }) + + it.each([ + { name: 'known secret entries', entries: [secretEntry] }, + { name: 'malformed array', entries: [null] }, + { name: 'malformed object', entries: {} }, + ])( + 'keeps $name unsafe with the flag off and does not report a proceeded read', + async ({ entries }) => { + await insertRow({ status: 'exact', entries }) + await insertRow({ id: 'unrecorded-row', status: 'unknown' }) + await expect( + getTableSnapshotModelMountSafety({ + tableId: 'table-1', + workspaceId: 'workspace-1', + rowsVersion: 7, + }) + ).resolves.toBe('unsafe-provenance') + expect(mockReport).not.toHaveBeenCalled() + } + ) + + it('returns one count for a stable allowed snapshot containing several unrecorded rows', async () => { + await insertRow({ id: 'missing' }) + await insertRow({ id: 'unknown', status: 'unknown' }) + await expect( + getTableSnapshotModelMountSafety({ + tableId: 'table-1', + workspaceId: 'workspace-1', + rowsVersion: 7, + }) + ).resolves.toBe('safe') + expect(mockReport).toHaveBeenCalledExactlyOnceWith({ + surface: 'table-row', + cause: 'row-sidecar-not-exact', + affectedCount: 2, + workspaceId: 'workspace-1', + }) + }) + + it('preserves legacy compatibility and records every SQL-derived unknown by cause', async () => { + if (!connection || !database.current) + throw new Error('PostgreSQL test database is not initialized') + await insertRow({ id: 'legacy', version: null, status: 'unknown', stale: true }) + await insertRow({ id: 'exact', status: 'exact', entries: [secretEntry] }) + await insertRow({ id: 'unknown', status: 'unknown' }) + await insertRow({ + id: 'malformed', + status: 'exact', + entries: [{ encryptedValue: 'missing-column' }], + }) + + await database.current.transaction(async (tx) => { + const count = await updateTableRowsWithDerivedSecretProvenance(tx as DbTransaction, { + rowWhere: eq(userTableRows.tableId, 'table-1'), + transformation: { mode: 'remove-columns', columnIds: ['removed'] }, + }) + expect(count).toBe(4) + }) + const rows = await connection` + SELECT r.id, r.secret_provenance_version AS version, p.status, p.entries, + p.content_updated_at = r.updated_at AS current + FROM user_table_rows r JOIN user_table_row_secret_provenance p ON p.row_id = r.id ORDER BY r.id + ` + expect(rows).toEqual([ + { id: 'exact', version: 1, status: 'exact', entries: [secretEntry], current: true }, + { id: 'legacy', version: 1, status: 'exact', entries: [], current: true }, + { id: 'malformed', version: 1, status: 'unknown', entries: [], current: true }, + { id: 'unknown', version: 1, status: 'unknown', entries: [], current: true }, + ]) + for (const cause of ['derived-base-unvouchable', 'derived-base-unnormalizable']) { + expect(mockError).toHaveBeenCalledWith( + 'Table row write staged unrecorded secret provenance', + { + surface: 'table-row', + cause, + mode: 'remove-columns', + rowCount: 1, + workspaceId: 'workspace-1', + tableId: 'table-1', + } + ) + } + expect(mockError).toHaveBeenCalledTimes(2) + }) + + it('applies the same derived logging to preserved-column transformations', async () => { + if (!database.current) throw new Error('PostgreSQL test database is not initialized') + await insertRow({ status: 'unknown' }) + await database.current.transaction(async (tx) => { + await updateTableRowsWithDerivedSecretProvenance(tx as DbTransaction, { + rowWhere: eq(userTableRows.tableId, 'table-1'), + transformation: { + mode: 'preserve', + dataExpression: sql`${userTableRows.data} || '{"added":true}'::jsonb`, + }, + }) + }) + expect(mockError).toHaveBeenCalledExactlyOnceWith( + 'Table row write staged unrecorded secret provenance', + { + surface: 'table-row', + cause: 'derived-base-unvouchable', + mode: 'preserve', + rowCount: 1, + workspaceId: 'workspace-1', + tableId: 'table-1', + } + ) + }) + + it('counts ordinary writes from rows actually bound rather than planned or nonexistent rows', async () => { + if (!connection || !database.current) + throw new Error('PostgreSQL test database is not initialized') + await insertRow({ id: 'written', version: null }) + await insertRow({ id: 'untouched', version: null }) + await database.current.transaction(async (tx) => { + await mutateTableRowsWithSecretProvenance(tx as DbTransaction, { + rows: ['written', 'untouched', 'nonexistent'].map((rowId) => ({ + rowId, + provenance: { complete: false, columns: {} }, + })), + rowState: 'new', + mode: 'replace', + mutate: async () => ({ value: undefined, affectedRowIds: ['written', 'nonexistent'] }), + }) + }) + const sidecars = await connection`SELECT row_id, status FROM user_table_row_secret_provenance` + expect(sidecars).toEqual([{ row_id: 'written', status: 'unknown' }]) + expect(mockError).toHaveBeenCalledExactlyOnceWith( + 'Table row write staged unrecorded secret provenance', + { + surface: 'table-row', + cause: 'incoming-provenance-incomplete', + mode: 'replace', + rowCount: 1, + workspaceId: 'workspace-1', + tableId: 'table-1', + } + ) + }) + + it('writes and reads 1,000 columns carrying eleven secrets without losing their column or source bindings', async () => { + if (!connection) throw new Error('PostgreSQL test database is not initialized') + const { scope, entries, data } = await writeWideRow() + const [stored] = await connection` + SELECT p.status, jsonb_array_length(p.entries) AS bindings, + (SELECT count(DISTINCT entry ->> 'encryptedValue')::integer + FROM jsonb_array_elements(p.entries) AS value(entry)) AS secrets, + r.secret_provenance_version AS version, p.content_updated_at = r.updated_at AS current + FROM user_table_rows r JOIN user_table_row_secret_provenance p ON p.row_id = r.id + ` + expect(stored).toEqual({ + status: 'exact', + bindings: 11_000, + secrets: 11, + version: 1, + current: true, + }) + for (const [columnId, expectedEntries] of [ + ['column-000', entries], + ['column-999', entries.map(({ encryptedValue }) => ({ encryptedValue }))], + ] as const) { + await expect( + loadTableRowSecretProvenance( + [{ id: 'row-1', updatedAt, selectedValues: { [columnId]: data[columnId] } }], + scope + ) + ).resolves.toEqual({ version: 1, complete: true, entries: expectedEntries, scope }) + } + expect(mockError).not.toHaveBeenCalled() + expect(mockReport).not.toHaveBeenCalled() + }) + + it('preserves wide bindings through derived SQL and removes only the deleted column', async () => { + if (!connection || !database.current) + throw new Error('PostgreSQL test database is not initialized') + const { scope, entries } = await writeWideRow() + await database.current.transaction(async (tx) => { + await updateTableRowsWithDerivedSecretProvenance(tx as DbTransaction, { + rowWhere: eq(userTableRows.tableId, 'table-1'), + transformation: { + mode: 'preserve', + dataExpression: sql`jsonb_set(${userTableRows.data}, '{column-000}', to_jsonb((${userTableRows.data} ->> 'column-000') || ' retained'))`, + }, + }) + }) + const [preserved] = await connection` + SELECT p.status, jsonb_array_length(p.entries) AS bindings, + p.content_updated_at = r.updated_at AS current + FROM user_table_rows r JOIN user_table_row_secret_provenance p ON p.row_id = r.id + ` + expect(preserved).toEqual({ status: 'exact', bindings: 11_000, current: true }) + + await database.current.transaction(async (tx) => { + await updateTableRowsWithDerivedSecretProvenance(tx as DbTransaction, { + rowWhere: eq(userTableRows.tableId, 'table-1'), + transformation: { mode: 'remove-columns', columnIds: ['column-999'] }, + }) + }) + const [removed] = await connection` + SELECT p.status, jsonb_array_length(p.entries) AS bindings, + r.data ? 'column-999' AS has_removed_data, + EXISTS (SELECT 1 FROM jsonb_array_elements(p.entries) AS value(entry) + WHERE entry ->> 'columnId' = 'column-999') AS has_removed_binding, + p.content_updated_at = r.updated_at AS current + FROM user_table_rows r JOIN user_table_row_secret_provenance p ON p.row_id = r.id + ` + expect(removed).toEqual({ + status: 'exact', + bindings: 10_989, + has_removed_data: false, + has_removed_binding: false, + current: true, + }) + const [row] = await database.current + .select({ updatedAt: userTableRows.updatedAt }) + .from(userTableRows) + await expect( + loadTableRowSecretProvenance([{ id: 'row-1', updatedAt: row.updatedAt }], scope) + ).resolves.toEqual({ version: 1, complete: true, entries, scope }) + expect(mockError).not.toHaveBeenCalled() + }) + + it('merges a wide row without losing untouched column bindings', async () => { + if (!connection || !database.current) + throw new Error('PostgreSQL test database is not initialized') + const { scope, entries, data } = await writeWideRow() + await database.current.transaction(async (tx) => { + await mutateTableRowsWithSecretProvenance(tx as DbTransaction, { + rows: [ + { + rowId: 'row-1', + provenance: { + complete: true, + columns: { 'column-000': { version: 1, complete: true, entries: [] } }, + }, + }, + ], + rowState: 'existing', + mode: 'merge', + mutate: async () => { + await tx.execute( + sql`UPDATE user_table_rows SET data = jsonb_set(data, '{column-000}', '"public"'::jsonb) WHERE id = 'row-1'` + ) + return { value: undefined, affectedRowIds: ['row-1'] } + }, + }) + }) + const [row] = await database.current + .select({ updatedAt: userTableRows.updatedAt }) + .from(userTableRows) + const [stored] = + await connection`SELECT status, jsonb_array_length(entries) AS bindings FROM user_table_row_secret_provenance` + expect(stored).toEqual({ status: 'exact', bindings: 10_989 }) + await expect( + loadTableRowSecretProvenance( + [{ id: 'row-1', updatedAt: row.updatedAt, selectedValues: { 'column-000': 'public' } }], + scope + ) + ).resolves.toEqual({ version: 1, complete: true, entries: [], scope }) + await expect( + loadTableRowSecretProvenance( + [ + { + id: 'row-1', + updatedAt: row.updatedAt, + selectedValues: { 'column-001': data['column-001'] }, + }, + ], + scope + ) + ).resolves.toEqual({ version: 1, complete: true, entries, scope }) + expect(mockError).not.toHaveBeenCalled() + }) + + it.each(['distinct-secrets', 'serialized-bytes'] as const)( + 'keeps the %s bound in the real derived SQL predicate', + async (limit) => { + if (!connection || !database.current) + throw new Error('PostgreSQL test database is not initialized') + await insertRow({ + status: 'exact', + entries: + limit === 'distinct-secrets' + ? Array.from({ length: 10_001 }, (_, index) => ({ + columnId: 'retained', + encryptedValue: `encrypted-${index}`, + })) + : ['retained', 'removed'].map((columnId) => ({ + columnId, + encryptedValue: 'x'.repeat(PROVENANCE_MAX_SERIALIZED_BYTES / 2), + })), + }) + await database.current.transaction(async (tx) => { + await updateTableRowsWithDerivedSecretProvenance(tx as DbTransaction, { + rowWhere: eq(userTableRows.tableId, 'table-1'), + transformation: { + mode: 'preserve', + dataExpression: sql`${userTableRows.data} || '{"removed":"changed"}'::jsonb`, + }, + }) + }) + expect( + await connection`SELECT status, entries FROM user_table_row_secret_provenance` + ).toEqual([{ status: 'unknown', entries: [] }]) + expect(mockError).toHaveBeenCalledWith( + 'Table row write staged unrecorded secret provenance', + expect.objectContaining({ cause: 'derived-base-unnormalizable', rowCount: 1 }) + ) + } + ) +}) diff --git a/apps/sim/lib/table/rows/secret-provenance.test.ts b/apps/sim/lib/table/rows/secret-provenance.test.ts index b2966a0e9d3..1cbcd3b7a48 100644 --- a/apps/sim/lib/table/rows/secret-provenance.test.ts +++ b/apps/sim/lib/table/rows/secret-provenance.test.ts @@ -6,9 +6,14 @@ import { dbChainMock, dbChainMockFns, queueTableRows, resetDbChainMock } from '@ import { eq } from 'drizzle-orm' import { beforeEach, describe, expect, it, vi } from 'vitest' -const { mockIsEnforced, mockReport } = vi.hoisted(() => ({ +const { mockIsEnforced, mockReport, mockError } = vi.hoisted(() => ({ mockIsEnforced: vi.fn(() => false), mockReport: vi.fn(), + mockError: vi.fn(), +})) + +vi.mock('@sim/logger', () => ({ + createLogger: () => ({ error: mockError, warn: vi.fn() }), })) vi.mock('@/lib/execution/durable-secret-provenance-enforcement', () => ({ @@ -17,6 +22,7 @@ vi.mock('@/lib/execution/durable-secret-provenance-enforcement', () => ({ reportUnrecordedDurableProvenance: mockReport, })) +import { PROVENANCE_MAX_SERIALIZED_BYTES } from '@/lib/execution/provenance-limits' import type { DbTransaction } from '@/lib/table/planner' import { classifyTableRowSecretProvenanceForCopy, @@ -61,10 +67,10 @@ describe('table row secret provenance', () => { mockIsEnforced.mockReturnValue(false) }) - it('checks a version-pinned table with one bounded unsafe-row query', async () => { + it('checks a version-pinned table with one aggregate rather than loading its rows', async () => { queueTableRows(userTableDefinitions, [{ rowsVersion: 7 }]) queueTableRows(userTableDefinitions, [{ rowsVersion: 7 }]) - queueTableRows(userTableRows, []) + queueTableRows(userTableRows, [{ unsafeCount: 0, unrecordedCount: 0 }]) await expect( getTableSnapshotModelMountSafety({ @@ -74,13 +80,13 @@ describe('table row secret provenance', () => { }) ).resolves.toBe('safe') - expect(dbChainMockFns.limit).toHaveBeenCalledTimes(3) + expect(dbChainMockFns.limit).toHaveBeenCalledTimes(2) expect(dbChainMockFns.orderBy).not.toHaveBeenCalled() }) it('classifies unsafe provenance after confirming the snapshot remains current', async () => { queueTableRows(userTableDefinitions, [{ rowsVersion: 7 }]) - queueTableRows(userTableRows, [{ id: 'unsafe-row' }]) + queueTableRows(userTableRows, [{ unsafeCount: 1, unrecordedCount: 3 }]) queueTableRows(userTableDefinitions, [{ rowsVersion: 7 }]) await expect( @@ -91,13 +97,14 @@ describe('table row secret provenance', () => { }) ).resolves.toBe('unsafe-provenance') - expect(dbChainMockFns.limit).toHaveBeenCalledTimes(3) + expect(dbChainMockFns.limit).toHaveBeenCalledTimes(2) + expect(mockReport).not.toHaveBeenCalled() }) it('rejects a snapshot when the table changes during the safety check', async () => { queueTableRows(userTableDefinitions, [{ rowsVersion: 7 }]) queueTableRows(userTableDefinitions, [{ rowsVersion: 8 }]) - queueTableRows(userTableRows, []) + queueTableRows(userTableRows, [{ unsafeCount: 0, unrecordedCount: 3 }]) await expect( getTableSnapshotModelMountSafety({ @@ -106,8 +113,36 @@ describe('table row secret provenance', () => { rowsVersion: 7, }) ).resolves.toBe('stale') + expect(mockReport).not.toHaveBeenCalled() }) + it.each([false, true])( + 'applies the table-row enforcement policy to snapshot absences (%s)', + async (enforced) => { + mockIsEnforced.mockReturnValue(enforced) + queueTableRows(userTableDefinitions, [{ rowsVersion: 7 }]) + queueTableRows(userTableDefinitions, [{ rowsVersion: 7 }]) + queueTableRows(userTableRows, [{ unsafeCount: '0', unrecordedCount: '3' }]) + + await expect( + getTableSnapshotModelMountSafety({ + tableId: 'table-1', + workspaceId: 'workspace-1', + rowsVersion: 7, + }) + ).resolves.toBe(enforced ? 'unsafe-provenance' : 'safe') + + if (enforced) expect(mockReport).not.toHaveBeenCalled() + else + expect(mockReport).toHaveBeenCalledExactlyOnceWith({ + surface: 'table-row', + cause: 'row-sidecar-not-exact', + affectedCount: 3, + workspaceId: 'workspace-1', + }) + } + ) + it('keeps untouched legacy rows readable with exact-empty provenance', async () => { queueTableRows(userTableRows, [ { @@ -526,7 +561,7 @@ describe('table row secret provenance', () => { }) expect(pendingRowsFromLastExecute()).toEqual([ - { row_id: 'tracked-row', status: 'unknown', entries: [] }, + { row_id: 'tracked-row', status: 'unknown', entries: [], cause: 'merge-base-unvouchable' }, ]) }) @@ -535,7 +570,7 @@ describe('table row secret provenance', () => { rows: [ { rowId: 'missing-row', - provenance: { complete: true, columns: {} }, + provenance: { complete: false, columns: {} }, }, ], rowState: 'new', @@ -546,6 +581,55 @@ describe('table row secret provenance', () => { expect(dbChainMockFns.select).not.toHaveBeenCalled() expect(dbChainMockFns.for).not.toHaveBeenCalled() expect(dbChainMockFns.execute).not.toHaveBeenCalled() + expect(mockError).not.toHaveBeenCalled() + }) + + it('does not report a planned unrecorded write when the mutation throws', async () => { + await expect( + mutateTableRowsWithSecretProvenance(dbChainMock.db as unknown as DbTransaction, { + rows: [{ rowId: 'missing-row', provenance: { complete: false, columns: {} } }], + rowState: 'new', + mode: 'replace', + mutate: async () => { + throw new Error('Mutation failed') + }, + }) + ).rejects.toThrow('Mutation failed') + expect(mockError).not.toHaveBeenCalled() + }) + + it('reports only bound ordinary writes with their canonical table and workspace', async () => { + dbChainMockFns.execute.mockResolvedValueOnce([ + { + workspaceId: 'workspace-1', + tableId: 'table-1', + cause: 'incoming-provenance-incomplete', + rowCount: 1, + }, + ]) + await mutateTableRowsWithSecretProvenance(dbChainMock.db as unknown as DbTransaction, { + rows: ['bound-row', 'unaffected-row'].map((rowId) => ({ + rowId, + provenance: { complete: false, columns: {} }, + })), + rowState: 'new', + mode: 'replace', + mutate: async () => ({ value: undefined, affectedRowIds: ['bound-row'] }), + }) + expect(mockError).toHaveBeenCalledExactlyOnceWith( + 'Table row write staged unrecorded secret provenance', + { + surface: 'table-row', + cause: 'incoming-provenance-incomplete', + mode: 'replace', + rowCount: 1, + workspaceId: 'workspace-1', + tableId: 'table-1', + } + ) + expect(mockError.mock.invocationCallOrder[0]).toBeGreaterThan( + dbChainMockFns.execute.mock.invocationCallOrder[0] + ) }) it('binds exact provenance for a new row without a pre-insert read', async () => { @@ -613,6 +697,47 @@ describe('table row secret provenance', () => { expect(dbChainMockFns.insert).not.toHaveBeenCalled() }) + it('attributes derived unrecorded writes after their sidecars and markers are bound', async () => { + queueTableRows(userTableRows, [{ id: 'unknown-row' }, { id: 'malformed-row' }]) + dbChainMockFns.execute.mockResolvedValueOnce([ + { + workspaceId: 'workspace-1', + tableId: 'table-1', + cause: 'derived-base-unvouchable', + rowCount: 1, + }, + { + workspaceId: 'workspace-1', + tableId: 'table-1', + cause: 'derived-base-unnormalizable', + rowCount: 1, + }, + ]) + + await updateTableRowsWithDerivedSecretProvenance(dbChainMock.db as unknown as DbTransaction, { + rowWhere: eq(userTableRows.tableId, 'table-1'), + transformation: { mode: 'remove-columns', columnIds: ['deleted-column'] }, + }) + + expect(mockError).toHaveBeenCalledTimes(2) + for (const cause of ['derived-base-unvouchable', 'derived-base-unnormalizable']) { + expect(mockError).toHaveBeenCalledWith( + 'Table row write staged unrecorded secret provenance', + { + surface: 'table-row', + cause, + mode: 'remove-columns', + rowCount: 1, + workspaceId: 'workspace-1', + tableId: 'table-1', + } + ) + } + expect(mockError.mock.invocationCallOrder[0]).toBeGreaterThan( + dbChainMockFns.execute.mock.invocationCallOrder[1] + ) + }) + it('preserves legacy fork compatibility without manufacturing provenance', () => { expect( classifyTableRowSecretProvenanceForCopy({ @@ -623,6 +748,104 @@ describe('table row secret provenance', () => { ).toEqual({ mode: 'legacy' }) }) + it('preserves more than ten thousand column bindings when they describe eleven secrets', () => { + const entries = Array.from({ length: 1_000 }, (_, column) => + Array.from({ length: 11 }, (_, secret) => ({ + columnId: `column-${column}`, + encryptedValue: `encrypted-${secret}`, + name: `SECRET_${secret}`, + sourceUserId: column === 999 ? 'foreign-user' : 'user-1', + sourceWorkspaceId: 'workspace-1', + })) + ).flat() + + const classified = classifyTableRowSecretProvenanceForCopy({ + secretProvenanceVersion: 1, + provenanceIsCurrent: true, + provenance: { status: 'exact', entries }, + }) + expect(classified).toMatchObject({ mode: 'tracked', status: 'exact' }) + if (classified.mode !== 'tracked') throw new Error('Expected tracked provenance') + expect(classified.entries).toHaveLength(11_000) + expect(new Set(classified.entries.map((entry) => JSON.stringify(entry)))).toEqual( + new Set(entries.map((entry) => JSON.stringify(entry))) + ) + }) + + it('deduplicates repeated bindings before charging the secret or serialized budgets', () => { + const entry = { columnId: 'column-1', encryptedValue: 'encrypted-secret' } + expect( + classifyTableRowSecretProvenanceForCopy({ + secretProvenanceVersion: 1, + provenanceIsCurrent: true, + provenance: { status: 'exact', entries: Array.from({ length: 11_000 }, () => entry) }, + }) + ).toEqual({ mode: 'tracked', status: 'exact', entries: [entry] }) + }) + + it('keeps distinct binding fields separate even when they contain delimiter characters', () => { + const entries = [ + { columnId: 'column\u0000one', encryptedValue: 'two' }, + { columnId: 'column', encryptedValue: 'one\u0000two' }, + ] + expect( + classifyTableRowSecretProvenanceForCopy({ + secretProvenanceVersion: 1, + provenanceIsCurrent: true, + provenance: { status: 'exact', entries }, + }) + ).toEqual({ mode: 'tracked', status: 'exact', entries: [entries[1], entries[0]] }) + }) + + it('still rejects a stored row carrying ten thousand and one distinct encrypted values', () => { + const entries = Array.from({ length: 10_001 }, (_, index) => ({ + columnId: 'column-1', + encryptedValue: `encrypted-${index}`, + })) + expect( + classifyTableRowSecretProvenanceForCopy({ + secretProvenanceVersion: 1, + provenanceIsCurrent: true, + provenance: { status: 'exact', entries }, + }) + ).toEqual({ mode: 'tracked', status: 'unknown', entries: [] }) + }) + + it('stops before later bindings when one entry already exceeds the serialized budget', () => { + const entries = [ + { columnId: 'column-1', encryptedValue: 'x'.repeat(PROVENANCE_MAX_SERIALIZED_BYTES + 1) }, + ] + const readNextEntry = vi.fn(() => { + throw new Error('Oversized provenance must stop before the next entry') + }) + Object.defineProperty(entries, 1, { get: readNextEntry, enumerable: true }) + expect( + classifyTableRowSecretProvenanceForCopy({ + secretProvenanceVersion: 1, + provenanceIsCurrent: true, + provenance: { status: 'exact', entries }, + }) + ).toEqual({ mode: 'tracked', status: 'unknown', entries: [] }) + expect(readNextEntry).not.toHaveBeenCalled() + }) + + it('charges each retained column binding against the serialized budget even for one secret', () => { + const encryptedValue = 'x'.repeat(PROVENANCE_MAX_SERIALIZED_BYTES / 2) + expect( + classifyTableRowSecretProvenanceForCopy({ + secretProvenanceVersion: 1, + provenanceIsCurrent: true, + provenance: { + status: 'exact', + entries: [ + { columnId: 'column-1', encryptedValue }, + { columnId: 'column-2', encryptedValue }, + ], + }, + }) + ).toEqual({ mode: 'tracked', status: 'unknown', entries: [] }) + }) + it('copies only exact provenance bound to the current source row version', () => { const exact = classifyTableRowSecretProvenanceForCopy({ secretProvenanceVersion: 1, diff --git a/apps/sim/lib/table/rows/secret-provenance.ts b/apps/sim/lib/table/rows/secret-provenance.ts index a02dd4b696a..604ef0c8b5e 100644 --- a/apps/sim/lib/table/rows/secret-provenance.ts +++ b/apps/sim/lib/table/rows/secret-provenance.ts @@ -11,6 +11,7 @@ import { isDurableSecretProvenanceEnforced, reportUnrecordedDurableProvenance, } from '@/lib/execution/durable-secret-provenance-enforcement' +import { SecretProvenanceBudget } from '@/lib/execution/provenance-budget' import { PROVENANCE_MAX_ENTRIES, PROVENANCE_MAX_SERIALIZED_BYTES, @@ -86,18 +87,26 @@ const STORED_ENTRY_KEYS = new Set([ /** * Why a durable table row write could not vouch for the cells it persisted. * - * Every path that stamps a row `unknown` through this module funnels into one mutation, so this is - * the whole cause set for the durable write — a closed union for the reason the read side's is one, - * and because these are the lines a surface would be closed on the strength of reaching zero. + * Shared by ordinary mutations and derived column transformations, so the same event covers + * every writer rather than only the paths that classify provenance in JavaScript. */ type UnvouchedTableRowWriteCause = | 'incoming-provenance-incomplete' | 'merge-base-unvouchable' | 'merge-base-unnormalizable' | 'merge-result-unnormalizable' + | 'derived-base-unvouchable' + | 'derived-base-unnormalizable' + +type UnvouchedTableRowWriteReport = { + cause: UnvouchedTableRowWriteCause + workspaceId: string + tableId: string + rowCount: number +} /** - * Records that a write persisted rows nobody could vouch for. + * Reports rows actually bound by the current transaction, which can still roll back later. * * Summarised per mutation rather than per row: one incomplete envelope marks every row of a batch, * and a batch runs to a thousand rows. Error for the same reason the read side uses it — an @@ -105,17 +114,14 @@ type UnvouchedTableRowWriteCause = * back to. */ function reportUnvouchedTableRowWrite( - countsByCause: ReadonlyMap, - mode: 'replace' | 'merge' + report: UnvouchedTableRowWriteReport, + mode: 'replace' | 'merge' | DerivedTableRowTransformation['mode'] ): void { - for (const [cause, rowCount] of countsByCause) { - logger.error('Table row write persisted unrecorded secret provenance', { - surface: 'table-row', - cause, - mode, - rowCount, - }) - } + logger.error('Table row write staged unrecorded secret provenance', { + surface: 'table-row', + ...report, + mode, + }) } function compareStrings(left: string, right: string): number { @@ -149,36 +155,71 @@ function isStoredEntry(value: unknown): value is StoredTableRowSecretProvenanceE ) } -function storedEntryKey(entry: StoredTableRowSecretProvenanceEntry): string { - return [ - entry.columnId, - entry.encryptedValue, - entry.name ?? '', - entry.sourceUserId ?? '', - entry.sourceWorkspaceId ?? '', - ].join('\u0000') +function normalizeStoredEntryBindings( + values: Iterable +): StoredTableRowSecretProvenanceEntry[] | undefined { + const deduplicated = new Map() + const budget = new SecretProvenanceBudget() + for (const entry of values) { + if (!isStoredEntry(entry)) return undefined + let minimumBytes = 0 + for (const value of Object.values(entry)) { + if (typeof value === 'string') minimumBytes += Buffer.byteLength(value, 'utf8') + if (minimumBytes > PROVENANCE_MAX_SERIALIZED_BYTES) return undefined + } + const normalized = { + columnId: entry.columnId, + encryptedValue: entry.encryptedValue, + ...(entry.name ? { name: entry.name } : {}), + ...(entry.sourceUserId ? { sourceUserId: entry.sourceUserId } : {}), + ...(entry.sourceWorkspaceId ? { sourceWorkspaceId: entry.sourceWorkspaceId } : {}), + } + const key = JSON.stringify(normalized) + if (deduplicated.has(key)) continue + if (!budget.add(entry.encryptedValue, Buffer.byteLength(key, 'utf8'))) return undefined + deduplicated.set(key, normalized) + } + return [...deduplicated.values()].sort( + (left, right) => + compareStrings(left.columnId, right.columnId) || + compareStrings(left.encryptedValue, right.encryptedValue) || + compareStrings(left.name ?? '', right.name ?? '') || + compareStrings(left.sourceUserId ?? '', right.sourceUserId ?? '') || + compareStrings(left.sourceWorkspaceId ?? '', right.sourceWorkspaceId ?? '') + ) } function normalizeStoredEntries(value: unknown): StoredTableRowSecretProvenanceEntry[] | undefined { - if ( - !Array.isArray(value) || - value.length > PROVENANCE_MAX_ENTRIES || - !value.every(isStoredEntry) - ) { - return undefined + return Array.isArray(value) ? normalizeStoredEntryBindings(value) : undefined +} + +function* storedEntriesFromColumns( + columns: [string, ResolvedSecretTraceProvenanceV1][] +): Generator { + for (const [columnId, provenance] of columns) { + for (const entry of provenance.entries) { + yield { + columnId, + encryptedValue: entry.encryptedValue, + ...(entry.name ? { name: entry.name } : {}), + ...(provenance.scope?.userId ? { sourceUserId: provenance.scope.userId } : {}), + ...(provenance.scope?.workspaceId + ? { sourceWorkspaceId: provenance.scope.workspaceId } + : {}), + } + } } - const deduplicated = new Map() - for (const entry of value) deduplicated.set(storedEntryKey(entry), { ...entry }) - const entries = [...deduplicated.values()].sort((left, right) => - compareStrings(storedEntryKey(left), storedEntryKey(right)) - ) - if ( - entries.length > PROVENANCE_MAX_ENTRIES || - serializedBytes(entries) > PROVENANCE_MAX_SERIALIZED_BYTES - ) { - return undefined +} + +function* mergedStoredEntries( + existing: StoredTableRowSecretProvenanceEntry[], + incoming: StoredTableRowSecretProvenanceEntry[], + touchedColumns: Set +): Generator { + for (const entry of existing) { + if (!touchedColumns.has(entry.columnId)) yield entry } - return entries + yield* incoming } function toStoredEntries(provenance: TableRowSecretProvenanceWrite): { @@ -200,21 +241,7 @@ function toStoredEntries(provenance: TableRowSecretProvenanceWrite): { return { complete: false, touchedColumns, entries: [] } } - const entries: StoredTableRowSecretProvenanceEntry[] = [] - for (const [columnId, columnProvenance] of columnEntries) { - for (const entry of columnProvenance.entries) { - entries.push({ - columnId, - encryptedValue: entry.encryptedValue, - ...(entry.name ? { name: entry.name } : {}), - ...(columnProvenance.scope?.userId ? { sourceUserId: columnProvenance.scope.userId } : {}), - ...(columnProvenance.scope?.workspaceId - ? { sourceWorkspaceId: columnProvenance.scope.workspaceId } - : {}), - }) - } - } - const normalized = normalizeStoredEntries(entries) + const normalized = normalizeStoredEntryBindings(storedEntriesFromColumns(columnEntries)) return normalized ? { complete: true, touchedColumns, entries: normalized } : { complete: false, touchedColumns, entries: [] } @@ -376,10 +403,10 @@ export async function mutateTableRowsWithSecretProvenance( row_id: string status: 'exact' | 'unknown' entries: StoredTableRowSecretProvenanceEntry[] + cause?: UnvouchedTableRowWriteCause } >() - const unvouchedRowCounts = new Map() for (const mutation of mutations) { if (mutation.provenance === undefined) continue const row = rowsById.get(mutation.rowId) @@ -400,10 +427,9 @@ export async function mutateTableRowsWithSecretProvenance( ) { const existing = normalizeStoredEntries(row.sidecarEntries) if (existing) { - const merged = normalizeStoredEntries([ - ...existing.filter((entry) => !incoming.touchedColumns.has(entry.columnId)), - ...incoming.entries, - ]) + const merged = normalizeStoredEntryBindings( + mergedStoredEntries(existing, incoming.entries, incoming.touchedColumns) + ) if (merged) entries = merged else { status = 'unknown' @@ -418,14 +444,13 @@ export async function mutateTableRowsWithSecretProvenance( cause = 'merge-base-unvouchable' } } - if (cause) unvouchedRowCounts.set(cause, (unvouchedRowCounts.get(cause) ?? 0) + 1) pendingByRowId.set(mutation.rowId, { row_id: mutation.rowId, status, entries: status === 'exact' ? entries : [], + ...(cause ? { cause } : {}), }) } - reportUnvouchedTableRowWrite(unvouchedRowCounts, options.mode) const outcome = await options.mutate() const affectedRowIds = new Set() @@ -442,18 +467,22 @@ export async function mutateTableRowsWithSecretProvenance( const pending = [...pendingByRowId.values()].filter((row) => affectedRowIds.has(row.row_id)) for (let index = 0; index < pending.length; index += QUERY_CHUNK_SIZE) { const chunk = JSON.stringify(pending.slice(index, index + QUERY_CHUNK_SIZE)) - await trx.execute(sql` + const unrecordedWrites = await trx.execute(sql` WITH pending AS ( SELECT * FROM jsonb_to_recordset(${chunk}::jsonb) - AS value(row_id text, status text, entries jsonb) + AS value(row_id text, status text, entries jsonb, cause text) ), bound AS ( UPDATE ${userTableRows} AS target SET secret_provenance_version = ${TABLE_ROW_SECRET_PROVENANCE_VERSION} FROM pending WHERE target.id = pending.row_id - RETURNING target.id AS row_id, target.updated_at AS content_updated_at - ) + RETURNING + target.id AS row_id, + target.updated_at AS content_updated_at, + target.workspace_id, + target.table_id + ), persisted AS ( INSERT INTO ${userTableRowSecretProvenance} ( row_id, content_updated_at, @@ -474,7 +503,20 @@ export async function mutateTableRowsWithSecretProvenance( status = EXCLUDED.status, entries = EXCLUDED.entries, updated_at = EXCLUDED.updated_at + RETURNING row_id, status + ) + SELECT + bound.workspace_id AS "workspaceId", + bound.table_id AS "tableId", + pending.cause, + count(*)::integer AS "rowCount" + FROM bound + INNER JOIN pending ON pending.row_id = bound.row_id + INNER JOIN persisted ON persisted.row_id = bound.row_id + WHERE persisted.status = 'unknown' + GROUP BY bound.workspace_id, bound.table_id, pending.cause `) + for (const report of unrecordedWrites) reportUnvouchedTableRowWrite(report, options.mode) } return outcome.value @@ -556,10 +598,12 @@ export async function updateTableRowsWithDerivedSecretProvenance( const rowIds = page.map((row) => row.id) updatedCount += rowIds.length - await trx.execute(sql` + const unrecordedWrites = await trx.execute(sql` WITH source AS MATERIALIZED ( SELECT ${userTableRows.id} AS row_id, + ${userTableRows.workspaceId} AS workspace_id, + ${userTableRows.tableId} AS table_id, ${userTableRows.updatedAt} AS old_updated_at, ${userTableRows.secretProvenanceVersion} AS old_provenance_version, ${userTableRowSecretProvenance.rowId} AS provenance_row_id, @@ -584,6 +628,17 @@ export async function updateTableRowsWithDerivedSecretProvenance( SELECT updated.row_id, updated.content_updated_at, + source.workspace_id, + source.table_id, + CASE + WHEN ( + source.old_provenance_version = ${TABLE_ROW_SECRET_PROVENANCE_VERSION} + AND source.provenance_status = 'exact' + AND source.provenance_content_updated_at = source.old_updated_at + ) IS TRUE + THEN 'derived-base-unnormalizable' + ELSE 'derived-base-unvouchable' + END AS unrecorded_cause, CASE WHEN source.old_provenance_version IS NULL THEN '[]'::jsonb @@ -591,12 +646,15 @@ export async function updateTableRowsWithDerivedSecretProvenance( AND source.provenance_status = 'exact' AND source.provenance_content_updated_at = source.old_updated_at AND jsonb_typeof(source.provenance_entries) = 'array' - AND jsonb_array_length( - CASE - WHEN jsonb_typeof(source.provenance_entries) = 'array' - THEN source.provenance_entries - ELSE '[]'::jsonb - END + AND ( + SELECT count(DISTINCT value.entry ->> 'encryptedValue') + FROM jsonb_array_elements( + CASE + WHEN jsonb_typeof(source.provenance_entries) = 'array' + THEN source.provenance_entries + ELSE '[]'::jsonb + END + ) AS value(entry) ) <= ${PROVENANCE_MAX_ENTRIES} AND octet_length(source.provenance_entries::text) <= ${PROVENANCE_MAX_SERIALIZED_BYTES} AND NOT EXISTS ( @@ -657,7 +715,7 @@ export async function updateTableRowsWithDerivedSecretProvenance( END AS exact_entries FROM updated INNER JOIN source ON source.row_id = updated.row_id - ) + ), persisted AS ( INSERT INTO ${userTableRowSecretProvenance} ( row_id, content_updated_at, @@ -677,6 +735,17 @@ export async function updateTableRowsWithDerivedSecretProvenance( status = EXCLUDED.status, entries = EXCLUDED.entries, updated_at = EXCLUDED.updated_at + RETURNING row_id, status + ) + SELECT + classified.workspace_id AS "workspaceId", + classified.table_id AS "tableId", + classified.unrecorded_cause AS cause, + count(*)::integer AS "rowCount" + FROM classified + INNER JOIN persisted ON persisted.row_id = classified.row_id + WHERE persisted.status = 'unknown' + GROUP BY classified.workspace_id, classified.table_id, classified.unrecorded_cause `) await trx.execute(sql` @@ -688,6 +757,10 @@ export async function updateTableRowsWithDerivedSecretProvenance( AND provenance.content_updated_at = target.updated_at `) + for (const report of unrecordedWrites) { + reportUnvouchedTableRowWrite(report, options.transformation.mode) + } + afterId = rowIds[rowIds.length - 1] if (page.length < QUERY_CHUNK_SIZE) break } @@ -720,8 +793,28 @@ export async function getTableSnapshotModelMountSafety(options: { return 'stale' } - const [unsafeRow] = await db - .select({ id: userTableRows.id }) + /** + * A missing LEFT JOIN sidecar is an explicit absence, not SQL NULL in a negated safety + * predicate. Legacy and unrecorded rows follow the same policy as ordinary table reads; + * a current exact sidecar containing secrets cannot accompany an unredacted CSV mount. + */ + const classification = sql`CASE + WHEN ${userTableRows.secretProvenanceVersion} IS NULL THEN 'safe' + WHEN ( + ${userTableRows.secretProvenanceVersion} = ${TABLE_ROW_SECRET_PROVENANCE_VERSION} + AND ${userTableRowSecretProvenance.status} = 'exact' + AND ${userTableRowSecretProvenance.contentUpdatedAt} = ${userTableRows.updatedAt} + ) IS NOT TRUE THEN 'unrecorded' + WHEN ${userTableRowSecretProvenance.entries} = '[]'::jsonb THEN 'safe' + ELSE 'unsafe' + END` + const [counts] = await db + .select({ + unsafeCount: sql`count(*) FILTER (WHERE (${classification}) = 'unsafe')`, + unrecordedCount: sql< + number | string + >`count(*) FILTER (WHERE (${classification}) = 'unrecorded')`, + }) .from(userTableRows) .leftJoin( userTableRowSecretProvenance, @@ -730,31 +823,26 @@ export async function getTableSnapshotModelMountSafety(options: { .where( and( eq(userTableRows.tableId, options.tableId), - eq(userTableRows.workspaceId, options.workspaceId), - sql`NOT ( - ${userTableRows.secretProvenanceVersion} IS NULL - OR - (${userTableRows.secretProvenanceVersion} = ${TABLE_ROW_SECRET_PROVENANCE_VERSION} - AND ${userTableRowSecretProvenance.status} = 'exact' - AND ${userTableRowSecretProvenance.contentUpdatedAt} = ${userTableRows.updatedAt} - AND jsonb_typeof(${userTableRowSecretProvenance.entries}) = 'array' - AND jsonb_array_length( - CASE - WHEN jsonb_typeof(${userTableRowSecretProvenance.entries}) = 'array' - THEN ${userTableRowSecretProvenance.entries} - ELSE '[]'::jsonb - END - ) = 0) - )` + eq(userTableRows.workspaceId, options.workspaceId) ) ) - .limit(1) if ((await readTableRowsVersion(options.tableId, options.workspaceId)) !== options.rowsVersion) { return 'stale' } - return unsafeRow ? 'unsafe-provenance' : 'safe' + if (!counts || Number(counts.unsafeCount) > 0) return 'unsafe-provenance' + const unrecordedCount = Number(counts.unrecordedCount) + if (unrecordedCount > 0) { + if (isDurableSecretProvenanceEnforced('table-row')) return 'unsafe-provenance' + reportUnrecordedDurableProvenance({ + surface: 'table-row', + cause: 'row-sidecar-not-exact', + affectedCount: unrecordedCount, + workspaceId: options.workspaceId, + }) + } + return 'safe' } /** diff --git a/apps/sim/lib/uploads/contexts/workspace/workspace-file-secret-provenance.test.ts b/apps/sim/lib/uploads/contexts/workspace/workspace-file-secret-provenance.test.ts index 3b0622fca66..a1b84a68099 100644 --- a/apps/sim/lib/uploads/contexts/workspace/workspace-file-secret-provenance.test.ts +++ b/apps/sim/lib/uploads/contexts/workspace/workspace-file-secret-provenance.test.ts @@ -5,15 +5,19 @@ import { workspaceFileSecretProvenance, workspaceFiles } from '@sim/db/schema' import { dbChainMock, dbChainMockFns, queueTableRows, resetDbChainMock } from '@sim/testing' import { beforeEach, describe, expect, it, vi } from 'vitest' -const { mockIsEnforced, mockReport } = vi.hoisted(() => ({ +const { mockIsEnforced, mockReport, mockReportWrite, mockReportRefusal } = vi.hoisted(() => ({ mockIsEnforced: vi.fn(() => false), mockReport: vi.fn(), + mockReportWrite: vi.fn(), + mockReportRefusal: vi.fn(), })) vi.mock('@/lib/execution/durable-secret-provenance-enforcement', () => ({ DURABLE_SECRET_PROVENANCE_SURFACES: ['memory', 'table-row', 'knowledge', 'workspace-file'], isDurableSecretProvenanceEnforced: mockIsEnforced, reportUnrecordedDurableProvenance: mockReport, + reportDurableSecretProvenanceWrite: mockReportWrite, + reportDurableSecretProvenanceRefusal: mockReportRefusal, })) import type { DbTransaction } from '@/lib/db/types' @@ -80,6 +84,7 @@ describe('workspace file secret provenance', () => { ) expect(dbChainMockFns.update).toHaveBeenCalledWith(workspaceFiles) expect(dbChainMockFns.set).toHaveBeenCalledWith({ secretProvenanceVersion: 1 }) + expect(mockReportWrite).not.toHaveBeenCalled() }) it('keeps the legacy logical-byte budget when storing an anonymous entry', async () => { @@ -169,6 +174,12 @@ describe('workspace file secret provenance', () => { expect(dbChainMockFns.values).toHaveBeenCalledWith( expect.objectContaining({ fileId: 'file-1', status: 'unrecorded', entries: [] }) ) + expect(mockReportWrite).toHaveBeenCalledWith({ + surface: 'workspace-file', + status: 'unrecorded', + cause: 'workspace-file-write-unrecorded', + resourceId: 'file-1', + }) }) it('persists a refused write as unknown, not as an absence', async () => { @@ -182,6 +193,23 @@ describe('workspace file secret provenance', () => { expect(dbChainMockFns.values).toHaveBeenCalledWith( expect.objectContaining({ fileId: 'file-1', status: 'unknown', entries: [] }) ) + expect(mockReportWrite).toHaveBeenCalledWith({ + surface: 'workspace-file', + status: 'unknown', + cause: 'workspace-file-write-unknown', + resourceId: 'file-1', + }) + }) + + it('does not report a non-exact initialization ignored for an existing sidecar', async () => { + dbChainMockFns.returning.mockResolvedValueOnce([]) + await initializeWorkspaceFileSecretProvenanceInTx( + dbChainMock.db as unknown as DbTransaction, + 'file-1', + CONTENT_UPDATED_AT, + { status: 'unknown' } + ) + expect(mockReportWrite).not.toHaveBeenCalled() }) it('persists an unrecorded replacement as its own status', async () => { @@ -448,6 +476,7 @@ describe('workspace file secret provenance', () => { ]) await expect(isModelSafeWorkspaceFileKey('legacy-key')).resolves.toBe(true) + expect(mockReportRefusal).not.toHaveBeenCalled() }) it('allows opaque egress only for exact-empty or legacy file provenance', async () => { @@ -982,6 +1011,27 @@ describe('workspace file secret provenance', () => { ).toEqual({ status: 'unknown' }) }) + it('does not discard known secret entries when another contributor is unrecorded', () => { + const known = { + status: 'exact' as const, + entries: [{ name: 'TOKEN', encryptedValue: 'encrypted', sourceUserId: 'user-1' }], + } + const unrecorded = { status: 'unrecorded' as const } + expect(mergeWorkspaceFileSecretProvenance(known, unrecorded)).toEqual({ status: 'unknown' }) + expect(mergeWorkspaceFileSecretProvenance(unrecorded, known)).toEqual({ status: 'unknown' }) + }) + + it('preserves absences without known secrets and never relaxes an unknown contributor', () => { + const unrecorded = { status: 'unrecorded' as const } + const empty = { status: 'exact' as const, entries: [] } + expect(mergeWorkspaceFileSecretProvenance(unrecorded)).toEqual(unrecorded) + expect(mergeWorkspaceFileSecretProvenance(unrecorded, empty)).toEqual(unrecorded) + expect(mergeWorkspaceFileSecretProvenance(empty, unrecorded)).toEqual(unrecorded) + expect(mergeWorkspaceFileSecretProvenance(unrecorded, { status: 'unknown' })).toEqual({ + status: 'unknown', + }) + }) + it('fails closed when persisted provenance is malformed', async () => { queueTableRows(workspaceFiles, [ { @@ -1495,6 +1545,11 @@ describe('workspace file secret provenance', () => { await expect(isModelSafeWorkspaceFileKey('unrecorded-key')).resolves.toBe(false) expect(mockReport).not.toHaveBeenCalled() + expect(mockReportRefusal).toHaveBeenCalledWith({ + surface: 'workspace-file', + cause: 'workspace-file-unrecorded-enforced', + workspaceId: undefined, + }) }) /** @@ -1530,6 +1585,13 @@ describe('workspace file secret provenance', () => { queueTableRows(workspaceFiles, [row]) await expect(isModelSafeWorkspaceFileKey(row.key)).resolves.toBe(false) } + expect(mockReportRefusal).toHaveBeenCalledTimes(2) + expect(mockReportRefusal).toHaveBeenCalledWith({ + surface: 'workspace-file', + cause: 'workspace-file-provenance-unavailable', + workspaceId: 'workspace-1', + resourceId: undefined, + }) }) /** diff --git a/apps/sim/lib/uploads/contexts/workspace/workspace-file-secret-provenance.ts b/apps/sim/lib/uploads/contexts/workspace/workspace-file-secret-provenance.ts index 62a1a9cada9..e89f258199e 100644 --- a/apps/sim/lib/uploads/contexts/workspace/workspace-file-secret-provenance.ts +++ b/apps/sim/lib/uploads/contexts/workspace/workspace-file-secret-provenance.ts @@ -14,6 +14,8 @@ import { } from '@/lib/execution/durable-secret-provenance' import { isDurableSecretProvenanceEnforced, + reportDurableSecretProvenanceRefusal, + reportDurableSecretProvenanceWrite, reportUnrecordedDurableProvenance, } from '@/lib/execution/durable-secret-provenance-enforcement' import { @@ -118,10 +120,9 @@ interface ModelSafeWorkspaceFileRow { /** * Combines byte-contributing classifications without broadening any source. * - * Ordered by how little each says: `unknown` beats `unrecorded`, which beats `exact`. An absence - * has to survive the merge — bytes nobody vouched for do not become vouched-for by being combined - * with bytes that were, and dropping through to the exact branch would hand a later boundary a - * positive claim that neither input made. + * An absence stays unrecorded only when no contributor carries known secrets. Mixing it with + * known secret entries cannot preserve an exact classification or discard those entries into a + * permissive absence; the newly combined bytes must remain unknown. */ export function mergeWorkspaceFileSecretProvenance( ...provenances: readonly WorkspaceFileSecretProvenance[] @@ -130,7 +131,11 @@ export function mergeWorkspaceFileSecretProvenance( return { status: 'unknown' } } if (provenances.some((provenance) => provenance.status === 'unrecorded')) { - return { status: 'unrecorded' } + return provenances.some( + (provenance) => provenance.status === 'exact' && provenance.entries.length > 0 + ) + ? { status: 'unknown' } + : { status: 'unrecorded' } } return { @@ -465,16 +470,17 @@ export async function replaceWorkspaceFileSecretProvenanceInTx( set: { contentUpdatedAt, status, entries: [], updatedAt: new Date() }, }) await markWorkspaceFileSecretProvenanceTrackedInTx(tx, fileId, contentUpdatedAt) + reportDurableSecretProvenanceWrite({ + surface: 'workspace-file', + status, + cause: + status === 'unknown' ? 'workspace-file-write-unknown' : 'workspace-file-write-unrecorded', + resourceId: fileId, + }) } /** * Initializes provenance for an exact file version without replacing an existing classification. - * - * Narrows to the two states the column accepts, as {@link replaceWorkspaceFileSecretProvenanceInTx} - * does. The union has three; the CHECK constraint permits `('exact', 'unknown')`, so forwarding the - * status verbatim would let an `'unrecorded'` reach the database as a value it rejects — a - * constraint violation aborting the enclosing transaction, not a bad row. Nothing passes one today, - * which is exactly why it needs saying here rather than in a caller. */ export async function initializeWorkspaceFileSecretProvenanceInTx( tx: DbTransaction, @@ -485,7 +491,7 @@ export async function initializeWorkspaceFileSecretProvenanceInTx( const isExact = provenance.status === 'exact' const entries = isExact ? serializeExactEntriesForStorage(provenance.entries) : [] const status = isExact ? 'exact' : provenance.status === 'unrecorded' ? 'unrecorded' : 'unknown' - await tx + const inserted = await tx .insert(workspaceFileSecretProvenance) .values({ fileId, @@ -495,7 +501,17 @@ export async function initializeWorkspaceFileSecretProvenanceInTx( updatedAt: new Date(), }) .onConflictDoNothing() + .returning({ fileId: workspaceFileSecretProvenance.fileId }) await markWorkspaceFileSecretProvenanceTrackedInTx(tx, fileId, contentUpdatedAt) + if (status !== 'exact' && inserted.length > 0) { + reportDurableSecretProvenanceWrite({ + surface: 'workspace-file', + status, + cause: + status === 'unknown' ? 'workspace-file-write-unknown' : 'workspace-file-write-unrecorded', + resourceId: fileId, + }) + } } /** Advances an intentionally preserved classification to the file's new content version. */ @@ -848,7 +864,14 @@ function mayReadUnrecordedWorkspaceFile( count = 1, actorUserId?: string ): boolean { - if (isDurableSecretProvenanceEnforced('workspace-file')) return false + if (isDurableSecretProvenanceEnforced('workspace-file')) { + reportDurableSecretProvenanceRefusal({ + surface: 'workspace-file', + cause: 'workspace-file-unrecorded-enforced', + workspaceId, + }) + return false + } if (count > 0) { reportUnrecordedDurableProvenance({ surface: 'workspace-file', @@ -861,6 +884,24 @@ function mayReadUnrecordedWorkspaceFile( return true } +/** Reports the canonical file identity without exposing its storage key or contents. */ +function refuseWorkspaceFileProvenance( + cause: + | 'workspace-file-provenance-unavailable' + | 'workspace-file-opaque-secret-content' + | 'workspace-file-registry-unavailable', + workspaceId: string | undefined, + resourceId?: string +): false { + reportDurableSecretProvenanceRefusal({ + surface: 'workspace-file', + cause, + workspaceId, + resourceId, + }) + return false +} + /** * Authorizes one model-facing view of an exact workspace-file version. Complete text views import * the entire sidecar so representation-changing consumers retain the original lineage. Derived @@ -877,14 +918,34 @@ export async function importWorkspaceFileSecretProvenanceForModelView(args: { actorUserId?: string }): Promise { const provenance = await getBoundWorkspaceFileSecretProvenance(args.workspaceId, args.identity) - if (provenance.status === 'unknown') return false + if (provenance.status === 'unknown') { + return refuseWorkspaceFileProvenance( + 'workspace-file-provenance-unavailable', + args.workspaceId, + args.identity.fileId + ) + } if (provenance.status === 'unrecorded') { return mayReadUnrecordedWorkspaceFile(args.workspaceId, 1, args.actorUserId) } if (provenance.entries.length === 0) return true - if (args.view === 'opaque' || !args.registry) return false + if (args.view === 'opaque' || !args.registry) { + return refuseWorkspaceFileProvenance( + args.view === 'opaque' + ? 'workspace-file-opaque-secret-content' + : 'workspace-file-registry-unavailable', + args.workspaceId, + args.identity.fileId + ) + } - if (args.view === 'derived' && args.value === undefined) return false + if (args.view === 'derived' && args.value === undefined) { + return refuseWorkspaceFileProvenance( + 'workspace-file-provenance-unavailable', + args.workspaceId, + args.identity.fileId + ) + } return importDurableSecretProvenance( args.registry, @@ -899,9 +960,22 @@ export async function isOpaqueWorkspaceFileEgressSafe( identity: WorkspaceFileSecretProvenanceIdentity ): Promise { const provenance = await getBoundWorkspaceFileSecretProvenance(workspaceId, identity) - if (provenance.status === 'unknown') return false + if (provenance.status === 'unknown') { + return refuseWorkspaceFileProvenance( + 'workspace-file-provenance-unavailable', + workspaceId, + identity.fileId + ) + } if (provenance.status === 'unrecorded') return mayReadUnrecordedWorkspaceFile(workspaceId) - return provenance.entries.length === 0 + return ( + provenance.entries.length === 0 || + refuseWorkspaceFileProvenance( + 'workspace-file-opaque-secret-content', + workspaceId, + identity.fileId + ) + ) } /** @@ -917,12 +991,24 @@ export async function importWorkspaceFileSecretProvenanceForRuntime(args: { actorUserId?: string }): Promise { const provenance = await getBoundWorkspaceFileSecretProvenance(args.workspaceId, args.identity) - if (provenance.status === 'unknown') return false + if (provenance.status === 'unknown') { + return refuseWorkspaceFileProvenance( + 'workspace-file-provenance-unavailable', + args.workspaceId, + args.identity.fileId + ) + } if (provenance.status === 'unrecorded') { return mayReadUnrecordedWorkspaceFile(args.workspaceId, 1, args.actorUserId) } if (provenance.entries.length === 0) return true - if (!args.registry) return false + if (!args.registry) { + return refuseWorkspaceFileProvenance( + 'workspace-file-registry-unavailable', + args.workspaceId, + args.identity.fileId + ) + } const imported = await importDurableSecretProvenance(args.registry, provenance) return imported && !args.registry.isPermanentlyIncomplete() @@ -960,6 +1046,7 @@ export async function filterModelSafeWorkspaceFileAttachments< const rowByKey = new Map(rows.map((row) => [row.key, row])) let unrecorded = 0 + let refused = 0 const kept = attachments.filter((attachment) => { if (typeof attachment.key !== 'string' || attachment.key.length === 0) return true const row = rowByKey.get(attachment.key) @@ -967,10 +1054,16 @@ export async function filterModelSafeWorkspaceFileAttachments< if (row.context !== 'workspace' && row.context !== 'mothership') return true const classification = classifyModelSafeWorkspaceFileRow(row, options.workspaceId) if (classification === 'safe') return true - if (classification === 'unsafe') return false + if (classification === 'unsafe') { + refused += 1 + return false + } unrecorded += 1 return !isDurableSecretProvenanceEnforced('workspace-file') }) + if (refused > 0) { + refuseWorkspaceFileProvenance('workspace-file-provenance-unavailable', options.workspaceId) + } /** One report for the whole set of attachments, which is one read, rather than one per file. */ if (unrecorded > 0) { mayReadUnrecordedWorkspaceFile(options.workspaceId, unrecorded, options.actorUserId) @@ -1057,7 +1150,12 @@ export async function areModelSafeWorkspaceFileKeys( for (const row of rows) { if (row.context !== 'workspace' && row.context !== 'mothership') continue const classification = classifyModelSafeWorkspaceFileRow(row, options.workspaceId) - if (classification === 'unsafe') return false + if (classification === 'unsafe') { + return refuseWorkspaceFileProvenance( + 'workspace-file-provenance-unavailable', + options.workspaceId ?? row.workspaceId ?? undefined + ) + } if (classification === 'unrecorded') unrecorded += 1 } /** One report for the batch, not one per key: a caller checking many keys is one read. */