Skip to content

Commit acbbb1d

Browse files
authored
fix(files): refuse an unknown file per read instead of blanking the Chat tool result (#8922)
* fix(files): refuse an unknown file per read instead of blanking the Chat tool result A Chat CLI text read of a file whose secret provenance is unknown imported nothing, marked the invocation's registry permanently incomplete, and still returned the text. The egress projection then withheld the whole tool result, so one tainted file blanked every match of a workspace-wide `grep --scope files`. Forwarded version and bulk reads refused the bytes and also poisoned the registry. Text reads and forwarded responses now refuse just that file before its bytes load (403, "File cannot be sent to a model ..."), so grep reports it as unreadable and returns the clean matches. Direct downloads keep delivering and marking the invocation incomplete, because a workbench copy must carry the unknown classification onto anything derived from it. The delivery observer now receives the canonical file id, so the refusal log names the file it refused. * test(files): expect a per-file refusal for an unknown historical version read * fix(files): attribute each forwarded delivery's refusal to its own file
1 parent 0ef4d61 commit acbbb1d

11 files changed

Lines changed: 238 additions & 102 deletions

‎apps/sim/lib/mothership/agent-cli/file-provenance.test.ts‎

Lines changed: 149 additions & 56 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { queueTableRows, resetDbChainMock } from '@sim/testing'
44
import { createPersonalApiKeyPrincipal } from '@sim/testing/factories/principal.factory'
55
import { encryptionMock, encryptionMockFns } from '@sim/testing/mocks/encryption.mock'
66
import { fileParsersMock, fileParsersMockFns } from '@sim/testing/mocks/file-parsers.mock'
7+
import { getMockLogger } from '@sim/testing/mocks/logger.mock'
78
import {
89
mothershipWorkspaceTargetMock,
910
mothershipWorkspaceTargetMockFns,
@@ -53,6 +54,7 @@ vi.mock('@/lib/mothership/agent-cli/scoped-transport', () => ({
5354
import { createFileReadTransport } from '@/lib/mothership/agent-cli/file-read-transport'
5455
import { inspectToolResultForCopilot } from '@/lib/mothership/request/tools/resolved-secret-result'
5556
import { executeSimCli } from '@/lib/mothership/tools/handlers/sim-cli'
57+
import { reportWorkspaceFileDelivery } from '@/lib/workspace-files/application/file-delivery-observer'
5658
import { readWorkspaceFileArtifact } from '@/lib/workspace-files/application/read-workspace-file-artifact'
5759
import { ResolvedSecretTraceRegistry } from '@/executor/utils/resolved-secret-trace-registry'
5860

@@ -98,17 +100,48 @@ function registry() {
98100
return new ResolvedSecretTraceRegistry([], { userId: 'reader', workspaceId: 'workspace' })
99101
}
100102

101-
function classify(status: 'exact' | 'unknown', fileRevision = revision) {
103+
type SidecarStatus = 'exact' | 'unknown'
104+
105+
function sidecarRow(status: SidecarStatus, fileRevision = revision, entries: unknown[] = []) {
106+
return {
107+
fileContentUpdatedAt: fileRevision,
108+
secretProvenanceVersion: 1,
109+
provenanceContentUpdatedAt: fileRevision,
110+
status,
111+
entries,
112+
}
113+
}
114+
115+
/** Queues one sidecar per status, in the order the reads consume them. */
116+
function classify(...statuses: [SidecarStatus, ...SidecarStatus[]]) {
102117
resetDbChainMock()
103-
queueTableRows(workspaceFiles, [
118+
for (const status of statuses) queueTableRows(workspaceFiles, [sidecarRow(status)])
119+
}
120+
121+
type CliInvocation = Parameters<typeof executeSimCli>[0]['request']['invocation']
122+
123+
const readFile: CliInvocation = {
124+
kind: 'augmentation',
125+
name: 'files read',
126+
positionals: ['file'],
127+
flags: {},
128+
}
129+
130+
function grepFiles(pattern: string): CliInvocation {
131+
return { kind: 'augmentation', name: 'grep', positionals: [pattern], flags: { scope: 'files' } }
132+
}
133+
134+
function runCli(invocation: CliInvocation, trace = registry()) {
135+
return executeSimCli(
136+
{ request: { invocation } },
104137
{
105-
fileContentUpdatedAt: fileRevision,
106-
secretProvenanceVersion: 1,
107-
provenanceContentUpdatedAt: fileRevision,
108-
status,
109-
entries: [],
110-
},
111-
])
138+
userId: 'reader',
139+
workspaceId: 'workspace',
140+
workflowId: '',
141+
chatId: 'chat',
142+
resolvedSecretTraceRegistry: trace,
143+
}
144+
)
112145
}
113146

114147
function readTransport(trace = registry()) {
@@ -278,14 +311,53 @@ describe('file provenance at the actual CLI and model-result boundary', () => {
278311
expect(mocks.stream).toHaveBeenCalledTimes(1)
279312
})
280313

281-
it('withholds extracted text whose canonical revision changed', async () => {
282-
classify('exact', new Date(revision.getTime() + 1))
314+
it('refuses a forwarded response naming its unknown file, without blanking the invocation', async () => {
315+
const refusals = getMockLogger('DurableSecretProvenancePersistence').error
316+
refusals.mockClear()
317+
const trace = registry()
318+
const transport = createFileReadTransport({
319+
endpoint: 'https://sim.test',
320+
userId: 'reader',
321+
registry: trace,
322+
invocation: { userId: 'reader', workspaceId: 'workspace', chatId: 'chat' },
323+
transport: async () => {
324+
await reportWorkspaceFileDelivery({ status: 'exact', entries: [] }, 'clean')
325+
await reportWorkspaceFileDelivery({ status: 'unknown' }, 'tainted')
326+
return new Response(content)
327+
},
328+
})
329+
const response = await transport(
330+
new Request('https://sim.test/api/v2/files/bulk-download?workspaceId=workspace')
331+
)
332+
expect(response.status).toBe(403)
333+
expect(await response.text()).not.toContain(content)
334+
expect(trace.isPermanentlyIncomplete()).toBe(false)
335+
expect(refusals).toHaveBeenCalledTimes(1)
336+
expect(refusals).toHaveBeenCalledWith(
337+
'Refusing unavailable durable secret provenance',
338+
expect.objectContaining({ resourceId: 'tainted' })
339+
)
340+
})
341+
342+
it('refuses extracted text whose canonical revision changed before loading its bytes', async () => {
343+
resetDbChainMock()
344+
queueTableRows(workspaceFiles, [sidecarRow('exact', new Date(revision.getTime() + 1))])
283345
const trace = registry()
284346
const response = await readTransport(trace)(fileRequest('/text'))
347+
expect(response.status).toBe(403)
348+
expect(await response.text()).not.toContain(content)
349+
expect(mocks.buffer).not.toHaveBeenCalled()
350+
expect(trace.isPermanentlyIncomplete()).toBe(false)
351+
})
352+
353+
it('still withholds a delivered text read once the registry is incomplete for another reason', async () => {
354+
classify('exact')
355+
const trace = registry()
356+
trace.markIncomplete('provenance-capacity-exceeded')
357+
const response = await readTransport(trace)(fileRequest('/text'))
285358
expect(response.status).toBe(200)
286359
const output = await response.text()
287360
expect(output).toContain(content)
288-
expect(trace.isPermanentlyIncomplete()).toBe(true)
289361
expect(
290362
JSON.stringify(
291363
inspectToolResultForCopilot({ success: true, output }, trace, 'sim_cli').result
@@ -366,62 +438,83 @@ describe('file provenance at the actual CLI and model-result boundary', () => {
366438
classify('unknown')
367439
const grep = async () => {
368440
const trace = registry()
369-
const result = await executeSimCli(
370-
{
371-
request: {
372-
invocation: {
373-
kind: 'augmentation',
374-
name: 'grep',
375-
positionals: [content],
376-
flags: { scope: 'files' },
377-
},
378-
},
379-
},
380-
{
381-
userId: 'reader',
382-
workspaceId: 'workspace',
383-
workflowId: '',
384-
chatId: 'chat',
385-
resolvedSecretTraceRegistry: trace,
386-
}
441+
const result = await runCli(grepFiles(content), trace)
442+
expect(result.error).toContain('Search incomplete')
443+
expect(JSON.stringify(result.output)).toContain(
444+
'files/private.txt (file): could not read text'
387445
)
388-
expect(result.success).toBe(true)
389-
expect(JSON.stringify(result.output)).toContain(content)
390-
expect(
391-
JSON.stringify(inspectToolResultForCopilot(result, trace, 'sim_cli').result)
392-
).not.toContain(content)
446+
expect(JSON.stringify(result.output)).toContain('No matches')
447+
expect(trace.isPermanentlyIncomplete()).toBe(false)
393448
}
394449
if (mode === 'parallel') await Promise.all([grep(), grep()])
395450
else {
396451
await grep()
397452
await grep()
398453
}
399-
expect(mocks.buffer).toHaveBeenCalledTimes(2)
454+
expect(mocks.buffer).not.toHaveBeenCalled()
400455
}
401456
)
402457

403-
it('carries unified text reads through the actual CLI transport and secret projection', async () => {
404-
classify('unknown')
458+
it('returns clean grep matches while refusing only the file whose provenance is unknown', async () => {
459+
const tainted = { ...file, id: 'tainted', key: 'workspace/workspace/tainted-key' }
460+
const taintedText = 'TAINTED_FILE_CANARY_FOR_LOCAL_TEST'
461+
const byId = (id: string) => (id === tainted.id ? tainted : file)
462+
mocks.file.mockImplementation(async (_workspaceId: string, id: string) => byId(id))
463+
mocks.reference.mockImplementation(async (_workspaceId: string, id: string) => byId(id))
464+
mocks.context.mockImplementation(async (id: string) => ({
465+
workspaceId: 'workspace',
466+
fileId: id,
467+
workspaceOrganizationId: null,
468+
allowPersonalApiKeys: true,
469+
billedAccountUserId: 'owner',
470+
}))
471+
mocks.buffer.mockImplementation(async (record: { id: string }) =>
472+
Buffer.from(record.id === tainted.id ? taintedText : content)
473+
)
474+
vi.stubGlobal('fetch', async () =>
475+
Response.json({
476+
data: [file, tainted].map((record) => ({ ...record, updatedAt: revision.toISOString() })),
477+
nextCursor: null,
478+
})
479+
)
480+
classify('exact', 'unknown')
481+
const refusals = getMockLogger('DurableSecretProvenancePersistence').error
482+
refusals.mockClear()
405483
const trace = registry()
406-
const result = await executeSimCli(
407-
{
408-
request: {
409-
invocation: {
410-
kind: 'augmentation',
411-
name: 'files read',
412-
positionals: ['file'],
413-
flags: {},
414-
},
415-
},
416-
},
417-
{
418-
userId: 'reader',
419-
workspaceId: 'workspace',
420-
workflowId: '',
421-
chatId: 'chat',
422-
resolvedSecretTraceRegistry: trace,
423-
}
484+
const result = await runCli(grepFiles('CANARY_FOR_LOCAL_TEST'), trace)
485+
const projected = JSON.stringify(inspectToolResultForCopilot(result, trace, 'sim_cli').result)
486+
expect(trace.isPermanentlyIncomplete()).toBe(false)
487+
expect(projected).not.toContain(taintedText)
488+
expect(projected).toContain(content)
489+
expect(mocks.buffer).toHaveBeenCalledTimes(1)
490+
expect(refusals).toHaveBeenCalledTimes(1)
491+
expect(refusals).toHaveBeenCalledWith(
492+
'Refusing unavailable durable secret provenance',
493+
expect.objectContaining({
494+
cause: 'workspace-file-provenance-unavailable',
495+
resourceId: tainted.id,
496+
})
424497
)
498+
})
499+
500+
it('refuses an unknown file through the actual CLI text read without loading its bytes', async () => {
501+
classify('unknown')
502+
const result = await runCli(readFile)
503+
expect(result.success).toBe(false)
504+
expect(JSON.stringify(result.output)).not.toContain(content)
505+
expect(JSON.stringify(result)).toContain('secret provenance is unavailable')
506+
expect(mocks.buffer).not.toHaveBeenCalled()
507+
})
508+
509+
it('carries unified text reads through the actual CLI transport and secret projection', async () => {
510+
resetDbChainMock()
511+
queueTableRows(workspaceFiles, [
512+
sidecarRow('exact', revision, [
513+
{ name: 'FILE_SECRET', encryptedValue: 'fixture-ciphertext', sourceUserId: 'reader' },
514+
]),
515+
])
516+
const trace = registry()
517+
const result = await runCli(readFile, trace)
425518
expect(result.success).toBe(true)
426519
expect(JSON.stringify(result.output)).toContain(content)
427520
expect(JSON.stringify(result.output)).toContain('representation')

‎apps/sim/lib/mothership/agent-cli/file-read-transport.ts‎

Lines changed: 48 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -13,12 +13,14 @@ import {
1313
import { readV2CredentialHeaders } from '@/lib/api/server/routes/v2-credential-headers'
1414
import { V2_PARSE_DEFAULTS } from '@/lib/api/server/routes/v2-json-route'
1515
import { parseRequest } from '@/lib/api/server/validation'
16+
import { OrchestrationError } from '@/lib/core/orchestration/types'
1617
import {
1718
type CopilotChatDelegationContext,
1819
createCopilotChatPrincipal,
1920
} from '@/lib/mothership/auth/application-delegation'
2021
import {
2122
importWorkspaceFileSnapshotProvenance,
23+
MODEL_UNSAFE_WORKSPACE_FILE_ERROR_MESSAGE,
2224
mergeWorkspaceFileSecretProvenance,
2325
type WorkspaceFileSecretProvenance,
2426
} from '@/lib/uploads/contexts/workspace/workspace-file-secret-provenance'
@@ -52,18 +54,33 @@ export function createFileReadTransport(context: {
5254
`${basePath}${v2ListFileFoldersContract.path}`,
5355
`${basePath}${v2BulkDownloadFilesContract.path}`,
5456
])
55-
const observe = async (workspaceId: string, provenance?: WorkspaceFileSecretProvenance) => {
56-
const imported =
57-
provenance &&
58-
(await importWorkspaceFileSnapshotProvenance({
59-
workspaceId,
60-
provenance,
61-
registry: context.registry,
62-
actorUserId: context.userId,
63-
}))
64-
if (!imported) context.registry?.markIncomplete('workspace-file-provenance-unknown')
65-
return imported
66-
}
57+
/**
58+
* Admits one delivery's classification into the invocation registry. A delivery it refuses is
59+
* either withheld (text and forwarded responses reach only the model, so refusing them is enough)
60+
* or, for a direct download whose bytes can be persisted, delivered with the whole invocation
61+
* marked incomplete so anything derived from the copy keeps the unknown classification.
62+
*/
63+
const admit = async (
64+
workspaceId: string,
65+
provenance: WorkspaceFileSecretProvenance | undefined,
66+
resourceId?: string
67+
): Promise<boolean> =>
68+
provenance !== undefined &&
69+
(await importWorkspaceFileSnapshotProvenance({
70+
workspaceId,
71+
provenance,
72+
registry: context.registry,
73+
actorUserId: context.userId,
74+
resourceId,
75+
}))
76+
const refused = () =>
77+
v2FileErrorPolicies.concealResourceAuthorization.render(
78+
new OrchestrationError('forbidden', MODEL_UNSAFE_WORKSPACE_FILE_ERROR_MESSAGE)
79+
) ??
80+
Response.json(
81+
{ error: { message: MODEL_UNSAFE_WORKSPACE_FILE_ERROR_MESSAGE } },
82+
{ status: 403 }
83+
)
6784

6885
const forward: typeof fetch = async (input, init) => {
6986
const url = new URL(input instanceof Request ? input.url : input)
@@ -72,8 +89,10 @@ export function createFileReadTransport(context: {
7289
return (context.transport ?? fetch)(input, init)
7390
}
7491
let provenance: WorkspaceFileSecretProvenance | undefined
92+
const deliveries: { source: WorkspaceFileSecretProvenance | undefined; fileId: string }[] = []
7593
const response = await observeWorkspaceFileDelivery(
76-
async (source) => {
94+
async (source, fileId) => {
95+
deliveries.push({ source, fileId })
7796
provenance = mergeWorkspaceFileSecretProvenance(
7897
provenance ?? { status: 'exact', entries: [] },
7998
source ?? { status: 'unknown' }
@@ -83,13 +102,20 @@ export function createFileReadTransport(context: {
83102
)
84103
if (!provenance) return response
85104
try {
86-
if (!context.registry || !(await observe(context.invocation.workspaceId, provenance))) {
105+
if (!context.registry) {
87106
await response.body?.cancel().catch(() => {})
88107
return Response.json(
89108
{ error: { message: 'File read provenance is unavailable. Retry the read.' } },
90109
{ status: 503 }
91110
)
92111
}
112+
/** Each delivery is admitted under its own file id, so a refusal names the file it refused. */
113+
for (const { source, fileId } of deliveries) {
114+
if (!(await admit(context.invocation.workspaceId, source, fileId))) {
115+
await response.body?.cancel().catch(() => {})
116+
return refused()
117+
}
118+
}
93119
if (response.body) context.trackDownload?.(response.body, provenance)
94120
return response
95121
} catch (error) {
@@ -143,8 +169,10 @@ export function createFileReadTransport(context: {
143169
if (!parsed.success) return parsed.response
144170
request.signal.throwIfAborted()
145171
const result = await observeWorkspaceFileDelivery(
146-
async (source) => {
147-
await observe(parsed.data.query.workspaceId, source)
172+
async (source, fileId) => {
173+
if (!(await admit(parsed.data.query.workspaceId, source, fileId))) {
174+
throw new OrchestrationError('forbidden', MODEL_UNSAFE_WORKSPACE_FILE_ERROR_MESSAGE)
175+
}
148176
},
149177
() =>
150178
readWorkspaceFileText.execute({
@@ -173,8 +201,10 @@ export function createFileReadTransport(context: {
173201
if (!parsed.success) return parsed.response
174202
request.signal.throwIfAborted()
175203
const result = await observeWorkspaceFileDelivery(
176-
async (source) => {
177-
await observe(parsed.data.query.workspaceId, source)
204+
async (source, fileId) => {
205+
if (!(await admit(parsed.data.query.workspaceId, source, fileId))) {
206+
context.registry?.markIncomplete('workspace-file-provenance-unknown')
207+
}
178208
},
179209
() =>
180210
downloadWorkspaceFileStream.execute({

0 commit comments

Comments
 (0)