Skip to content

Commit 377702f

Browse files
fix(uploads): manual file uploads must ignore provenance stamping (#6314)
* fix(attachments): model egress attachments * fix(uploads): manual uploads provenance ignore * further checks on kb * tags fixes * fix more stuff * fix tests * address comments * fix * fix * fix * address timestamp concern
1 parent e0a464d commit 377702f

27 files changed

Lines changed: 1587 additions & 396 deletions

apps/sim/app/api/knowledge/[id]/documents/[documentId]/tag-definitions/route.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import {
1111
createOrUpdateTagDefinitionsBulk,
1212
deleteAllTagDefinitions,
1313
getDocumentTagDefinitions,
14+
KnowledgeTagProvenanceConflictError,
1415
} from '@/lib/knowledge/tags/service'
1516
import type { BulkTagDefinitionsData } from '@/lib/knowledge/tags/types'
1617
import { checkDocumentAccess, checkDocumentWriteAccess } from '@/app/api/knowledge/utils'
@@ -198,6 +199,9 @@ export const DELETE = withRouteHandler(
198199
data: { deleted: deletedCount },
199200
})
200201
} catch (error) {
202+
if (error instanceof KnowledgeTagProvenanceConflictError) {
203+
return NextResponse.json({ error: error.message }, { status: 409 })
204+
}
201205
logger.error(`[${requestId}] Error with tag definitions operation`, error)
202206
return NextResponse.json({ error: 'Failed to process tag definitions' }, { status: 500 })
203207
}

apps/sim/app/api/knowledge/[id]/tag-definitions/[tagId]/route.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,10 @@ import { deleteTagDefinitionContract } from '@/lib/api/contracts/knowledge'
55
import { parseRequest } from '@/lib/api/server'
66
import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid'
77
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
8-
import { deleteTagDefinition } from '@/lib/knowledge/tags/service'
8+
import {
9+
deleteTagDefinition,
10+
KnowledgeTagProvenanceConflictError,
11+
} from '@/lib/knowledge/tags/service'
912
import { checkKnowledgeBaseWriteAccess } from '@/app/api/knowledge/utils'
1013

1114
export const dynamic = 'force-dynamic'
@@ -45,6 +48,9 @@ export const DELETE = withRouteHandler(
4548
message: `Tag definition "${deletedTag.displayName}" deleted successfully`,
4649
})
4750
} catch (error) {
51+
if (error instanceof KnowledgeTagProvenanceConflictError) {
52+
return NextResponse.json({ error: error.message }, { status: 409 })
53+
}
4854
logger.error(`[${requestId}] Error deleting tag definition`, error)
4955
return NextResponse.json({ error: 'Failed to delete tag definition' }, { status: 500 })
5056
}

apps/sim/app/api/knowledge/secret-provenance.test.ts

Lines changed: 150 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -10,25 +10,171 @@ import {
1010
PRIVATE_SECRET_PROVENANCE_FIELD,
1111
PRIVATE_SECRET_PROVENANCE_HEADER,
1212
} from '@/lib/execution/private-tool-metadata'
13-
import { resolveKnowledgeWriteSecretProvenance } from '@/app/api/knowledge/secret-provenance'
13+
import {
14+
resolveKnowledgeDocumentWriteSecretProvenance,
15+
resolveKnowledgeWriteSecretProvenance,
16+
} from '@/app/api/knowledge/secret-provenance'
17+
18+
const PRIVATE_PROVENANCE_SCOPE = {
19+
userId: 'user-1',
20+
workspaceId: 'workspace-1',
21+
} as const
1422

15-
function createRequest(payload: Record<string, unknown>): NextRequest {
23+
function createRequest(
24+
payload: Record<string, unknown>,
25+
provenanceHeader = PRIVATE_SECRET_PROVENANCE_BUNDLE_V1
26+
): NextRequest {
27+
return new NextRequest('http://localhost/api/knowledge/kb/documents', {
28+
method: 'POST',
29+
headers: { [PRIVATE_SECRET_PROVENANCE_HEADER]: provenanceHeader },
30+
body: JSON.stringify(payload),
31+
})
32+
}
33+
34+
function createHeaderlessRequest(payload: Record<string, unknown>): NextRequest {
1635
return new NextRequest('http://localhost/api/knowledge/kb/documents', {
1736
method: 'POST',
18-
headers: { [PRIVATE_SECRET_PROVENANCE_HEADER]: PRIVATE_SECRET_PROVENANCE_BUNDLE_V1 },
1937
body: JSON.stringify(payload),
2038
})
2139
}
2240

2341
describe('knowledge write secret provenance', () => {
42+
it('classifies a headerless external chunk write as exact-empty', () => {
43+
const payload = { content: 'manual content' }
44+
45+
const result = resolveKnowledgeWriteSecretProvenance({
46+
request: createHeaderlessRequest(payload),
47+
payload,
48+
authType: AuthType.API_KEY,
49+
userId: 'user-1',
50+
workspaceId: 'workspace-1',
51+
selectionKeys: ['chunk-content'],
52+
})
53+
54+
expect(result).toEqual({
55+
success: true,
56+
provenances: [{ status: 'exact', entries: [] }],
57+
})
58+
})
59+
60+
it('classifies a headerless external document write as exact-empty', () => {
61+
const payload = {
62+
filename: 'manual.txt',
63+
}
64+
65+
const result = resolveKnowledgeDocumentWriteSecretProvenance({
66+
request: createHeaderlessRequest(payload),
67+
payload,
68+
authType: AuthType.SESSION,
69+
userId: 'user-1',
70+
workspaceId: 'workspace-1',
71+
documents: [payload],
72+
})
73+
74+
expect(result).toEqual({
75+
success: true,
76+
provenances: [
77+
{
78+
filename: { status: 'exact', entries: [] },
79+
content: { status: 'exact', entries: [] },
80+
tags: [],
81+
},
82+
],
83+
})
84+
})
85+
86+
it('does not track durable provenance for a legacy headerless internal write', () => {
87+
const payload = { content: 'legacy workflow content' }
88+
89+
const result = resolveKnowledgeWriteSecretProvenance({
90+
request: createHeaderlessRequest(payload),
91+
payload,
92+
authType: AuthType.INTERNAL_JWT,
93+
userId: 'user-1',
94+
workspaceId: 'workspace-1',
95+
selectionKeys: ['chunk-content'],
96+
})
97+
98+
expect(result).toEqual({ success: true })
99+
})
100+
101+
it('tracks exact-empty provenance only when an internal write supplies a verified envelope', () => {
102+
const bundle = {
103+
version: 1 as const,
104+
complete: true,
105+
selections: [
106+
{
107+
key: 'chunk-content',
108+
provenance: {
109+
version: 1 as const,
110+
complete: true,
111+
entries: [],
112+
scope: PRIVATE_PROVENANCE_SCOPE,
113+
},
114+
},
115+
],
116+
}
117+
const payload = { content: 'workflow content', [PRIVATE_SECRET_PROVENANCE_FIELD]: bundle }
118+
119+
const result = resolveKnowledgeWriteSecretProvenance({
120+
request: createRequest(payload),
121+
payload,
122+
authType: AuthType.INTERNAL_JWT,
123+
userId: 'user-1',
124+
workspaceId: 'workspace-1',
125+
selectionKeys: ['chunk-content'],
126+
})
127+
128+
expect(result).toEqual({
129+
success: true,
130+
provenances: [{ status: 'exact', entries: [] }],
131+
})
132+
})
133+
134+
it('rejects a private provenance envelope from an external caller', () => {
135+
const bundle = {
136+
version: 1 as const,
137+
complete: true,
138+
selections: [
139+
{
140+
key: 'chunk-content',
141+
provenance: {
142+
version: 1 as const,
143+
complete: true,
144+
entries: [],
145+
scope: PRIVATE_PROVENANCE_SCOPE,
146+
},
147+
},
148+
],
149+
}
150+
const payload = { content: 'external content', [PRIVATE_SECRET_PROVENANCE_FIELD]: bundle }
151+
152+
const result = resolveKnowledgeWriteSecretProvenance({
153+
request: createRequest(payload),
154+
payload,
155+
authType: AuthType.API_KEY,
156+
userId: 'user-1',
157+
workspaceId: 'workspace-1',
158+
selectionKeys: ['chunk-content'],
159+
})
160+
161+
expect(result.success).toBe(false)
162+
if (!result.success) expect(result.response.status).toBe(400)
163+
})
164+
24165
it('rejects an unavailable verified selection before a write can start', () => {
25166
const bundle = {
26167
version: 1 as const,
27168
complete: true,
28169
selections: [
29170
{
30171
key: 'document-source:0',
31-
provenance: { version: 1 as const, complete: false, entries: [] },
172+
provenance: {
173+
version: 1 as const,
174+
complete: false,
175+
entries: [],
176+
scope: PRIVATE_PROVENANCE_SCOPE,
177+
},
32178
},
33179
],
34180
}

apps/sim/executor/handlers/agent/agent-handler.test.ts

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import {
1818
vi,
1919
} from 'vitest'
2020
import type { AutoRoutingSignals } from '@/lib/model-router/resolve'
21+
import * as userFileBase64 from '@/lib/uploads/utils/user-file-base64.server'
2122
import { getAllBlocks } from '@/blocks'
2223
import { AGENT, BlockType, isMcpTool } from '@/executor/constants'
2324
import { AgentBlockHandler } from '@/executor/handlers/agent/agent-handler'
@@ -488,6 +489,47 @@ describe('AgentBlockHandler', () => {
488489
})
489490
})
490491

492+
it('normalizes the persisted workspace-picker shape before provider execution', async () => {
493+
const key = 'workspace/ws-1/example.png'
494+
const hydrationSpy = vi
495+
.spyOn(userFileBase64, 'hydrateUserFilesWithBase64')
496+
.mockImplementationOnce(async (files) =>
497+
files.map((file) => ({ ...file, base64: 'aW1hZ2U=' }))
498+
)
499+
500+
try {
501+
mockGetProviderFromModel.mockReturnValue('openai')
502+
503+
await handler.execute(mockContext, mockBlock, {
504+
model: 'gpt-4o',
505+
userPrompt: 'Analyze this file',
506+
files: [
507+
{
508+
name: 'example.png',
509+
path: `/api/files/serve/${encodeURIComponent(key)}?context=workspace`,
510+
key,
511+
size: 128,
512+
type: 'image/png',
513+
},
514+
],
515+
apiKey: 'test-api-key',
516+
})
517+
518+
const normalizedFile = hydrationSpy.mock.calls[0][0][0]
519+
expect(normalizedFile).toMatchObject({
520+
id: expect.stringMatching(/^file-\d+$/),
521+
key,
522+
name: 'example.png',
523+
type: 'image/png',
524+
})
525+
expect(mockExecuteProviderRequest.mock.calls[0][1].messages.at(-1)?.files).toEqual([
526+
expect.objectContaining({ key, name: 'example.png', base64: 'aW1hZ2U=' }),
527+
])
528+
} finally {
529+
hydrationSpy.mockRestore()
530+
}
531+
})
532+
491533
it('should reject files for providers without attachment support', async () => {
492534
const inputs = {
493535
model: 'deepseek-chat',

apps/sim/lib/copilot/request/lifecycle/run.test.ts

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -496,14 +496,10 @@ describe('runCopilotLifecycle', () => {
496496
expect(JSON.parse(capturedRequestBody).fileAttachments).toEqual([safe])
497497
})
498498

499-
it('continues without attachments when durable provenance cannot be verified', async () => {
499+
it('rejects when durable attachment provenance cannot be verified', async () => {
500500
mockFilterModelSafeWorkspaceFileAttachments.mockRejectedValueOnce(new Error('db unavailable'))
501-
let capturedRequestBody = ''
502-
mockRunStreamLoop.mockImplementationOnce(async (_url: string, request: RequestInit) => {
503-
capturedRequestBody = String(request.body)
504-
})
505501

506-
await runCopilotLifecycle(
502+
const result = await runCopilotLifecycle(
507503
{
508504
message: 'Continue safely',
509505
fileAttachments: [{ id: 'wf-file', name: 'file.txt', key: 'workspace/ws-1/file.txt' }],
@@ -517,7 +513,11 @@ describe('runCopilotLifecycle', () => {
517513
}
518514
)
519515

520-
expect(JSON.parse(capturedRequestBody)).not.toHaveProperty('fileAttachments')
516+
expect(result).toMatchObject({
517+
success: false,
518+
error: 'Copilot model input could not be safely projected',
519+
})
520+
expect(mockRunStreamLoop).not.toHaveBeenCalled()
521521
})
522522

523523
it.each(['123', 'true'])(

apps/sim/lib/copilot/request/lifecycle/run.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -607,11 +607,11 @@ async function omitUnsafeInitialCopilotAttachments(
607607
try {
608608
safeAttachments = await filterModelSafeWorkspaceFileAttachments(attachments, { workspaceId })
609609
} catch (error) {
610-
logger.warn('Workspace file secret provenance could not be verified; omitting attachments', {
610+
logger.error('Workspace file secret provenance could not be verified', {
611611
attachmentCount: attachments.length,
612612
error: toError(error).message,
613613
})
614-
safeAttachments = []
614+
throw new CopilotModelContentProjectionError()
615615
}
616616

617617
if (safeAttachments.length === attachments.length) continue

apps/sim/lib/execution/sandbox/bundles/docx.cjs

Lines changed: 21 additions & 21 deletions
Large diffs are not rendered by default.

apps/sim/lib/execution/sandbox/bundles/pdf-lib.cjs

Lines changed: 21 additions & 21 deletions
Large diffs are not rendered by default.

apps/sim/lib/execution/sandbox/bundles/pptxgenjs.cjs

Lines changed: 63 additions & 62 deletions
Large diffs are not rendered by default.

0 commit comments

Comments
 (0)