Skip to content

Commit ad171b7

Browse files
fix(tables): reject stale sandbox snapshots
1 parent d866849 commit ad171b7

4 files changed

Lines changed: 52 additions & 32 deletions

File tree

apps/sim/lib/copilot/tools/handlers/function-execute.test.ts

Lines changed: 21 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@ const {
3131
mockMaterializeCopilotCodeSecrets,
3232
mockHasWorkspaceSandboxAccess,
3333
mockImportWorkspaceFileSecretProvenanceForRuntime,
34-
mockIsTableSnapshotSafeForModelMount,
34+
mockGetTableSnapshotModelMountSafety,
3535
} = vi.hoisted(() => ({
3636
mockGetTableById: vi.fn(),
3737
mockListTables: vi.fn(),
@@ -53,7 +53,7 @@ const {
5353
mockMaterializeCopilotCodeSecrets: vi.fn(),
5454
mockHasWorkspaceSandboxAccess: vi.fn(),
5555
mockImportWorkspaceFileSecretProvenanceForRuntime: vi.fn(),
56-
mockIsTableSnapshotSafeForModelMount: vi.fn(),
56+
mockGetTableSnapshotModelMountSafety: vi.fn(),
5757
}))
5858

5959
vi.mock('@/lib/core/security/encryption', () => encryptionMock)
@@ -62,7 +62,7 @@ vi.mock('@/lib/table/service', () => ({
6262
listTables: mockListTables,
6363
}))
6464
vi.mock('@/lib/table/rows/secret-provenance', () => ({
65-
isTableSnapshotSafeForModelMount: mockIsTableSnapshotSafeForModelMount,
65+
getTableSnapshotModelMountSafety: mockGetTableSnapshotModelMountSafety,
6666
}))
6767
vi.mock('@/lib/table/snapshot-cache', () => ({
6868
getOrCreateTableSnapshot: mockGetOrCreateTableSnapshot,
@@ -146,8 +146,8 @@ function resetExecutionMocks(): void {
146146
vi.clearAllMocks()
147147
mockExecuteTool.mockReset()
148148
mockMaterializeCopilotCodeSecrets.mockReset()
149-
mockIsTableSnapshotSafeForModelMount.mockReset()
150-
mockIsTableSnapshotSafeForModelMount.mockResolvedValue(true)
149+
mockGetTableSnapshotModelMountSafety.mockReset()
150+
mockGetTableSnapshotModelMountSafety.mockResolvedValue('safe')
151151
mockListWorkspaceFiles.mockResolvedValue([])
152152
mockListWorkspaceFileFolders.mockResolvedValue([])
153153
mockListAllWorkspaceFiles.mockImplementation(async () => {
@@ -609,7 +609,7 @@ describe('executeFunctionExecute table mounts', () => {
609609
})
610610

611611
it('unknown snapshot provenance still mounts and taints model egress', async () => {
612-
mockIsTableSnapshotSafeForModelMount.mockResolvedValue(false)
612+
mockGetTableSnapshotModelMountSafety.mockResolvedValue('unsafe-provenance')
613613
mockGetOrCreateTableSnapshot.mockResolvedValue({
614614
key: 'table-snapshots/ws_1/tbl_1/v5.csv',
615615
size: 9,
@@ -637,6 +637,21 @@ describe('executeFunctionExecute table mounts', () => {
637637
expect(projectToolResultForCopilot(result, parentRegistry)).toEqual({ success: true })
638638
})
639639

640+
it('rejects a snapshot that becomes stale before mounting', async () => {
641+
mockGetTableSnapshotModelMountSafety.mockResolvedValue('stale')
642+
mockGetOrCreateTableSnapshot.mockResolvedValue({
643+
key: 'table-snapshots/ws_1/tbl_1/v5.csv',
644+
size: 9,
645+
version: 5,
646+
})
647+
648+
await expect(
649+
executeFunctionExecute({ inputTables: ['tbl_1'] }, context as never)
650+
).rejects.toThrow(/changed while preparing its snapshot/)
651+
expect(mockGeneratePresignedDownloadUrl).not.toHaveBeenCalled()
652+
expect(mockExecuteTool).not.toHaveBeenCalled()
653+
})
654+
640655
it('throws when a cloud snapshot exceeds the table mount limit', async () => {
641656
mockGetOrCreateTableSnapshot.mockResolvedValue({
642657
key: 'table-snapshots/ws_1/tbl_1/v5.csv',

apps/sim/lib/copilot/tools/handlers/function-execute.ts

Lines changed: 10 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ import {
1919
} from '@/lib/execution/private-tool-metadata'
2020
import { MAX_PLAN_REQUIRED } from '@/lib/execution/remote-sandbox/workspace-sandboxes'
2121
import { recordSecretUsage } from '@/lib/secrets/usage/record'
22-
import { isTableSnapshotSafeForModelMount } from '@/lib/table/rows/secret-provenance'
22+
import { getTableSnapshotModelMountSafety } from '@/lib/table/rows/secret-provenance'
2323
import { getTableById, listTables } from '@/lib/table/service'
2424
import { getOrCreateTableSnapshot, SNAPSHOT_MAX_BYTES } from '@/lib/table/snapshot-cache'
2525
import {
@@ -484,15 +484,15 @@ export async function resolveInputFiles(
484484
`Input table "${tableId}" cannot be mounted because its secret provenance is unavailable.`
485485
)
486486
}
487-
try {
488-
const safeForModelMount = await isTableSnapshotSafeForModelMount({
489-
tableId: table.id,
490-
workspaceId,
491-
rowsVersion: snapshot.version,
492-
})
493-
if (!safeForModelMount)
494-
resolvedSecretTraceRegistry.markIncomplete('table-snapshot-unsafe-for-mount')
495-
} catch {
487+
const mountSafety = await getTableSnapshotModelMountSafety({
488+
tableId: table.id,
489+
workspaceId,
490+
rowsVersion: snapshot.version,
491+
})
492+
if (mountSafety === 'stale') {
493+
throw new Error(`Input table "${tableId}" changed while preparing its snapshot. Retry.`)
494+
}
495+
if (mountSafety === 'unsafe-provenance') {
496496
resolvedSecretTraceRegistry.markIncomplete('table-snapshot-unsafe-for-mount')
497497
}
498498

apps/sim/lib/table/rows/secret-provenance.test.ts

Lines changed: 10 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ vi.mock('@/lib/execution/durable-secret-provenance-enforcement', () => ({
2020
import type { DbTransaction } from '@/lib/table/planner'
2121
import {
2222
classifyTableRowSecretProvenanceForCopy,
23-
isTableSnapshotSafeForModelMount,
23+
getTableSnapshotModelMountSafety,
2424
loadTableRowSecretProvenance,
2525
mutateTableRowsWithSecretProvenance,
2626
updateTableRowsWithDerivedSecretProvenance,
@@ -67,30 +67,31 @@ describe('table row secret provenance', () => {
6767
queueTableRows(userTableRows, [])
6868

6969
await expect(
70-
isTableSnapshotSafeForModelMount({
70+
getTableSnapshotModelMountSafety({
7171
tableId: 'table-1',
7272
workspaceId: 'workspace-1',
7373
rowsVersion: 7,
7474
})
75-
).resolves.toBe(true)
75+
).resolves.toBe('safe')
7676

7777
expect(dbChainMockFns.limit).toHaveBeenCalledTimes(3)
7878
expect(dbChainMockFns.orderBy).not.toHaveBeenCalled()
7979
})
8080

81-
it('rejects the snapshot as soon as an unsafe row exists', async () => {
81+
it('classifies unsafe provenance after confirming the snapshot remains current', async () => {
8282
queueTableRows(userTableDefinitions, [{ rowsVersion: 7 }])
8383
queueTableRows(userTableRows, [{ id: 'unsafe-row' }])
84+
queueTableRows(userTableDefinitions, [{ rowsVersion: 7 }])
8485

8586
await expect(
86-
isTableSnapshotSafeForModelMount({
87+
getTableSnapshotModelMountSafety({
8788
tableId: 'table-1',
8889
workspaceId: 'workspace-1',
8990
rowsVersion: 7,
9091
})
91-
).resolves.toBe(false)
92+
).resolves.toBe('unsafe-provenance')
9293

93-
expect(dbChainMockFns.limit).toHaveBeenCalledTimes(2)
94+
expect(dbChainMockFns.limit).toHaveBeenCalledTimes(3)
9495
})
9596

9697
it('rejects a snapshot when the table changes during the safety check', async () => {
@@ -99,12 +100,12 @@ describe('table row secret provenance', () => {
99100
queueTableRows(userTableRows, [])
100101

101102
await expect(
102-
isTableSnapshotSafeForModelMount({
103+
getTableSnapshotModelMountSafety({
103104
tableId: 'table-1',
104105
workspaceId: 'workspace-1',
105106
rowsVersion: 7,
106107
})
107-
).resolves.toBe(false)
108+
).resolves.toBe('stale')
108109
})
109110

110111
it('keeps untouched legacy rows readable with exact-empty provenance', async () => {

apps/sim/lib/table/rows/secret-provenance.ts

Lines changed: 11 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -705,17 +705,19 @@ async function readTableRowsVersion(tableId: string, workspaceId: string): Promi
705705
return table?.rowsVersion ?? null
706706
}
707707

708+
export type TableSnapshotModelMountSafety = 'safe' | 'unsafe-provenance' | 'stale'
709+
708710
/**
709-
* Verifies that a version-pinned table contains no secret-bearing or unknown
710-
* cells before its opaque CSV bytes cross into a model-controlled sandbox.
711+
* Classifies whether a version-pinned table snapshot can cross into a model-controlled sandbox.
712+
* A version change takes precedence over provenance because stale bytes must never be mounted.
711713
*/
712-
export async function isTableSnapshotSafeForModelMount(options: {
714+
export async function getTableSnapshotModelMountSafety(options: {
713715
tableId: string
714716
workspaceId: string
715717
rowsVersion: number
716-
}): Promise<boolean> {
718+
}): Promise<TableSnapshotModelMountSafety> {
717719
if ((await readTableRowsVersion(options.tableId, options.workspaceId)) !== options.rowsVersion) {
718-
return false
720+
return 'stale'
719721
}
720722

721723
const [unsafeRow] = await db
@@ -748,9 +750,11 @@ export async function isTableSnapshotSafeForModelMount(options: {
748750
)
749751
.limit(1)
750752

751-
if (unsafeRow) return false
753+
if ((await readTableRowsVersion(options.tableId, options.workspaceId)) !== options.rowsVersion) {
754+
return 'stale'
755+
}
752756

753-
return (await readTableRowsVersion(options.tableId, options.workspaceId)) === options.rowsVersion
757+
return unsafeRow ? 'unsafe-provenance' : 'safe'
754758
}
755759

756760
/**

0 commit comments

Comments
 (0)