Skip to content

Commit 2cbc59e

Browse files
fix(files): align workbench reads with provenance policy (#8553)
* fix(files): align workbench reads with provenance policy * fix(files): validate snapshots and audit completed admissions * fix(files): audit completed workbench transfers
1 parent 4436f82 commit 2cbc59e

36 files changed

Lines changed: 2016 additions & 640 deletions

‎apps/sim/lib/execution/durable-secret-provenance-telemetry.ts‎

Lines changed: 60 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,10 @@
1+
import {
2+
AuditAction,
3+
type AuditLogParams,
4+
AuditResourceType,
5+
recordAudit,
6+
recordAuditBatch,
7+
} from '@sim/audit'
18
import { createLogger } from '@sim/logger'
29

310
const persistenceLogger = createLogger('DurableSecretProvenancePersistence')
@@ -28,7 +35,6 @@ export type DurableSecretProvenanceRefusalCause =
2835
| 'workspace-file-provenance-unavailable'
2936
| 'workspace-file-opaque-secret-content'
3037
| 'workspace-file-registry-unavailable'
31-
| 'workspace-file-unrecorded-enforced'
3238

3339
export interface DurableSecretProvenanceRefusalReport {
3440
surface: DurableSecretProvenanceSurface
@@ -65,3 +71,56 @@ export function reportDurableSecretProvenanceRefusal(
6571
...(report.resourceId ? { resourceId: report.resourceId } : {}),
6672
})
6773
}
74+
75+
export interface DurableSecretProvenanceUnrecordedReport {
76+
surface: DurableSecretProvenanceSurface
77+
workspaceId?: string
78+
organizationId?: string
79+
resourceId?: string
80+
recordCount?: number
81+
actorUserId?: string
82+
}
83+
84+
function unrecordedProvenanceAuditEntry(
85+
report: DurableSecretProvenanceUnrecordedReport
86+
): AuditLogParams | undefined {
87+
const metadata = {
88+
surface: report.surface,
89+
...(report.organizationId ? { organizationId: report.organizationId } : {}),
90+
...(report.recordCount !== undefined ? { recordCount: report.recordCount } : {}),
91+
}
92+
persistenceLogger.warn('Using content without recorded secret provenance', {
93+
...metadata,
94+
...(report.workspaceId ? { workspaceId: report.workspaceId } : {}),
95+
...(report.resourceId ? { resourceId: report.resourceId } : {}),
96+
})
97+
if (!report.workspaceId && !report.organizationId) return undefined
98+
return {
99+
workspaceId: report.organizationId ? null : report.workspaceId,
100+
actorId: report.actorUserId ?? null,
101+
action: AuditAction.SECRET_PROVENANCE_UNRECORDED,
102+
resourceType: AuditResourceType.SECRET_PROVENANCE,
103+
...(report.resourceId ? { resourceId: report.resourceId } : {}),
104+
description: 'Used content without recorded secret provenance',
105+
metadata,
106+
}
107+
}
108+
109+
/** Records accepted content whose producer did not supply provenance, without recording bytes. */
110+
export function reportDurableSecretProvenanceUnrecorded(
111+
report: DurableSecretProvenanceUnrecordedReport
112+
): void {
113+
const entry = unrecordedProvenanceAuditEntry(report)
114+
if (entry) recordAudit(entry)
115+
}
116+
117+
/** Batches tenant-scoped admission events without one pooled database query per source. */
118+
export function reportDurableSecretProvenanceUnrecordedBatch(
119+
reports: readonly DurableSecretProvenanceUnrecordedReport[]
120+
): void {
121+
const entries = reports.flatMap((report) => {
122+
const entry = unrecordedProvenanceAuditEntry(report)
123+
return entry ? [entry] : []
124+
})
125+
if (entries.length > 0) recordAuditBatch(entries)
126+
}

‎apps/sim/lib/function-execution/execute-request.test.ts‎

Lines changed: 68 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -2264,11 +2264,13 @@ describe('Function execution request', () => {
22642264
)
22652265

22662266
it.each([
2267-
{ reason: 'source-provenance-incomplete', status: 200 },
2268-
{ reason: 'entry-decrypt-failed', status: 400 },
2267+
{ reason: 'source-provenance-incomplete', status: 200, knownMount: false, text: false },
2268+
{ reason: 'source-provenance-incomplete', status: 200, knownMount: true, text: false },
2269+
{ reason: 'source-provenance-incomplete', status: 200, knownMount: true, text: true },
2270+
{ reason: 'entry-decrypt-failed', status: 400, knownMount: false, text: false },
22692271
] as const)(
2270-
'distinguishes historical absence from provenance faults: $reason',
2271-
async ({ reason, status }) => {
2272+
'distinguishes historical absence from provenance faults: $reason knownMount=$knownMount text=$text',
2273+
async ({ reason, status, knownMount, text }) => {
22722274
envFlagsMock.isRemoteSandboxEnabled = true
22732275
const registry = new ResolvedSecretTraceRegistry([], {
22742276
userId: 'user-123',
@@ -2278,7 +2280,7 @@ describe('Function execution request', () => {
22782280
const contentUpdatedAt = new Date('2026-01-01T00:00:00Z')
22792281
mockMountContributors.mockReturnValue([
22802282
{
2281-
fileId: 'legacy-file',
2283+
fileId: knownMount ? 'known-file' : 'legacy-file',
22822284
key: 'execution/workspace-1/workflow-1/execution-1/a/input.txt',
22832285
context: 'execution',
22842286
contentUpdatedAt,
@@ -2287,25 +2289,38 @@ describe('Function execution request', () => {
22872289
dbChainMockFns.limit.mockResolvedValue([
22882290
{
22892291
fileContentUpdatedAt: contentUpdatedAt,
2290-
secretProvenanceVersion: null,
2291-
provenanceContentUpdatedAt: null,
2292-
status: null,
2293-
entries: null,
2292+
secretProvenanceVersion: knownMount ? 1 : null,
2293+
provenanceContentUpdatedAt: knownMount ? contentUpdatedAt : null,
2294+
status: knownMount ? 'exact' : null,
2295+
entries: knownMount
2296+
? [
2297+
{
2298+
name: 'API_KEY',
2299+
encryptedValue: 'encrypted:mounted-secret',
2300+
sourceUserId: 'user-123',
2301+
sourceWorkspaceId: 'workspace-1',
2302+
},
2303+
]
2304+
: null,
22942305
},
22952306
])
2296-
const buffer = Buffer.from('ordinary file')
2297-
mockExecuteInSandbox.mockResolvedValueOnce({
2307+
const buffer = Buffer.from(text ? 'Bearer mounted-secret' : 'ordinary file')
2308+
mockExecuteInSandbox.mockResolvedValue({
22982309
result: null,
22992310
stdout: '',
23002311
sandboxId: 'sbx',
2301-
collectedFiles: [
2302-
{
2303-
relativePath: 'report.zip',
2304-
path: '/tmp/sim/outputs/report.zip',
2305-
contentBase64: buffer.toString('base64'),
2306-
byteLength: buffer.length,
2307-
},
2308-
],
2312+
...(text
2313+
? { exportedFiles: { '/home/user/report.txt': buffer.toString('utf8') } }
2314+
: {
2315+
collectedFiles: [
2316+
{
2317+
relativePath: 'report.zip',
2318+
path: '/tmp/sim/outputs/report.zip',
2319+
contentBase64: buffer.toString('base64'),
2320+
byteLength: buffer.length,
2321+
},
2322+
],
2323+
}),
23092324
})
23102325
const response = await POST(
23112326
createMockRequest('POST', {
@@ -2314,13 +2329,42 @@ describe('Function execution request', () => {
23142329
workspaceId: 'workspace-1',
23152330
workflowId: 'workflow-1',
23162331
executionId: 'execution-1',
2332+
...(text
2333+
? {
2334+
outputs: {
2335+
files: [
2336+
{
2337+
path: 'files/report.txt',
2338+
sandboxPath: '/home/user/report.txt',
2339+
mimeType: 'text/plain',
2340+
},
2341+
],
2342+
},
2343+
}
2344+
: {}),
23172345
}),
23182346
registry
23192347
)
23202348
expect(response.status).toBe(status)
2321-
if (status === 200)
2322-
expect(mockUploadExecutionFile.mock.calls[0][5]).toEqual({ status: 'unrecorded' })
2323-
else expect(mockUploadExecutionFile).not.toHaveBeenCalled()
2349+
if (status === 200) {
2350+
if (text) {
2351+
expect(mockWriteWorkspaceFileByPath.mock.calls[0][0].secretProvenance).toEqual({
2352+
status: 'exact',
2353+
entries: [
2354+
{
2355+
name: 'API_KEY',
2356+
encryptedValue: 'encrypted:mounted-secret',
2357+
sourceUserId: 'user-123',
2358+
sourceWorkspaceId: 'workspace-1',
2359+
},
2360+
],
2361+
})
2362+
} else {
2363+
expect(mockUploadExecutionFile.mock.calls[0][5]).toEqual({
2364+
status: knownMount ? 'unknown' : 'unrecorded',
2365+
})
2366+
}
2367+
} else expect(mockUploadExecutionFile).not.toHaveBeenCalled()
23242368
}
23252369
)
23262370

@@ -2567,7 +2611,7 @@ describe('Function execution request', () => {
25672611
it.each([
25682612
['a mount with no provenance source', true],
25692613
['no mounts', false],
2570-
] as const)('withholds workbench certification for %s', async (_label, mounted) => {
2614+
] as const)('keeps workbench use available for %s', async (_label, mounted) => {
25712615
envFlagsMock.isMothershipSandboxEnabled = true
25722616
mockUnprovenancedMountCount.mockReturnValue(mounted ? 1 : 0)
25732617
hybridAuthMockFns.mockCheckInternalAuth.mockResolvedValue({
@@ -2588,7 +2632,7 @@ describe('Function execution request', () => {
25882632
expect(response.status).toBe(200)
25892633
const session = mockExecuteInSandbox.mock.calls.at(-1)?.[0].session
25902634
expect(session.key).toBe('chat-session')
2591-
expect(session.unprovenancedInputs === true).toBe(mounted)
2635+
expect(session.unprovenancedInputs).not.toBe(true)
25922636
})
25932637

25942638
it('gives overlapping calls in one persistent workbench distinct automatic export directories', async () => {

‎apps/sim/lib/function-execution/execute-request.ts‎

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1089,6 +1089,10 @@ async function importRuntimeInputProvenance(
10891089
})
10901090
if (decision.safe && decision.provenance.status === 'unrecorded') {
10911091
context.runtimeInputProvenanceUnrecorded = true
1092+
context.runtimeFileSecretTraceRegistry = new ResolvedSecretTraceRegistry([], {
1093+
userId: context.attributedUserId,
1094+
workspaceId: context.workspaceId,
1095+
})
10921096
return
10931097
}
10941098
}
@@ -2718,6 +2722,9 @@ export async function executeFunctionRequest(
27182722
logger,
27192723
},
27202724
})
2725+
if (resolvedMounts.unprovenancedMountCount > 0) {
2726+
routeContext.runtimeInputProvenanceUnrecorded = true
2727+
}
27212728
await importRuntimeFileContributors(
27222729
routeContext,
27232730
resolvedMounts.contributingFiles,
@@ -2747,7 +2754,6 @@ export async function executeFunctionRequest(
27472754
const mothershipSession = admittedSession
27482755
? {
27492756
...admittedSession,
2750-
unprovenancedInputs: resolvedMounts.unprovenancedMountCount > 0,
27512757
inputProvenance: () => {
27522758
const runtime = activeRouteContext.runtimeFileSecretTraceRegistry?.exportProvenance()
27532759
return mergeDurableSecretProvenance(

‎apps/sim/lib/function-execution/sandbox-mounts.ts‎

Lines changed: 10 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { createLogger } from '@sim/logger'
2+
import { reportDurableSecretProvenanceUnrecorded } from '@/lib/execution/durable-secret-provenance-telemetry'
23
import { resolveStoredFileProvenanceSource } from '@/lib/execution/payloads/file-secret-provenance'
34
import {
45
assertUserFileContentAccess,
@@ -277,15 +278,7 @@ export async function resolveUserFileMounts(args: {
277278
manifest: SandboxMountManifestEntry[]
278279
contributingFiles?: readonly WorkspaceFileSecretProvenanceIdentity[]
279280
renderedContributingFiles?: readonly WorkspaceFileSecretProvenanceIdentity[]
280-
/**
281-
* Mounts whose own bytes have no provenance source (no principal to bind one, or a key with no
282-
* canonical metadata record). Workflow runs keep their legacy absence policy; a persistent
283-
* workbench must not certify a machine that received one.
284-
*
285-
* Storage contexts other than workspace and execution (chat, copilot, knowledge-base, logs, and
286-
* the other public contexts) never have a source, so they always count here and taint a
287-
* workbench. That is conservative by design.
288-
*/
281+
/** Mounts without producer provenance remain usable and report the recording gap. */
289282
unprovenancedMountCount: number
290283
}> {
291284
let unprovenancedMountCount = 0
@@ -365,6 +358,14 @@ export async function resolveUserFileMounts(args: {
365358
})
366359
}
367360

361+
if (unprovenancedMountCount > 0) {
362+
reportDurableSecretProvenanceUnrecorded({
363+
surface: 'workspace-file',
364+
workspaceId: args.context.workspaceId,
365+
actorUserId: args.context.userId,
366+
recordCount: unprovenancedMountCount,
367+
})
368+
}
368369
logger.info('Resolved sandbox file mounts', {
369370
mountCount: sandboxFiles.length,
370371
bufferedBytes: budget.buffered,

‎apps/sim/lib/internal/file/operations.provenance.test.ts‎

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,8 @@ vi.mock('@/lib/workspaces/permissions/utils', () => permissionsMock)
8989
vi.mock('@/app/api/files/authorization', () => filesAuthorizationMock)
9090

9191
vi.mock('@/lib/execution/durable-secret-provenance-telemetry', () => ({
92+
reportDurableSecretProvenanceUnrecorded: vi.fn(),
93+
reportDurableSecretProvenanceUnrecordedBatch: vi.fn(),
9294
reportDurableSecretProvenanceWrite: vi.fn(),
9395
reportDurableSecretProvenanceRefusal: vi.fn(),
9496
}))
@@ -237,7 +239,7 @@ describe('appended file provenance', () => {
237239
})
238240

239241
it.each([
240-
{ predecessor: 'unrecorded', secret: true, expectedStatus: 'unknown' },
242+
{ predecessor: 'unrecorded', secret: true, expectedStatus: 'exact' },
241243
{ predecessor: 'exact', secret: true, expectedStatus: 'exact' },
242244
{ predecessor: 'legacy', secret: true, expectedStatus: 'exact' },
243245
{ predecessor: 'unrecorded', secret: false, expectedStatus: 'unrecorded' },
@@ -315,7 +317,7 @@ describe('appended file provenance', () => {
315317
view: 'complete',
316318
value: `before:${content}`,
317319
})
318-
expect(permitted).toBe(expectedStatus === 'exact')
320+
expect(permitted).toBe(expectedStatus !== 'unknown')
319321
if (permitted) {
320322
expect(projectResolvedSecretModelContent(`before:${content}`, registry)).toEqual({
321323
safe: true,
@@ -326,7 +328,7 @@ describe('appended file provenance', () => {
326328
joinedRow(persisted.status, persisted.entries, persisted.contentUpdatedAt),
327329
])
328330
expect(await isOpaqueWorkspaceFileEgressSafe('workspace-1', IDENTITY)).toBe(
329-
expectedStatus === 'exact' && !secret
331+
expectedStatus !== 'unknown' && !secret
330332
)
331333
}
332334
)
@@ -352,7 +354,7 @@ describe('execution-file content provenance', () => {
352354

353355
it.each([
354356
{ status: 'exact', version: 1, stale: false, complete: true },
355-
{ status: 'unrecorded', version: 1, stale: false, complete: false },
357+
{ status: 'unrecorded', version: 1, stale: false, complete: true },
356358
{ status: 'unknown', version: 1, stale: false, complete: false },
357359
{ status: 'unknown', version: null, stale: false, complete: true },
358360
{ status: 'exact', version: 1, stale: true, complete: false },

‎apps/sim/lib/internal/file/operations.ts‎

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import { isPayloadSizeLimitError } from '@/lib/core/utils/stream-limits'
1414
import { ensureAbsoluteUrl } from '@/lib/core/utils/urls'
1515
import { isUserFile } from '@/lib/core/utils/user-file'
1616
import { durableSecretProvenanceFromPrivateBundle } from '@/lib/execution/durable-secret-provenance'
17+
import { reportDurableSecretProvenanceUnrecorded } from '@/lib/execution/durable-secret-provenance-telemetry'
1718
import {
1819
inspectPrivateSecretProvenanceRequest,
1920
isPrivateSecretProvenanceBundleV1,
@@ -596,6 +597,7 @@ export async function getFileContentProvenance(
596597
? { userId: ownerUserId, workspaceId }
597598
: undefined
598599
const accumulator = new ResolvedSecretTraceProvenanceAccumulator(scope)
600+
let unrecorded = 0
599601

600602
for (const source of sources) {
601603
signal?.throwIfAborted()
@@ -605,7 +607,11 @@ export async function getFileContentProvenance(
605607
}
606608
const provenance = await readFileSourceSecretProvenance(principal, workspaceId, source.identity)
607609
signal?.throwIfAborted()
608-
if (provenance.status !== 'exact' || (source.opaque && provenance.entries.length > 0)) {
610+
if (provenance.status === 'unrecorded') {
611+
unrecorded += 1
612+
continue
613+
}
614+
if (provenance.status === 'unknown' || (source.opaque && provenance.entries.length > 0)) {
609615
accumulator.markIncomplete('workspace-file-provenance-unknown')
610616
continue
611617
}
@@ -625,6 +631,13 @@ export async function getFileContentProvenance(
625631
})
626632
}
627633

634+
if (unrecorded > 0) {
635+
reportDurableSecretProvenanceUnrecorded({
636+
surface: 'workspace-file',
637+
workspaceId,
638+
recordCount: unrecorded,
639+
})
640+
}
628641
return accumulator.exportProvenance()
629642
}
630643

0 commit comments

Comments
 (0)