Skip to content

Commit 217f28a

Browse files
committed
fix(sandbox): withhold workbench certification after an unprovenanced file mount
A persistent chat workbench stays "clean" only while every input it received was classified secret-free, and the scratch-file read hands its bytes to the model on that basis. File mounts resolved from platform file objects were counted only when a provenance source existed, so a mount whose key has no canonical metadata record (or no principal to bind one) left the machine certified. Both the `files` parameter and a mount marker in context variables reach this resolver from model-supplied Function parameters. The resolver now reports how many mounts had no provenance source. When a workbench session receives any, the session request carries `unprovenancedInputs` and the code boundary records the machine as unknown. Workflow runs have no session and keep their existing absence policy.
1 parent 85402d0 commit 217f28a

7 files changed

Lines changed: 209 additions & 11 deletions

File tree

‎apps/sim/lib/execution/remote-sandbox/index.ts‎

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -891,7 +891,9 @@ async function executeInSandboxWithinBudget(
891891
await recordSessionFileInput(
892892
req.session.key,
893893
{ providerId: created.providerId, sandboxId },
894-
sandboxSessionInputsSafe() && !Object.keys(selected?.envs ?? {}).length
894+
sandboxSessionInputsSafe() &&
895+
!req.session.unprovenancedInputs &&
896+
!Object.keys(selected?.envs ?? {}).length
895897
)
896898
await provisionWithinBudget(sandbox, selected, signal)
897899
await writeSandboxInputs(sandbox, req.sandboxFiles, {
@@ -1078,7 +1080,9 @@ async function executeShellInSandboxWithinBudget(
10781080
await recordSessionFileInput(
10791081
req.session.key,
10801082
{ providerId: created.providerId, sandboxId },
1081-
sandboxSessionInputsSafe() && !Object.keys(selected?.envs ?? {}).length
1083+
sandboxSessionInputsSafe() &&
1084+
!req.session.unprovenancedInputs &&
1085+
!Object.keys(selected?.envs ?? {}).length
10821086
)
10831087
await provisionWithinBudget(sandbox, selected, signal)
10841088
await writeSandboxInputs(sandbox, req.sandboxFiles, {
Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
1+
/**
2+
* The persistent workbench's input history is what lets a scratch file reach the model. These
3+
* run the real code boundary against the real history recorder, so a mount the caller could not
4+
* classify has to leave the machine uncertified.
5+
*/
6+
import { redisConfigMockFns } from '@sim/testing/mocks/redis-config.mock'
7+
import {
8+
remoteSandboxProviderMock,
9+
remoteSandboxProviderMockFns,
10+
} from '@sim/testing/mocks/remote-sandbox-provider.mock'
11+
import { generateShortId } from '@sim/utils/id'
12+
import { beforeEach, describe, expect, it, vi } from 'vitest'
13+
import { CodeLanguage } from '@/lib/execution/languages'
14+
import type { SandboxHandle, SandboxProvider } from '@/lib/execution/remote-sandbox/types'
15+
16+
const { mockFindSessionSandbox } = vi.hoisted(() => ({ mockFindSessionSandbox: vi.fn() }))
17+
18+
vi.mock('@/lib/execution/remote-sandbox/provider', () => remoteSandboxProviderMock)
19+
vi.mock('@/lib/execution/remote-sandbox/resolve', () => ({
20+
resolveWorkspaceSandbox: vi.fn().mockResolvedValue(null),
21+
provisionRuntimeDependencies: vi.fn(),
22+
repairMissingSandboxImage: vi.fn().mockResolvedValue(null),
23+
RUNTIME_INSTALL_TIMEOUT_MS: 60_000,
24+
}))
25+
vi.mock('@/lib/core/execution-limits/metrics', () => ({
26+
recordSandboxTeardownFailure: vi.fn(),
27+
recordSandboxProviderLimit: vi.fn(),
28+
}))
29+
30+
import {
31+
executeInSandbox,
32+
executeShellInSandbox,
33+
SIM_RESULT_PREFIX,
34+
} from '@/lib/execution/remote-sandbox'
35+
import { observeSandboxSessionInputs } from '@/lib/execution/remote-sandbox/execution-observer'
36+
import {
37+
initializeSessionFileProvenance,
38+
isSessionFileProvenanceClean,
39+
} from '@/lib/execution/remote-sandbox/session-file-provenance'
40+
41+
/** Same one-way semantics as the Lua history script. */
42+
const records = new Map<string, string>()
43+
redisConfigMockFns.mockGetRedisClient.mockImplementation(() => ({
44+
set: async (key: string, value: string) => {
45+
if (!records.has(key)) records.set(key, value)
46+
return 'OK'
47+
},
48+
get: async (key: string) => records.get(key) ?? null,
49+
eval: async (_script: string, _count: number, key: string, input: string) => {
50+
records.set(key, records.get(key) === 'clean' && input === 'clean' ? 'clean' : 'unknown')
51+
return records.get(key)
52+
},
53+
}))
54+
remoteSandboxProviderMockFns.mockResolveProvider.mockImplementation(
55+
(): SandboxProvider => ({
56+
id: 'e2b',
57+
dependencyStrategy: 'prebuilt',
58+
resolveLifetimeMs: (ms: number) => ms,
59+
create: vi.fn(),
60+
findSessionSandbox: mockFindSessionSandbox,
61+
})
62+
)
63+
64+
function machine(sandboxId: string): SandboxHandle {
65+
return {
66+
sandboxId,
67+
runCode: async () => ({ text: `${SIM_RESULT_PREFIX}{"ok":true}`, stdout: '', stderr: '' }),
68+
runCommand: async () => ({ stdout: '', stderr: '', exitCode: 0 }),
69+
extendLifetime: async () => {},
70+
getFileSize: async () => 0,
71+
readFile: async () => '',
72+
readFileWithLimit: async () => ({ content: '', byteLength: 0 }),
73+
writeFile: async () => {},
74+
removeFile: async () => {},
75+
listFiles: async () => [],
76+
kill: async () => {},
77+
}
78+
}
79+
80+
beforeEach(() => {
81+
records.clear()
82+
})
83+
84+
describe('workbench certification at the code boundary', () => {
85+
it.each([
86+
['code', false],
87+
['code', true],
88+
['shell', false],
89+
['shell', true],
90+
] as const)('%s with unprovenanced mounts %s', async (kind, unprovenanced) => {
91+
const sandboxId = `machine-${generateShortId(8)}`
92+
const key = `chat-${generateShortId(8)}`
93+
const identity = { providerId: 'e2b', sandboxId } as const
94+
mockFindSessionSandbox.mockResolvedValue(machine(sandboxId))
95+
await initializeSessionFileProvenance(key, identity)
96+
const session = { key, ...(unprovenanced ? { unprovenancedInputs: true } : {}) }
97+
const request = {
98+
code: 'print(1)',
99+
language: CodeLanguage.Python,
100+
timeoutMs: 30_000,
101+
session,
102+
}
103+
await observeSandboxSessionInputs(
104+
() => true,
105+
() =>
106+
kind === 'code'
107+
? executeInSandbox(request)
108+
: executeShellInSandbox({ ...request, envs: {} })
109+
)
110+
expect(await isSessionFileProvenanceClean(key, identity)).toBe(!unprovenanced)
111+
})
112+
})

‎apps/sim/lib/execution/remote-sandbox/types.ts‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,11 @@ export interface SandboxSessionRequest {
9191
cli?: { path: string; content: string; runtime?: { path: string; content: string } }
9292
/** Extra environment variables present on every execution in the session. */
9393
envs?: Record<string, string>
94+
/**
95+
* This execution mounts bytes whose secret provenance is unknown, so the machine's input
96+
* history must not stay certified clean even when the caller's own inputs are.
97+
*/
98+
unprovenancedInputs?: boolean
9499
}
95100

96101
export interface SandboxShellExecutionRequest {

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

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -153,6 +153,7 @@ vi.mock('@/lib/function-execution/sandbox-mounts', () => ({
153153
}) => ({
154154
contributingFiles: mockMountContributors(),
155155
renderedContributingFiles: mockRenderedMountContributors(),
156+
unprovenancedMountCount: mockMountContributors() ? 0 : planned.length,
156157
sandboxFiles: planned.map(({ mountPath }) => ({
157158
type: 'url' as const,
158159
path: mountPath,
@@ -2560,6 +2561,32 @@ describe('Function execution request', () => {
25602561
expect(mockWriteWorkspaceFileByPath).not.toHaveBeenCalled()
25612562
})
25622563

2564+
it.each([
2565+
['a mount with no provenance source', true],
2566+
['no mounts', false],
2567+
] as const)('withholds workbench certification for %s', async (_label, mounted) => {
2568+
envFlagsMock.isMothershipSandboxEnabled = true
2569+
hybridAuthMockFns.mockCheckInternalAuth.mockResolvedValue({
2570+
success: true,
2571+
userId: 'user-123',
2572+
authType: 'internal_jwt',
2573+
sandboxProfile: 'mothership',
2574+
})
2575+
const response = await POST(
2576+
createMockRequest('POST', {
2577+
code: 'x',
2578+
language: 'python',
2579+
workspaceId: 'workspace-1',
2580+
sandboxSessionKey: 'chat-session',
2581+
...(mounted ? { contextVariables: { doc: MOUNT_REF } } : {}),
2582+
})
2583+
)
2584+
expect(response.status).toBe(200)
2585+
const session = mockExecuteInSandbox.mock.calls.at(-1)?.[0].session
2586+
expect(session.key).toBe('chat-session')
2587+
expect(session.unprovenancedInputs === true).toBe(mounted)
2588+
})
2589+
25632590
it('gives overlapping calls in one persistent workbench distinct automatic export directories', async () => {
25642591
envFlagsMock.isMothershipSandboxEnabled = true
25652592
hybridAuthMockFns.mockCheckInternalAuth.mockResolvedValue({

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

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2385,7 +2385,7 @@ export async function executeFunctionRequest(
23852385
// would leave `{{OTHER_SECRET}}` resolving, which is a hole, not a scope.
23862386
const envVars = scopeEnvironmentVariables(rawEnvVars, secretScope, mountedSecrets)
23872387
const admittedChatOwner = activeSandboxChatOwner()
2388-
const mothershipSession =
2388+
const admittedSession =
23892389
usesMothershipSandbox &&
23902390
!selectedSandboxId &&
23912391
sandboxSessionKey &&
@@ -2716,6 +2716,10 @@ export async function executeFunctionRequest(
27162716
)
27172717
}
27182718
const { sandboxFiles: userFileMounts, manifest: mountManifest } = resolvedMounts
2719+
const mothershipSession =
2720+
admittedSession && resolvedMounts.unprovenancedMountCount > 0
2721+
? { ...admittedSession, unprovenancedInputs: true }
2722+
: admittedSession
27192723
const sandboxFiles = mergeSandboxFileMounts(_sandboxFiles, userFileMounts)
27202724

27212725
// Every `<block.file.path>` marker becomes the path its file was mounted at,

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

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -214,6 +214,42 @@ describe('resolveUserFileMounts', () => {
214214
expect(mockDownloadServableFileFromStorage).not.toHaveBeenCalled()
215215
})
216216

217+
/**
218+
* A persistent workbench certifies its machine from these counts: a mount whose bytes have no
219+
* provenance source can hold resolved secret plaintext nobody recorded, so it must be reported.
220+
*/
221+
it.each([
222+
['has no metadata record', null, 1],
223+
['has a canonical metadata record', 'recorded', 0],
224+
] as const)('reports a mounted file whose key %s', async (_label, metadata, expected) => {
225+
const file = executionFile()
226+
mockGetFileMetadataByKey.mockResolvedValue(
227+
metadata
228+
? {
229+
id: 'canonical-file-id',
230+
key: file.key,
231+
context: 'execution',
232+
workspaceId: WORKSPACE_ID,
233+
userId: 'user-1',
234+
contentUpdatedAt: new Date('2026-01-01T00:00:00Z'),
235+
}
236+
: null
237+
)
238+
const result = await resolveUserFileMounts({
239+
planned: planUserFileMounts([file]),
240+
context: { ...executionContext, principal: createSessionPrincipal() },
241+
})
242+
expect(result.unprovenancedMountCount).toBe(expected)
243+
})
244+
245+
it('reports every mount as unprovenanced when no principal can bind its source', async () => {
246+
const result = await resolveUserFileMounts({
247+
planned: planUserFileMounts([executionFile()]),
248+
context: executionContext,
249+
})
250+
expect(result.unprovenancedMountCount).toBe(1)
251+
})
252+
217253
it('preserves contributors introduced when an inline mount renders generated source', async () => {
218254
const contributor = {
219255
fileId: 'image-file',

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

Lines changed: 18 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -277,7 +277,14 @@ export async function resolveUserFileMounts(args: {
277277
manifest: SandboxMountManifestEntry[]
278278
contributingFiles?: readonly WorkspaceFileSecretProvenanceIdentity[]
279279
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+
unprovenancedMountCount: number
280286
}> {
287+
let unprovenancedMountCount = 0
281288
const sandboxFiles: SandboxFile[] = []
282289
const manifest: SandboxMountManifestEntry[] = []
283290
const budget = createSandboxMountBudget()
@@ -297,14 +304,16 @@ export async function resolveUserFileMounts(args: {
297304
for (const { userFile, mountPath } of args.planned) {
298305
const storageContext = resolveTrustedFileContext(userFile.key, userFile.context)
299306
await assertUserFileContentAccess(userFile, args.context)
300-
if (args.context.principal && args.context.workspaceId) {
301-
const source = await resolveStoredFileProvenanceSource(userFile, {
302-
...args.context,
303-
principal: args.context.principal,
304-
workspaceId: args.context.workspaceId,
305-
})
306-
if (source) addContributor(source.identity)
307-
}
307+
const source =
308+
args.context.principal && args.context.workspaceId
309+
? await resolveStoredFileProvenanceSource(userFile, {
310+
...args.context,
311+
principal: args.context.principal,
312+
workspaceId: args.context.workspaceId,
313+
})
314+
: undefined
315+
if (source) addContributor(source.identity)
316+
else unprovenancedMountCount += 1
308317

309318
await pushSandboxFileMount(
310319
sandboxFiles,
@@ -361,6 +370,7 @@ export async function resolveUserFileMounts(args: {
361370
return {
362371
sandboxFiles,
363372
manifest,
373+
unprovenancedMountCount,
364374
...(contributingFiles.size > 0 ? { contributingFiles: [...contributingFiles.values()] } : {}),
365375
...(renderedContributingFiles.size > 0
366376
? { renderedContributingFiles: [...renderedContributingFiles.values()] }

0 commit comments

Comments
 (0)