fix(tables): prevent truncated sandbox mounts (#7075)

* fix(tables): prevent truncated sandbox mounts

* fix(tables): snapshot every sandbox mount

* fix(tables): cap aggregate snapshot mounts

* fix(tables): reject stale sandbox snapshots
This commit is contained in:
Theodore Li
2026-08-25 18:18:16 -04:00
committed by GitHub
parent 8de7ada5c6
commit 470946dd7f
6 changed files with 110 additions and 270 deletions
@@ -11,10 +11,8 @@ import {
} from '@/lib/execution/private-tool-metadata'
const {
mockIsFeatureEnabled,
mockGetTableById,
mockListTables,
mockQueryRows,
mockGetOrCreateTableSnapshot,
mockDownloadFile,
mockGeneratePresignedDownloadUrl,
@@ -33,13 +31,10 @@ const {
mockMaterializeCopilotCodeSecrets,
mockHasWorkspaceSandboxAccess,
mockImportWorkspaceFileSecretProvenanceForRuntime,
mockLoadTableRowSecretProvenance,
mockIsTableSnapshotSafeForModelMount,
mockGetTableSnapshotModelMountSafety,
} = vi.hoisted(() => ({
mockIsFeatureEnabled: vi.fn(),
mockGetTableById: vi.fn(),
mockListTables: vi.fn(),
mockQueryRows: vi.fn(),
mockGetOrCreateTableSnapshot: vi.fn(),
mockDownloadFile: vi.fn(),
mockGeneratePresignedDownloadUrl: vi.fn(),
@@ -58,20 +53,16 @@ const {
mockMaterializeCopilotCodeSecrets: vi.fn(),
mockHasWorkspaceSandboxAccess: vi.fn(),
mockImportWorkspaceFileSecretProvenanceForRuntime: vi.fn(),
mockLoadTableRowSecretProvenance: vi.fn(),
mockIsTableSnapshotSafeForModelMount: vi.fn(),
mockGetTableSnapshotModelMountSafety: vi.fn(),
}))
vi.mock('@/lib/core/config/feature-flags', () => ({ isFeatureEnabled: mockIsFeatureEnabled }))
vi.mock('@/lib/core/security/encryption', () => encryptionMock)
vi.mock('@/lib/table/service', () => ({
getTableById: mockGetTableById,
listTables: mockListTables,
}))
vi.mock('@/lib/table/rows/service', () => ({ queryRows: mockQueryRows }))
vi.mock('@/lib/table/rows/secret-provenance', () => ({
isTableSnapshotSafeForModelMount: mockIsTableSnapshotSafeForModelMount,
loadTableRowSecretProvenance: mockLoadTableRowSecretProvenance,
getTableSnapshotModelMountSafety: mockGetTableSnapshotModelMountSafety,
}))
vi.mock('@/lib/table/snapshot-cache', () => ({
getOrCreateTableSnapshot: mockGetOrCreateTableSnapshot,
@@ -133,7 +124,7 @@ import { ResolvedSecretTraceRegistry } from '@/executor/utils/resolved-secret-tr
const table = {
id: 'tbl_1',
workspaceId: 'ws_1',
rowCount: 1000,
rowCount: 1,
schema: { columns: [{ id: 'col_name', name: 'name', type: 'string' }] },
}
@@ -151,20 +142,12 @@ function mountedFiles() {
return params._sandboxFiles ?? []
}
const snapshotCacheOn = (flag: string) => Promise.resolve(flag === 'table-snapshot-cache')
function resetExecutionMocks(): void {
vi.clearAllMocks()
mockExecuteTool.mockReset()
mockMaterializeCopilotCodeSecrets.mockReset()
mockLoadTableRowSecretProvenance.mockReset()
mockIsTableSnapshotSafeForModelMount.mockReset()
mockLoadTableRowSecretProvenance.mockResolvedValue({
version: 1,
complete: true,
entries: [],
})
mockIsTableSnapshotSafeForModelMount.mockResolvedValue(true)
mockGetTableSnapshotModelMountSafety.mockReset()
mockGetTableSnapshotModelMountSafety.mockResolvedValue('safe')
mockListWorkspaceFiles.mockResolvedValue([])
mockListWorkspaceFileFolders.mockResolvedValue([])
mockListAllWorkspaceFiles.mockImplementation(async () => {
@@ -576,72 +559,12 @@ describe('executeFunctionExecute table mounts', () => {
resetExecutionMocks()
mockExecuteTool.mockResolvedValue({ success: true })
mockGetTableById.mockResolvedValue(table)
mockIsFeatureEnabled.mockResolvedValue(false)
// Row data is keyed by stable column id at rest, not display name.
mockQueryRows.mockResolvedValue({ rows: [{ data: { col_name: 'Ada' } }] })
mockHasCloudStorage.mockReturnValue(true)
mockGeneratePresignedDownloadUrl.mockResolvedValue('https://s3.example/presigned?sig=abc')
})
it('flag OFF: drains the table inline via queryRows (existing path)', async () => {
await executeFunctionExecute({ inputTables: ['tbl_1'] }, context as never)
expect(mockQueryRows).toHaveBeenCalledTimes(1)
expect(mockGetOrCreateTableSnapshot).not.toHaveBeenCalled()
const files = mountedFiles()
expect(files[0].path).toBe('/home/user/tables/tbl_1.csv')
expect(files[0].content).toBe('name\nAda')
})
it('mounts CSV with display-name headers and id-keyed values, never column ids', async () => {
mockGetTableById.mockResolvedValue({
id: 'tbl_2',
workspaceId: 'ws_1',
rowCount: 2,
schema: {
columns: [
{ id: 'col_name', name: 'name', type: 'string' },
{ id: 'col_company', name: 'company', type: 'string' },
],
},
})
mockQueryRows.mockResolvedValue({
rows: [
{ data: { col_name: 'Ada', col_company: 'Analytical Engine' } },
{ data: { col_name: 'Grace', col_company: 'Navy, Inc' } },
],
})
await executeFunctionExecute({ inputTables: ['tbl_2'] }, context as never)
const csv = mountedFiles()[0].content as string
const lines = csv.split('\n')
expect(lines[0]).toBe('name,company')
expect(lines[1]).toBe('Ada,Analytical Engine')
// Value containing a comma is quoted.
expect(lines[2]).toBe('Grace,"Navy, Inc"')
// No stable column id leaks into the mounted file.
expect(csv).not.toContain('col_name')
expect(csv).not.toContain('col_company')
})
it('reads values by column id for legacy name-keyed rows too', async () => {
// Legacy column with no id: getColumnId falls back to name, so name-keyed data is correct.
mockGetTableById.mockResolvedValue({
id: 'tbl_legacy',
workspaceId: 'ws_1',
rowCount: 1,
schema: { columns: [{ name: 'email', type: 'string' }] },
})
mockQueryRows.mockResolvedValue({ rows: [{ data: { email: 'a@b.com' } }] })
await executeFunctionExecute({ inputTables: ['tbl_legacy'] }, context as never)
expect(mountedFiles()[0].content).toBe('email\na@b.com')
})
it('flag ON + cloud storage: mounts by presigned URL, no bytes through web', async () => {
mockIsFeatureEnabled.mockImplementation(snapshotCacheOn)
it('mounts every table by presigned snapshot URL', async () => {
mockGetTableById.mockResolvedValue({ ...table, rowCount: 0 })
mockGetOrCreateTableSnapshot.mockResolvedValue({
key: 'table-snapshots/ws_1/tbl_1/v5.csv',
size: 9,
@@ -651,7 +574,6 @@ describe('executeFunctionExecute table mounts', () => {
await executeFunctionExecute({ inputTables: ['tbl_1'] }, context as never)
expect(mockGetOrCreateTableSnapshot).toHaveBeenCalledTimes(1)
expect(mockQueryRows).not.toHaveBeenCalled()
expect(mockDownloadFile).not.toHaveBeenCalled()
expect(mockGeneratePresignedDownloadUrl).toHaveBeenCalledWith(
'table-snapshots/ws_1/tbl_1/v5.csv',
@@ -665,8 +587,7 @@ describe('executeFunctionExecute table mounts', () => {
})
})
it('flag ON + local storage: falls back to a buffered content mount', async () => {
mockIsFeatureEnabled.mockImplementation(snapshotCacheOn)
it('mounts a complete snapshot through a bounded buffer with local storage', async () => {
mockHasCloudStorage.mockReturnValue(false)
mockGetOrCreateTableSnapshot.mockResolvedValue({
key: 'table-snapshots/ws_1/tbl_1/v5.csv',
@@ -687,9 +608,8 @@ describe('executeFunctionExecute table mounts', () => {
expect(file.type).toBeUndefined()
})
it('flag ON + unknown snapshot provenance still mounts and taints model egress', async () => {
mockIsFeatureEnabled.mockImplementation(snapshotCacheOn)
mockIsTableSnapshotSafeForModelMount.mockResolvedValue(false)
it('unknown snapshot provenance still mounts and taints model egress', async () => {
mockGetTableSnapshotModelMountSafety.mockResolvedValue('unsafe-provenance')
mockGetOrCreateTableSnapshot.mockResolvedValue({
key: 'table-snapshots/ws_1/tbl_1/v5.csv',
size: 9,
@@ -717,46 +637,22 @@ describe('executeFunctionExecute table mounts', () => {
expect(projectToolResultForCopilot(result, parentRegistry)).toEqual({ success: true })
})
it('flag OFF + unknown row provenance still mounts and taints model egress', async () => {
mockLoadTableRowSecretProvenance.mockResolvedValue({
version: 1,
complete: false,
entries: [],
})
mockExecuteTool.mockResolvedValue({ success: true, output: { result: 'raw output' } })
const parentRegistry = new ResolvedSecretTraceRegistry([], {
userId: 'u1',
workspaceId: 'ws_1',
it('rejects a snapshot that becomes stale before mounting', async () => {
mockGetTableSnapshotModelMountSafety.mockResolvedValue('stale')
mockGetOrCreateTableSnapshot.mockResolvedValue({
key: 'table-snapshots/ws_1/tbl_1/v5.csv',
size: 9,
version: 5,
})
const result = await executeFunctionExecute(
{ inputTables: ['tbl_1'] },
{ ...context, resolvedSecretTraceRegistry: parentRegistry }
)
expect(mountedFiles()[0].content).toBe('name\nAda')
expect(mockExecuteTool.mock.calls[0]?.[1]?.[PRIVATE_SECRET_PROVENANCE_FIELD]).toEqual({
version: 1,
complete: false,
selections: [],
})
expect(result).toEqual({ success: true, output: { result: 'raw output' } })
expect(parentRegistry.isComplete()).toBe(false)
expect(projectToolResultForCopilot(result, parentRegistry)).toEqual({ success: true })
await expect(
executeFunctionExecute({ inputTables: ['tbl_1'] }, context as never)
).rejects.toThrow(/changed while preparing its snapshot/)
expect(mockGeneratePresignedDownloadUrl).not.toHaveBeenCalled()
expect(mockExecuteTool).not.toHaveBeenCalled()
})
it('flag ON but small table stays on the inline path', async () => {
mockIsFeatureEnabled.mockImplementation(snapshotCacheOn)
mockGetTableById.mockResolvedValue({ ...table, rowCount: 10 })
await executeFunctionExecute({ inputTables: ['tbl_1'] }, context as never)
expect(mockGetOrCreateTableSnapshot).not.toHaveBeenCalled()
expect(mockQueryRows).toHaveBeenCalledTimes(1)
})
it('flag ON + cloud: throws when the snapshot exceeds the table mount limit', async () => {
mockIsFeatureEnabled.mockImplementation(snapshotCacheOn)
it('throws when a cloud snapshot exceeds the table mount limit', async () => {
mockGetOrCreateTableSnapshot.mockResolvedValue({
key: 'table-snapshots/ws_1/tbl_1/v5.csv',
size: 600 * 1024 * 1024,
@@ -769,8 +665,22 @@ describe('executeFunctionExecute table mounts', () => {
expect(mockGeneratePresignedDownloadUrl).not.toHaveBeenCalled()
})
it('flag ON + local: throws when the snapshot exceeds the per-file mount limit', async () => {
mockIsFeatureEnabled.mockImplementation(snapshotCacheOn)
it('throws when cloud snapshots exceed the aggregate URL mount limit', async () => {
mockGetTableById.mockImplementation(async (tableId: string) => ({ ...table, id: tableId }))
mockGetOrCreateTableSnapshot.mockImplementation(async (mountedTable: typeof table) => ({
key: `table-snapshots/ws_1/${mountedTable.id}/v5.csv`,
size: 500 * 1024 * 1024,
version: 5,
}))
const tableIds = Array.from({ length: 5 }, (_, index) => `tbl_${index}`)
await expect(
executeFunctionExecute({ inputTables: tableIds }, context as never)
).rejects.toThrow(/total mount limit/)
expect(mockGeneratePresignedDownloadUrl).toHaveBeenCalledTimes(4)
})
it('throws when a local snapshot exceeds the per-file mount limit', async () => {
mockHasCloudStorage.mockReturnValue(false)
mockGetOrCreateTableSnapshot.mockResolvedValue({
key: 'table-snapshots/ws_1/tbl_1/v5.csv',
@@ -809,7 +719,6 @@ describe('executeFunctionExecute file mounts', () => {
beforeEach(() => {
resetExecutionMocks()
mockExecuteTool.mockResolvedValue({ success: true })
mockIsFeatureEnabled.mockResolvedValue(false)
mockHasCloudStorage.mockReturnValue(true)
mockGeneratePresignedDownloadUrl.mockResolvedValue('https://s3.example/file?sig=abc')
mockListWorkspaceFiles.mockResolvedValue([fileRecord])
@@ -1167,7 +1076,6 @@ describe('executeFunctionExecute unmountable namespaces', () => {
beforeEach(() => {
resetExecutionMocks()
mockExecuteTool.mockResolvedValue({ success: true })
mockIsFeatureEnabled.mockResolvedValue(false)
mockHasCloudStorage.mockReturnValue(true)
mockListWorkspaceFiles.mockResolvedValue([])
mockFindWorkspaceFileRecord.mockReturnValue(null)
@@ -11,8 +11,6 @@ import {
materializeCopilotCodeSecrets,
} from '@/lib/copilot/tools/secret-mount-materializer.server'
import { decodeVfsPathSegments, encodeVfsPathSegments } from '@/lib/copilot/vfs/path-utils'
import { isFeatureEnabled } from '@/lib/core/config/feature-flags'
import { neutralizeCsvFormula, toCsvRow } from '@/lib/core/utils/csv'
import { isPayloadSizeLimitError } from '@/lib/core/utils/stream-limits'
import type { PrivateSecretProvenanceBundleV1 } from '@/lib/execution/model-input-provenance'
import {
@@ -21,14 +19,7 @@ import {
} from '@/lib/execution/private-tool-metadata'
import { MAX_PLAN_REQUIRED } from '@/lib/execution/remote-sandbox/workspace-sandboxes'
import { recordSecretUsage } from '@/lib/secrets/usage/record'
import { getColumnId } from '@/lib/table/column-keys'
import { TABLE_LIMITS } from '@/lib/table/constants'
import { formatCsvCell } from '@/lib/table/export-format'
import {
isTableSnapshotSafeForModelMount,
loadTableRowSecretProvenance,
} from '@/lib/table/rows/secret-provenance'
import { queryRows } from '@/lib/table/rows/service'
import { getTableSnapshotModelMountSafety } from '@/lib/table/rows/secret-provenance'
import { getTableById, listTables } from '@/lib/table/service'
import { getOrCreateTableSnapshot, SNAPSHOT_MAX_BYTES } from '@/lib/table/snapshot-cache'
import {
@@ -62,13 +53,6 @@ const MAX_FILE_SIZE = 10 * 1024 * 1024
const MAX_TOTAL_SIZE = 50 * 1024 * 1024
const MAX_MOUNTED_FILES = 500
/**
* Below this row count a table mounts via the direct inline CSV path — the version-keyed snapshot
* cache (storage round-trip) only pays off for larger/hot tables. Behind the feature flag either
* way; this just keeps tiny one-shot tables on the cheaper path.
*/
const SNAPSHOT_MIN_ROWS = 500
/**
* Lifetime of a presigned URL handed to the sandbox to fetch a mounted object (table snapshot or
* workspace file). Long enough to download a large file at sandbox startup; the URL grants read to
@@ -318,7 +302,6 @@ export async function resolveInputFiles(
inputFiles?: unknown[],
inputTables?: unknown[],
inputDirectories?: unknown[],
provenanceUserId?: string,
resolvedSecretTraceRegistry?: ResolvedSecretTraceRegistry,
filePrincipal?: Principal
): Promise<SandboxFile[]> {
@@ -475,7 +458,6 @@ export async function resolveInputFiles(
const tablePathLookup = hasTablePathRefs
? new Map((await listTables(workspaceId)).map((table) => [table.name, table]))
: undefined
const snapshotCacheEnabled = await isFeatureEnabled('table-snapshot-cache')
for (const tableRef of inputTables) {
const tableId =
typeof tableRef === 'string'
@@ -496,109 +478,64 @@ export async function resolveInputFiles(
: undefined
const mountPath = sandboxPath || `/home/user/tables/${table.id}.csv`
// Large/hot tables mount by reference from a version-keyed CSV snapshot in object storage.
if (snapshotCacheEnabled && table.rowCount >= SNAPSHOT_MIN_ROWS) {
const snapshot = await getOrCreateTableSnapshot(table, 'copilot-fn-exec')
if (!resolvedSecretTraceRegistry) {
throw new Error(
`Input table "${tableId}" cannot be mounted because its secret provenance is unavailable.`
)
}
try {
const safeForModelMount = await isTableSnapshotSafeForModelMount({
tableId: table.id,
workspaceId,
rowsVersion: snapshot.version,
})
if (!safeForModelMount)
resolvedSecretTraceRegistry.markIncomplete('table-snapshot-unsafe-for-mount')
} catch {
resolvedSecretTraceRegistry.markIncomplete('table-snapshot-unsafe-for-mount')
}
if (hasCloudStorage()) {
// Mount by reference: the sandbox fetches the snapshot straight from storage via a
// presigned URL, so the bytes never pass through the web process — the only ceiling is
// sandbox disk (enforced at materialization by SNAPSHOT_MAX_BYTES).
if (snapshot.size > SNAPSHOT_MAX_BYTES) {
throw new Error(
`Input table "${tableId}" is ${Math.round(snapshot.size / 1024 / 1024)}MB, over the ${SNAPSHOT_MAX_BYTES / 1024 / 1024}MB table mount limit.`
)
}
const url = await generatePresignedDownloadUrl(
snapshot.key,
'execution',
MOUNT_URL_TTL_SECONDS
)
sandboxFiles.push({ type: 'url', path: mountPath, url })
continue
}
// Local storage: a presigned URL is an app-internal serve path a remote sandbox can't
// reach, so fall back to buffering the bytes through the web process (file-mount guards).
if (snapshot.size > MAX_FILE_SIZE) {
throw new Error(
`Input table "${tableId}" is ${Math.round(snapshot.size / 1024 / 1024)}MB, over the ${MAX_FILE_SIZE / 1024 / 1024}MB per-file mount limit.`
)
}
if (mounted.buffered + snapshot.size > MAX_TOTAL_SIZE) {
throw new Error(
`Mounting "${tableId}" would exceed the ${MAX_TOTAL_SIZE / 1024 / 1024}MB total mount limit. Mount fewer or smaller tables.`
)
}
const buffer = await downloadFile({
key: snapshot.key,
context: 'execution',
maxBytes: MAX_FILE_SIZE,
})
mounted.buffered += buffer.length
sandboxFiles.push({ path: mountPath, content: buffer.toString('utf-8') })
continue
}
// Keep the prior bounded mount — draining the whole table here was backed
// out for OOM, so don't ride the new unbounded queryRows default.
const rows = await queryRows(
table,
{ limit: TABLE_LIMITS.DEFAULT_QUERY_LIMIT },
'copilot-fn-exec'
)
const snapshot = await getOrCreateTableSnapshot(table, 'copilot-fn-exec')
if (!resolvedSecretTraceRegistry) {
throw new Error(
`Input table "${tableId}" cannot be mounted because its secret provenance is unavailable.`
)
}
try {
const provenance = await loadTableRowSecretProvenance(rows.rows, {
userId: provenanceUserId ?? 'opaque-model-mount',
workspaceId,
})
if (
!provenance.complete ||
!(await resolvedSecretTraceRegistry.importProvenance(provenance, {
trusted: true,
origin: 'copilotFunctionExecute.result',
}))
) {
resolvedSecretTraceRegistry.markIncomplete('source-provenance-incomplete', {
origin: 'copilotFunctionExecute.result',
})
}
} catch {
resolvedSecretTraceRegistry.markIncomplete('source-provenance-incomplete', {
origin: 'copilotFunctionExecute.result',
})
const mountSafety = await getTableSnapshotModelMountSafety({
tableId: table.id,
workspaceId,
rowsVersion: snapshot.version,
})
if (mountSafety === 'stale') {
throw new Error(`Input table "${tableId}" changed while preparing its snapshot. Retry.`)
}
if (mountSafety === 'unsafe-provenance') {
resolvedSecretTraceRegistry.markIncomplete('table-snapshot-unsafe-for-mount')
}
const columns = table.schema.columns
const csvLines = [toCsvRow(columns.map((column) => neutralizeCsvFormula(column.name)))]
for (const row of rows.rows) {
csvLines.push(
toCsvRow(columns.map((column) => formatCsvCell(column, row.data[getColumnId(column)])))
if (hasCloudStorage()) {
if (snapshot.size > SNAPSHOT_MAX_BYTES) {
throw new Error(
`Input table "${tableId}" is ${Math.round(snapshot.size / 1024 / 1024)}MB, over the ${SNAPSHOT_MAX_BYTES / 1024 / 1024}MB table mount limit.`
)
}
if (mounted.url + snapshot.size > MAX_TOTAL_URL_BYTES) {
throw new Error(
`Mounting "${tableId}" would exceed the ${MAX_TOTAL_URL_BYTES / 1024 / 1024 / 1024}GB total mount limit. Mount fewer or smaller files and tables.`
)
}
const url = await generatePresignedDownloadUrl(
snapshot.key,
'execution',
MOUNT_URL_TTL_SECONDS
)
sandboxFiles.push({ type: 'url', path: mountPath, url })
mounted.url += snapshot.size
continue
}
// Local storage: a presigned URL is an app-internal serve path a remote sandbox can't
// reach, so fall back to buffering the bytes through the web process (file-mount guards).
if (snapshot.size > MAX_FILE_SIZE) {
throw new Error(
`Input table "${tableId}" is ${Math.round(snapshot.size / 1024 / 1024)}MB, over the ${MAX_FILE_SIZE / 1024 / 1024}MB per-file mount limit.`
)
}
const csvContent = csvLines.join('\n')
sandboxFiles.push({ path: mountPath, content: csvContent })
if (mounted.buffered + snapshot.size > MAX_TOTAL_SIZE) {
throw new Error(
`Mounting "${tableId}" would exceed the ${MAX_TOTAL_SIZE / 1024 / 1024}MB total mount limit. Mount fewer or smaller tables.`
)
}
const buffer = await downloadFile({
key: snapshot.key,
context: 'execution',
maxBytes: MAX_FILE_SIZE,
})
mounted.buffered += buffer.length
sandboxFiles.push({ path: mountPath, content: buffer.toString('utf-8') })
}
}
@@ -737,7 +674,6 @@ export async function executeFunctionExecute(
inputFiles,
inputTables,
inputDirectories,
secretActorUserId ?? context.userId,
mountedRegistry,
inputFiles.length > 0 || inputDirectories.length > 0
? resolveCopilotFilePrincipal(context)
-1
View File
@@ -180,7 +180,6 @@ export const env = createEnv({
BILLING_CONCURRENCY_LIMIT_TEAM: z.string().optional(), // In-flight executions per Max-tier billing account (Max and Max for Teams)
BILLING_CONCURRENCY_LIMIT_ENTERPRISE: z.string().optional(), // In-flight executions per Enterprise billing account (metadata-overridable)
BILLING_ENABLED: z.boolean().optional(), // Enable billing enforcement and usage tracking
TABLE_SNAPSHOT_CACHE: z.boolean().optional(), // Mount tables into sandboxes by reference via a version-keyed CSV snapshot in object storage instead of draining the whole table into web-process heap
PII_REDACTION: z.boolean().optional(), // Redact PII from workflow logs via configurable Data Retention rules (Presidio at the logger persist choke point) and expose the Data Retention config UI
PII_GRANULAR_REDACTION: z.boolean().optional(), // Expose the execution-altering PII redaction stages (redact workflow input + block outputs in-flight) in the Data Retention config; layered on top of PII_REDACTION
TRIGGER_EU_REGION: z.boolean().optional(), // Route Trigger.dev runs to eu-central-1 instead of the default us-east-1 (fallback for the trigger-eu-region flag when AppConfig is not the source of truth)
@@ -56,14 +56,6 @@ interface FeatureFlagDefinition {
/** The single registry of known flags. To add a flag, add one entry here. */
const FEATURE_FLAGS = {
'table-snapshot-cache': {
description:
'Mount Sim tables into code sandboxes by reference via a version-keyed CSV snapshot in ' +
'object storage (reused across runs until the table mutates) instead of draining the whole ' +
'table into web-process heap. resolveInputFiles evaluates without user context — use ' +
'enabled:true for global rollout rather than per-user targeting.',
fallback: 'TABLE_SNAPSHOT_CACHE',
},
'pii-redaction': {
description:
'Redact PII from workflow logs via configurable Data Retention rules (Presidio at the ' +
@@ -20,7 +20,7 @@ vi.mock('@/lib/execution/durable-secret-provenance-enforcement', () => ({
import type { DbTransaction } from '@/lib/table/planner'
import {
classifyTableRowSecretProvenanceForCopy,
isTableSnapshotSafeForModelMount,
getTableSnapshotModelMountSafety,
loadTableRowSecretProvenance,
mutateTableRowsWithSecretProvenance,
updateTableRowsWithDerivedSecretProvenance,
@@ -67,30 +67,31 @@ describe('table row secret provenance', () => {
queueTableRows(userTableRows, [])
await expect(
isTableSnapshotSafeForModelMount({
getTableSnapshotModelMountSafety({
tableId: 'table-1',
workspaceId: 'workspace-1',
rowsVersion: 7,
})
).resolves.toBe(true)
).resolves.toBe('safe')
expect(dbChainMockFns.limit).toHaveBeenCalledTimes(3)
expect(dbChainMockFns.orderBy).not.toHaveBeenCalled()
})
it('rejects the snapshot as soon as an unsafe row exists', async () => {
it('classifies unsafe provenance after confirming the snapshot remains current', async () => {
queueTableRows(userTableDefinitions, [{ rowsVersion: 7 }])
queueTableRows(userTableRows, [{ id: 'unsafe-row' }])
queueTableRows(userTableDefinitions, [{ rowsVersion: 7 }])
await expect(
isTableSnapshotSafeForModelMount({
getTableSnapshotModelMountSafety({
tableId: 'table-1',
workspaceId: 'workspace-1',
rowsVersion: 7,
})
).resolves.toBe(false)
).resolves.toBe('unsafe-provenance')
expect(dbChainMockFns.limit).toHaveBeenCalledTimes(2)
expect(dbChainMockFns.limit).toHaveBeenCalledTimes(3)
})
it('rejects a snapshot when the table changes during the safety check', async () => {
@@ -99,12 +100,12 @@ describe('table row secret provenance', () => {
queueTableRows(userTableRows, [])
await expect(
isTableSnapshotSafeForModelMount({
getTableSnapshotModelMountSafety({
tableId: 'table-1',
workspaceId: 'workspace-1',
rowsVersion: 7,
})
).resolves.toBe(false)
).resolves.toBe('stale')
})
it('keeps untouched legacy rows readable with exact-empty provenance', async () => {
+11 -7
View File
@@ -705,17 +705,19 @@ async function readTableRowsVersion(tableId: string, workspaceId: string): Promi
return table?.rowsVersion ?? null
}
export type TableSnapshotModelMountSafety = 'safe' | 'unsafe-provenance' | 'stale'
/**
* Verifies that a version-pinned table contains no secret-bearing or unknown
* cells before its opaque CSV bytes cross into a model-controlled sandbox.
* Classifies whether a version-pinned table snapshot can cross into a model-controlled sandbox.
* A version change takes precedence over provenance because stale bytes must never be mounted.
*/
export async function isTableSnapshotSafeForModelMount(options: {
export async function getTableSnapshotModelMountSafety(options: {
tableId: string
workspaceId: string
rowsVersion: number
}): Promise<boolean> {
}): Promise<TableSnapshotModelMountSafety> {
if ((await readTableRowsVersion(options.tableId, options.workspaceId)) !== options.rowsVersion) {
return false
return 'stale'
}
const [unsafeRow] = await db
@@ -748,9 +750,11 @@ export async function isTableSnapshotSafeForModelMount(options: {
)
.limit(1)
if (unsafeRow) return false
if ((await readTableRowsVersion(options.tableId, options.workspaceId)) !== options.rowsVersion) {
return 'stale'
}
return (await readTableRowsVersion(options.tableId, options.workspaceId)) === options.rowsVersion
return unsafeRow ? 'unsafe-provenance' : 'safe'
}
/**