From 05b84e91c8e8678aba72788e6ef206595aa30b62 Mon Sep 17 00:00:00 2001 From: Vikhyath Mondreti Date: Wed, 30 Sep 2026 18:36:48 -0700 Subject: [PATCH 1/3] fix(sandbox): redact temporary session credentials in model output --- .../sim/lib/execution/remote-sandbox/types.ts | 5 + .../lib/function-execution/execute-request.ts | 13 ++ .../workbench-confidentiality.live.test.ts | 191 +++++++++++++++--- .../mothership/tools/sandbox-session.test.ts | 2 +- .../lib/mothership/tools/sandbox-session.ts | 16 +- 5 files changed, 196 insertions(+), 31 deletions(-) diff --git a/apps/sim/lib/execution/remote-sandbox/types.ts b/apps/sim/lib/execution/remote-sandbox/types.ts index d1b013ca3c5..db97544c410 100644 --- a/apps/sim/lib/execution/remote-sandbox/types.ts +++ b/apps/sim/lib/execution/remote-sandbox/types.ts @@ -92,6 +92,11 @@ export interface SandboxSessionRequest { cli?: { path: string; content: string; runtime?: { path: string; content: string } } /** Extra environment variables present on every execution in the session. */ envs?: Record + /** + * Ephemeral callback credentials for model-output redaction after execution. They expire with + * the tool lease and do not contribute to durable machine or exported-file provenance. + */ + outputProvenance?: DurableSecretProvenance /** * This execution mounts bytes whose secret provenance is unknown, so the machine's input * history must not stay certified clean even when the caller's own inputs are. diff --git a/apps/sim/lib/function-execution/execute-request.ts b/apps/sim/lib/function-execution/execute-request.ts index 5a42e170d50..77dbb00bf0f 100644 --- a/apps/sim/lib/function-execution/execute-request.ts +++ b/apps/sim/lib/function-execution/execute-request.ts @@ -1030,6 +1030,7 @@ interface FunctionRouteExecutionContext { runtimeFileSecretTraceRegistry?: ResolvedSecretTraceRegistry runtimeInputProvenanceUnrecorded?: boolean resolvedSecretTraceRegistry?: ResolvedSecretTraceRegistry + sessionOutputProvenance?: DurableSecretProvenance } /** Keeps bound file provenance in both ordinary Function results and exported artifact bytes. */ @@ -1276,6 +1277,17 @@ async function functionJsonResponse( context: FunctionRouteExecutionContext, init?: ResponseInit ) { + /** + * Narrow callback receipts to the returned JSON before compaction. Scanning serialized bytes + * avoids activating secret-only traversal limits on large results that contain no credential. + */ + if (context.sessionOutputProvenance && context.resolvedSecretTraceRegistry) { + await importDurableSecretProvenance( + context.resolvedSecretTraceRegistry, + context.sessionOutputProvenance, + JSON.stringify(body) + ) + } const responseBody = { ...body, largeValueKeys: context.largeValueKeys, @@ -2487,6 +2499,7 @@ export async function executeFunctionRequest( ), mountedFileSecretProvenanceScanner, resolvedSecretTraceRegistry: auth.resolvedSecretTraceRegistry, + sessionOutputProvenance: admittedSession?.outputProvenance, } const lang = isValidCodeLanguage(language) ? language : DEFAULT_CODE_LANGUAGE diff --git a/apps/sim/lib/mothership/tools/handlers/workbench-confidentiality.live.test.ts b/apps/sim/lib/mothership/tools/handlers/workbench-confidentiality.live.test.ts index 79fbfb88d4b..137e5c6cca8 100644 --- a/apps/sim/lib/mothership/tools/handlers/workbench-confidentiality.live.test.ts +++ b/apps/sim/lib/mothership/tools/handlers/workbench-confidentiality.live.test.ts @@ -6,6 +6,18 @@ import { createDelegatedPrincipal } from '@sim/testing/factories/principal.facto import { createDeferred } from '@sim/testing/helpers/deferred' import { setEnv } from '@sim/testing/mocks/env.mock' import { envFlagsMock } from '@sim/testing/mocks/env-flags.mock' +import { + mothershipAgentUrlMock, + mothershipAgentUrlMockFns, +} from '@sim/testing/mocks/mothership-agent-url.mock' +import { + mothershipAsyncRunsMock, + mothershipAsyncRunsMockFns, +} from '@sim/testing/mocks/mothership-async-runs.mock' +import { + mothershipGoFetchMock, + mothershipGoFetchMockFns, +} from '@sim/testing/mocks/mothership-go-fetch.mock' import { redisConfigMockFns } from '@sim/testing/mocks/redis-config.mock' import { remoteSandboxProviderMock, @@ -30,9 +42,9 @@ vi.mock('@/lib/execution/remote-sandbox/resolve', () => ({ repairMissingSandboxImage: async () => null, RUNTIME_INSTALL_TIMEOUT_MS: 60_000, })) -vi.mock('@/lib/mothership/tools/sandbox-session', () => ({ - buildMothershipSandboxSession: async (args: { sessionKey: string }) => ({ key: args.sessionKey }), -})) +vi.mock('@/lib/mothership/async-runs/repository', () => mothershipAsyncRunsMock) +vi.mock('@/lib/mothership/request/go/fetch', () => mothershipGoFetchMock) +vi.mock('@/lib/mothership/server/agent-url', () => mothershipAgentUrlMock) vi.mock('@/lib/workspace-files/application/delegated-principal', () => ({ rebindWorkspaceFileDelegatedPrincipal: ({ principal }: { principal: unknown }) => principal, })) @@ -61,6 +73,10 @@ import { inspectToolResultForCopilot } from '@/lib/mothership/request/tools/reso import type { ToolExecutionContext } from '@/lib/mothership/tool-executor/types' import { executeFunctionExecute } from '@/lib/mothership/tools/handlers/function-execute' import { executeRunCode } from '@/lib/mothership/tools/handlers/run-code' +import { + readSandboxResourceScope, + withSandboxResourceScope, +} from '@/lib/mothership/tools/sandbox-resources' import { chatSandboxSessionKey } from '@/lib/mothership/tools/sandbox-session-key' import { ResolvedSecretTraceRegistry } from '@/executor/utils/resolved-secret-trace-registry' import { buildFunctionExecuteBody, functionExecuteTool } from '@/tools/function/execute' @@ -83,34 +99,47 @@ function workerPath(path: string) { return path.startsWith('/') ? join(root, path.slice(1)) : join(root, 'home/user', path) } +async function runWorkerProcess( + executable: string, + args: string[], + options: Parameters[1] +) { + const envs = Object.fromEntries( + Object.entries(options.envs ?? {}).map(([key, value]) => [ + key, + value + .replaceAll('/home/user', workerPath('/home/user')) + .replaceAll('/tmp/sim/', `${workerPath('/tmp/sim')}/`) + .replaceAll('/tmp/.sim-private-input-', workerPath('/tmp/.sim-private-input-')), + ]) + ) + try { + const output = await execute(executable, args, { + cwd: workerPath('/home/user'), + env: { PATH: '/usr/bin:/bin:/opt/homebrew/bin', ...envs }, + timeout: options.timeoutMs, + maxBuffer: options.maxOutputBytes, + }) + return { ...output, exitCode: 0 } + } catch (error) { + const failure = error as { stdout: string; stderr: string; code: number } + return { stdout: failure.stdout, stderr: failure.stderr, exitCode: failure.code } + } +} + function localWorker(): SandboxHandle { return { sandboxId: `local-${generateShortId(12)}`, - runCode: async () => { - throw new Error('This reproduction uses actual shell processes') - }, - async runCommand(command, options) { - const envs = Object.fromEntries( - Object.entries(options.envs ?? {}).map(([key, value]) => [ - key, - value - .replaceAll('/home/user', workerPath('/home/user')) - .replaceAll('/tmp/sim/', `${workerPath('/tmp/sim')}/`), - ]) - ) - try { - const output = await execute('/bin/bash', ['-c', command], { - cwd: workerPath('/home/user'), - env: { PATH: '/usr/bin:/bin:/opt/homebrew/bin', ...envs }, - timeout: options.timeoutMs, - maxBuffer: options.maxOutputBytes, - }) - return { ...output, exitCode: 0 } - } catch (error) { - const failure = error as { stdout: string; stderr: string; code: number } - return { stdout: failure.stdout, stderr: failure.stderr, exitCode: failure.code } + async runCode(code, options) { + const result = await runWorkerProcess(process.execPath, ['-e', code], options) + return { + text: '', + stdout: result.stdout, + stderr: result.stderr, + ...(result.exitCode ? { error: { name: 'RuntimeError', value: result.stderr } } : {}), } }, + runCommand: (command, options) => runWorkerProcess('/bin/bash', ['-c', command], options), extendLifetime: async () => {}, getFileSize: async (path) => (await stat(workerPath(path))).size, readFile: async (path) => readFile(workerPath(path), 'utf8'), @@ -175,7 +204,16 @@ beforeEach(async () => { throw new Error('Only the existing disposable worker may be used') }, }) - setEnv({ ENCRYPTION_KEY: 'a'.repeat(64) }) + setEnv({ + ENCRYPTION_KEY: 'a'.repeat(64), + MOTHERSHIP_SIM_TRANSPORT: 'direct', + MOTHERSHIP_SANDBOX_CLI_ENDPOINT: 'https://callback.test', + }) + mothershipAsyncRunsMockFns.mockIsActiveSandboxResourceOwner.mockResolvedValue(true) + mothershipAgentUrlMockFns.mockGetMothershipBaseURL.mockResolvedValue('https://worker.test') + mothershipGoFetchMockFns.mockFetchGo.mockImplementation(async () => + Response.json({ version: 1, entrypoint: 'fixture-bootstrap' }) + ) envFlagsMock.isMothershipSandboxEnabled = true envFlagsMock.isRemoteSandboxEnabled = true root = await mkdtemp('/private/tmp/sim-workbench-test-') @@ -250,9 +288,13 @@ function context(): ToolExecutionContext { } } -async function run(code: string, secrets: string[] = []) { +async function run( + code: string, + secrets: string[] = [], + language: 'shell' | 'javascript' = 'shell' +) { const current = context() - const raw = await executeRunCode({ code, language: 'shell', secrets }, current) + const raw = await inResourceScope(() => executeRunCode({ code, language, secrets }, current)) const projected = inspectToolResultForCopilot( raw, current.resolvedSecretTraceRegistry, @@ -262,7 +304,82 @@ async function run(code: string, secrets: string[] = []) { return { raw, projected } } +function inResourceScope(action: () => Promise) { + return withSandboxResourceScope( + { + ...scope, + chatId, + runId: 'fixture-run', + toolCallId: 'fixture-call', + ownerToken: 'fixture-owner', + }, + AbortSignal.timeout(15_000), + undefined, + action + ) +} + describe('persistent workbench output confidentiality', () => { + it.each(['javascript', 'shell'] as const)( + 'redacts session credentials in %s output while preserving routing metadata', + async (language) => { + const code = + language === 'shell' + ? 'printf "%s" "$SIM_API_KEY" > session-key.txt; printf "%s %s" "$SIM_API_KEY" "$SIM_WORKSPACE"' + : '(await import("node:fs")).writeFileSync("session-key.txt", process.env.SIM_API_KEY); process.stdout.write(process.env.SIM_API_KEY + " " + process.env.SIM_WORKSPACE)' + const result = await run(code, [], language) + expect(result.raw.success).toBe(true) + const credential = await readFile(workerPath('session-key.txt'), 'utf8') + expect(credential).toMatch(/^mothership-sandbox:/) + expect(result.projected.safe).toBe(true) + expect(JSON.stringify(result.projected.result)).not.toContain(credential) + expect(JSON.stringify(result.projected.result)).toContain('{{SIM_API_KEY}}') + expect(JSON.stringify(result.projected.result)).toContain(scope.workspaceId) + expect( + await readSessionSecretProvenance(chatSandboxSessionKey(chatId), { + providerId: 'e2b', + sandboxId: machine.sandboxId, + }) + ).toEqual({ status: 'exact', entries: [] }) + } + ) + it('redacts session credentials when the provider falls back to a one-shot machine', async () => { + remoteSandboxProviderMockFns.mockResolveProvider.mockReturnValue({ + id: 'e2b', + dependencyStrategy: 'prebuilt', + resolveLifetimeMs: (ms: number) => ms, + create: async () => machine, + }) + const result = await run('printf "%s" "$SIM_API_KEY" > session-key.txt; cat session-key.txt') + const credential = await readFile(workerPath('session-key.txt'), 'utf8') + expect(result.raw.success).toBe(true) + expect(result.projected.safe).toBe(true) + expect(JSON.stringify(result.projected.result)).not.toContain(credential) + expect(JSON.stringify(result.projected.result)).toContain('{{SIM_API_KEY}}') + }) + it('omits session authentication if its encrypted receipt cannot be created', async () => { + setEnv({ ENCRYPTION_KEY: '' }) + const result = await run('test -z "$SIM_API_KEY" && printf allowed') + expect(result.raw.success).toBe(true) + expect(JSON.stringify(result.projected.result)).toContain('allowed') + }) + it('keeps large ordinary results readable when callback credentials are absent from them', async () => { + const result = await run('return Array.from({ length: 100_001 }, () => 0)', [], 'javascript') + expect(result.raw.success).toBe(true) + expect(result.raw.output).toHaveProperty('result.length', 100_001) + expect(result.projected.safe).toBe(true) + }) + it('revokes callback authentication before a result reaches the model', async () => { + const result = await run( + 'printf "%s" "$SIM_API_KEY" > session-key.txt; printf "%s" "$SIM_ENDPOINT" > session-endpoint.txt; printf done' + ) + const credential = await readFile(workerPath('session-key.txt'), 'utf8') + const endpoint = await readFile(workerPath('session-endpoint.txt'), 'utf8') + expect(result.raw.success).toBe(true) + expect(result.projected.safe).toBe(true) + expect(await readSandboxResourceScope(endpoint.split('/').at(-1)!, credential)).toBeNull() + }) + it('allows a mounted empty value without requiring a redaction receipt', async () => { const emptyCatalog = [ { name: 'TOKEN', plaintext: '', encryptedValue: (await encryptSecret('')).encrypted }, @@ -395,6 +512,22 @@ describe('persistent workbench output confidentiality', () => { expect(JSON.stringify(output.projected.result)).not.toContain(canary) expect(JSON.stringify(output.projected.result)).toContain('{{TOKEN}}') }) + it('keeps ordinary binary exports usable when only callback authentication is present', async () => { + const result = await inResourceScope(() => + executeFunctionExecute( + { + code: "printf '\\211PNG\\000\\001' > image.png", + language: 'shell', + outputs: { files: [{ path: 'files/image.png', sandboxPath: 'image.png' }] }, + }, + context() + ) + ) + expect(result.success).toBe(true) + const saved = io.write.mock.calls.at(-1)![0] + expect(saved.buffer).toEqual(Buffer.from([0x89, 0x50, 0x4e, 0x47, 0, 1])) + expect(saved.secretProvenance).toEqual({ status: 'exact', entries: [] }) + }) it('retains historical secret provenance on a text export', async () => { await run('printf "%s" "$TOKEN" > saved.txt', ['TOKEN']) const current = context() diff --git a/apps/sim/lib/mothership/tools/sandbox-session.test.ts b/apps/sim/lib/mothership/tools/sandbox-session.test.ts index 5f6aa239f8e..d85b08cf212 100644 --- a/apps/sim/lib/mothership/tools/sandbox-session.test.ts +++ b/apps/sim/lib/mothership/tools/sandbox-session.test.ts @@ -31,7 +31,7 @@ const fetchBootstrap = mothershipGoFetchMockFns.mockFetchGo const baseURL = mothershipAgentUrlMockFns.mockGetMothershipBaseURL urlsMockFns.mockGetBaseUrl.mockReturnValue('https://unused.test') -setEnv({ MOTHERSHIP_SANDBOX_CLI_ENDPOINT: 'https://sim.test' }) +setEnv({ MOTHERSHIP_SANDBOX_CLI_ENDPOINT: 'https://sim.test', ENCRYPTION_KEY: 'a'.repeat(64) }) const request = { sessionKey: 'chat', workspaceId: 'workspace', userId: 'user' } diff --git a/apps/sim/lib/mothership/tools/sandbox-session.ts b/apps/sim/lib/mothership/tools/sandbox-session.ts index ae4c08a9cec..28161df0a7a 100644 --- a/apps/sim/lib/mothership/tools/sandbox-session.ts +++ b/apps/sim/lib/mothership/tools/sandbox-session.ts @@ -5,7 +5,9 @@ import { createLogger } from '@sim/logger' import { getErrorMessage } from '@sim/utils/errors' import { generateId } from '@sim/utils/id' import { env } from '@/lib/core/config/env' +import { encryptSecret } from '@/lib/core/security/encryption' import { getBaseUrl } from '@/lib/core/utils/urls' +import type { DurableSecretProvenance } from '@/lib/execution/durable-secret-provenance' import type { SandboxSessionRequest } from '@/lib/execution/remote-sandbox/types' import { WorkbenchBootstrap } from '@/lib/mothership/generated/workbench' import { fetchGo } from '@/lib/mothership/request/go/fetch' @@ -79,11 +81,23 @@ export async function buildMothershipSandboxSession(args: { if (getSimConnection().mode === 'checkpoint') return { key: args.sessionKey } const cli = await workbenchCli(args.userId, args.signal) let cliEnvs: Record | undefined + let outputProvenance: DurableSecretProvenance | undefined try { const apiKey = `mothership-sandbox:${generateId()}` const endpoint = env.MOTHERSHIP_SANDBOX_CLI_ENDPOINT?.trim() || getBaseUrl() const scopedEndpoint = await sandboxResourceEndpoint(endpoint, args, apiKey) if (scopedEndpoint !== endpoint) { + outputProvenance = { + status: 'exact', + entries: [ + { + name: 'SIM_API_KEY', + encryptedValue: (await encryptSecret(apiKey)).encrypted, + sourceUserId: args.userId, + ...(args.workspaceId ? { sourceWorkspaceId: args.workspaceId } : {}), + }, + ], + } cliEnvs = { SIM_API_KEY: apiKey, ...(args.organizationId @@ -101,6 +115,6 @@ export async function buildMothershipSandboxSession(args: { return { key: args.sessionKey, cli, - ...(cliEnvs ? { envs: cliEnvs } : {}), + ...(cliEnvs ? { envs: cliEnvs, outputProvenance } : {}), } } From b440da75c7097c57ea466bdacbcdd434aa7ff36a Mon Sep 17 00:00:00 2001 From: Vikhyath Mondreti Date: Wed, 30 Sep 2026 19:08:02 -0700 Subject: [PATCH 2/3] fix(sandbox): unify output handling and avoid response copies --- .../sim/lib/execution/remote-sandbox/types.ts | 6 +-- .../lib/function-execution/execute-request.ts | 39 ++++++++++++------ .../workbench-confidentiality.live.test.ts | 41 +++++++++++++++++++ .../lib/mothership/tools/sandbox-session.ts | 35 ++++++++++++++-- 4 files changed, 103 insertions(+), 18 deletions(-) diff --git a/apps/sim/lib/execution/remote-sandbox/types.ts b/apps/sim/lib/execution/remote-sandbox/types.ts index db97544c410..bdcbc8194cb 100644 --- a/apps/sim/lib/execution/remote-sandbox/types.ts +++ b/apps/sim/lib/execution/remote-sandbox/types.ts @@ -93,10 +93,10 @@ export interface SandboxSessionRequest { /** Extra environment variables present on every execution in the session. */ envs?: Record /** - * Ephemeral callback credentials for model-output redaction after execution. They expire with - * the tool lease and do not contribute to durable machine or exported-file provenance. + * Selects ephemeral callback credentials present in the returned JSON for model redaction. + * They expire with the tool lease and do not contribute to machine or exported-file provenance. */ - outputProvenance?: DurableSecretProvenance + outputProvenance?: (value: unknown) => DurableSecretProvenance /** * This execution mounts bytes whose secret provenance is unknown, so the machine's input * history must not stay certified clean even when the caller's own inputs are. diff --git a/apps/sim/lib/function-execution/execute-request.ts b/apps/sim/lib/function-execution/execute-request.ts index 77dbb00bf0f..1e969a16ed8 100644 --- a/apps/sim/lib/function-execution/execute-request.ts +++ b/apps/sim/lib/function-execution/execute-request.ts @@ -91,7 +91,11 @@ import { MAX_BLOCK_MOUNTED_FILES, SANDBOX_OUTPUT_DIR, } from '@/lib/execution/remote-sandbox/sandbox-paths' -import type { SandboxCollectedFile, SandboxFile } from '@/lib/execution/remote-sandbox/types' +import type { + SandboxCollectedFile, + SandboxFile, + SandboxSessionRequest, +} from '@/lib/execution/remote-sandbox/types' import { isExecutionResourceLimitError } from '@/lib/execution/resource-errors' import { MAX_FUNCTION_REFERENCES } from '@/lib/function-execution/limits' import type { SandboxExportedFile } from '@/lib/function-execution/output' @@ -1030,7 +1034,7 @@ interface FunctionRouteExecutionContext { runtimeFileSecretTraceRegistry?: ResolvedSecretTraceRegistry runtimeInputProvenanceUnrecorded?: boolean resolvedSecretTraceRegistry?: ResolvedSecretTraceRegistry - sessionOutputProvenance?: DurableSecretProvenance + sessionOutputProvenance?: SandboxSessionRequest['outputProvenance'] } /** Keeps bound file provenance in both ordinary Function results and exported artifact bytes. */ @@ -1277,15 +1281,10 @@ async function functionJsonResponse( context: FunctionRouteExecutionContext, init?: ResponseInit ) { - /** - * Narrow callback receipts to the returned JSON before compaction. Scanning serialized bytes - * avoids activating secret-only traversal limits on large results that contain no credential. - */ if (context.sessionOutputProvenance && context.resolvedSecretTraceRegistry) { await importDurableSecretProvenance( context.resolvedSecretTraceRegistry, - context.sessionOutputProvenance, - JSON.stringify(body) + context.sessionOutputProvenance(body) ) } const responseBody = { @@ -1555,13 +1554,14 @@ function exportUnchangedNote(sandboxPath?: string): string { } function exportFailure( + context: FunctionRouteExecutionContext, error: string, status: number, stdout: string, executionTime: number, cost: FunctionExecutionCost | undefined -): NextResponse { - return NextResponse.json( +) { + return functionJsonResponse( { success: false, error, @@ -1572,6 +1572,7 @@ function exportFailure( ...(cost ? { cost } : {}), }, }, + context, { status } ) } @@ -1661,6 +1662,7 @@ async function maybeExportSandboxFileToWorkspace(args: { if (!outputPath) { return exportFailure( + routeContext, 'outputSandboxPath requires outputPath. Set outputPath to the destination workspace file, e.g. "files/result.csv".', 400, stdout, @@ -1674,6 +1676,7 @@ async function maybeExportSandboxFileToWorkspace(args: { if (!resolvedWorkspaceId || routeContext.principal.kind !== 'delegated') { return exportFailure( + routeContext, 'Workspace context required to save sandbox file to workspace', 400, stdout, @@ -1684,6 +1687,7 @@ async function maybeExportSandboxFileToWorkspace(args: { if (exportedFileContent === undefined) { return exportFailure( + routeContext, `Sandbox file "${outputSandboxPath}" was not found or could not be read`, 500, stdout, @@ -1707,6 +1711,7 @@ async function maybeExportSandboxFileToWorkspace(args: { const outputBytes = Buffer.byteLength(exportedFileContent, isBinary ? 'base64' : 'utf-8') if (outputBytes > MAX_SANDBOX_OUTPUT_BYTES) { return exportFailure( + routeContext, `Sandbox output files exceed ${MAX_SANDBOX_OUTPUT_BYTES} bytes total`, 400, stdout, @@ -1791,6 +1796,7 @@ async function maybeExportSandboxFileToWorkspace(args: { }) } catch (error) { return exportFailure( + routeContext, getErrorMessage(error, 'Failed to export sandbox file'), workspaceFileExportErrorStatus(error), stdout, @@ -1817,6 +1823,7 @@ async function maybeExportSandboxFilesToWorkspace(args: { if (sandboxFiles.length === 0) return null if (sandboxFiles.length > MAX_SANDBOX_OUTPUT_FILES) { return exportFailure( + args.routeContext, `Too many sandbox output files requested (${sandboxFiles.length}). Maximum is ${MAX_SANDBOX_OUTPUT_FILES}.`, 400, args.stdout, @@ -1852,6 +1859,7 @@ async function maybeExportSandboxFilesToWorkspace(args: { (args.workflowId ? (await getWorkflowById(args.workflowId))?.workspaceId : undefined) if (!resolvedWorkspaceId || args.routeContext.principal.kind !== 'delegated') { return exportFailure( + args.routeContext, 'Workspace context required to save sandbox files to workspace', 400, args.stdout, @@ -1867,6 +1875,7 @@ async function maybeExportSandboxFilesToWorkspace(args: { const content = args.exportedFiles?.[sandboxPath] if (content === undefined) { return exportFailure( + args.routeContext, `Sandbox file "${sandboxPath}" was not found or could not be read`, 500, args.stdout, @@ -1888,6 +1897,7 @@ async function maybeExportSandboxFilesToWorkspace(args: { totalOutputBytes += size if (totalOutputBytes > MAX_SANDBOX_OUTPUT_BYTES) { return exportFailure( + args.routeContext, `Sandbox output files exceed ${MAX_SANDBOX_OUTPUT_BYTES} bytes total`, 400, args.stdout, @@ -1940,6 +1950,7 @@ async function maybeExportSandboxFilesToWorkspace(args: { validationPaths = validations.map((validation) => validation.vfsPath) } catch (error) { return exportFailure( + args.routeContext, getErrorMessage(error, 'Invalid sandbox output destination'), workspaceFileExportErrorStatus(error), args.stdout, @@ -1952,6 +1963,7 @@ async function maybeExportSandboxFilesToWorkspace(args: { ) if (duplicateDestination) { return exportFailure( + args.routeContext, `Duplicate sandbox output destination: ${duplicateDestination}`, 400, args.stdout, @@ -2009,6 +2021,7 @@ async function maybeExportSandboxFilesToWorkspace(args: { } } catch (error) { return exportFailure( + args.routeContext, getErrorMessage(error, 'Failed to export sandbox files'), workspaceFileExportErrorStatus(error), args.stdout, @@ -2157,7 +2170,8 @@ async function collectSandboxOutputFiles(args: { // reporting success without them would read as "your script wrote nothing". if (!resolvedWorkspaceId || !args.workflowId || !args.executionId) { return { - response: exportFailure( + response: await exportFailure( + routeContext, 'Workspace, workflow, and execution context are required to return files from the sandbox.', 400, args.stdout, @@ -2188,7 +2202,8 @@ async function collectSandboxOutputFiles(args: { ) { await discardUploadedExecutionFiles(files) return { - response: exportFailure( + response: await exportFailure( + routeContext, `Sandbox output file "${name}" contains a resolved secret value and was not returned. Write the file without embedding secret values, or export it to a workspace file where its provenance can be recorded.`, 400, args.stdout, diff --git a/apps/sim/lib/mothership/tools/handlers/workbench-confidentiality.live.test.ts b/apps/sim/lib/mothership/tools/handlers/workbench-confidentiality.live.test.ts index 137e5c6cca8..7dbbc70d263 100644 --- a/apps/sim/lib/mothership/tools/handlers/workbench-confidentiality.live.test.ts +++ b/apps/sim/lib/mothership/tools/handlers/workbench-confidentiality.live.test.ts @@ -512,6 +512,47 @@ describe('persistent workbench output confidentiality', () => { expect(JSON.stringify(output.projected.result)).not.toContain(canary) expect(JSON.stringify(output.projected.result)).toContain('{{TOKEN}}') }) + it('redacts session credentials when an export write fails', async () => { + io.write.mockRejectedValueOnce(new Error('Write unavailable')) + const current = context() + const raw = await inResourceScope(() => + executeFunctionExecute( + { + code: 'printf "%s" "$SIM_API_KEY" > session-key.txt; cat session-key.txt; printf data > export.txt', + language: 'shell', + outputs: { + files: [{ path: 'files/export.txt', sandboxPath: 'export.txt' }], + }, + }, + current + ) + ) + const credential = await readFile(workerPath('session-key.txt'), 'utf8') + const projected = inspectToolResultForCopilot( + raw, + current.resolvedSecretTraceRegistry, + 'function_execute' + ) + expect(raw.success).toBe(false) + expect(projected.safe).toBe(true) + expect(JSON.stringify(projected.result)).not.toContain(credential) + expect(JSON.stringify(projected.result)).toContain('{{SIM_API_KEY}}') + }) + it.each([ + '{ nested: [process.env.SIM_API_KEY] }', + '{ nested: [{ [process.env.SIM_API_KEY]: true }] }', + ])('redacts session credentials in returned %s', async (value) => { + const result = await run( + `(await import("node:fs")).writeFileSync("session-key.txt", process.env.SIM_API_KEY); return ${value}`, + [], + 'javascript' + ) + const credential = await readFile(workerPath('session-key.txt'), 'utf8') + expect(result.raw.success).toBe(true) + expect(result.projected.safe).toBe(true) + expect(JSON.stringify(result.projected.result)).not.toContain(credential) + expect(JSON.stringify(result.projected.result)).toContain('{{SIM_API_KEY}}') + }) it('keeps ordinary binary exports usable when only callback authentication is present', async () => { const result = await inResourceScope(() => executeFunctionExecute( diff --git a/apps/sim/lib/mothership/tools/sandbox-session.ts b/apps/sim/lib/mothership/tools/sandbox-session.ts index 28161df0a7a..0dfa6e92bd4 100644 --- a/apps/sim/lib/mothership/tools/sandbox-session.ts +++ b/apps/sim/lib/mothership/tools/sandbox-session.ts @@ -4,10 +4,14 @@ import { resolve } from 'node:path' import { createLogger } from '@sim/logger' import { getErrorMessage } from '@sim/utils/errors' import { generateId } from '@sim/utils/id' +import { toRecord } from '@sim/utils/object' import { env } from '@/lib/core/config/env' import { encryptSecret } from '@/lib/core/security/encryption' import { getBaseUrl } from '@/lib/core/utils/urls' -import type { DurableSecretProvenance } from '@/lib/execution/durable-secret-provenance' +import { + type DurableSecretProvenance, + EXACT_EMPTY_DURABLE_SECRET_PROVENANCE, +} from '@/lib/execution/durable-secret-provenance' import type { SandboxSessionRequest } from '@/lib/execution/remote-sandbox/types' import { WorkbenchBootstrap } from '@/lib/mothership/generated/workbench' import { fetchGo } from '@/lib/mothership/request/go/fetch' @@ -15,9 +19,29 @@ import { mothershipRequestHeaders } from '@/lib/mothership/request/headers' import { getMothershipBaseURL } from '@/lib/mothership/server/agent-url' import { sandboxResourceEndpoint } from '@/lib/mothership/tools/sandbox-resources' import { getSimConnection } from '@/lib/mothership/transport/connection' +import { + containsResolvedSecret, + createResolvedSecretMatcher, + type ResolvedSecretMatcher, +} from '@/executor/utils/resolved-secret-content-projection' const logger = createLogger('MothershipSandboxSession') +/** Scans parsed sandbox JSON in place, including keys, without allocating a second payload. */ +function containsSessionCredential(value: unknown, matcher: ResolvedSecretMatcher): boolean { + if (typeof value === 'string') return containsResolvedSecret(value, matcher) + if (value === null || typeof value !== 'object') return false + if (Array.isArray(value)) return value.some((item) => containsSessionCredential(item, matcher)) + const record = toRecord(value) + for (const key in record) { + if (!Object.hasOwn(record, key)) continue + if (containsResolvedSecret(key, matcher) || containsSessionCredential(record[key], matcher)) { + return true + } + } + return false +} + /** Public runtime and private bootstrap share an immutable release directory. */ async function workbenchCli( userId: string, @@ -81,13 +105,13 @@ export async function buildMothershipSandboxSession(args: { if (getSimConnection().mode === 'checkpoint') return { key: args.sessionKey } const cli = await workbenchCli(args.userId, args.signal) let cliEnvs: Record | undefined - let outputProvenance: DurableSecretProvenance | undefined + let outputProvenance: SandboxSessionRequest['outputProvenance'] try { const apiKey = `mothership-sandbox:${generateId()}` const endpoint = env.MOTHERSHIP_SANDBOX_CLI_ENDPOINT?.trim() || getBaseUrl() const scopedEndpoint = await sandboxResourceEndpoint(endpoint, args, apiKey) if (scopedEndpoint !== endpoint) { - outputProvenance = { + const provenance: DurableSecretProvenance = { status: 'exact', entries: [ { @@ -98,6 +122,11 @@ export async function buildMothershipSandboxSession(args: { }, ], } + const matcher = createResolvedSecretMatcher([{ plaintext: apiKey, replacement: '' }]) + outputProvenance = (value) => + matcher && containsSessionCredential(value, matcher) + ? provenance + : EXACT_EMPTY_DURABLE_SECRET_PROVENANCE cliEnvs = { SIM_API_KEY: apiKey, ...(args.organizationId From 1dddf80a90a228ec5b2f4d4a5b984abbb4e2c961 Mon Sep 17 00:00:00 2001 From: Vikhyath Mondreti Date: Wed, 30 Sep 2026 19:18:22 -0700 Subject: [PATCH 3/3] fix(sandbox): scan session output without recursion --- .../workbench-confidentiality.live.test.ts | 26 +++++++++++++++ .../lib/mothership/tools/sandbox-session.ts | 32 +++++++++++++------ 2 files changed, 49 insertions(+), 9 deletions(-) diff --git a/apps/sim/lib/mothership/tools/handlers/workbench-confidentiality.live.test.ts b/apps/sim/lib/mothership/tools/handlers/workbench-confidentiality.live.test.ts index 7dbbc70d263..e1457dc6259 100644 --- a/apps/sim/lib/mothership/tools/handlers/workbench-confidentiality.live.test.ts +++ b/apps/sim/lib/mothership/tools/handlers/workbench-confidentiality.live.test.ts @@ -77,6 +77,7 @@ import { readSandboxResourceScope, withSandboxResourceScope, } from '@/lib/mothership/tools/sandbox-resources' +import { buildMothershipSandboxSession } from '@/lib/mothership/tools/sandbox-session' import { chatSandboxSessionKey } from '@/lib/mothership/tools/sandbox-session-key' import { ResolvedSecretTraceRegistry } from '@/executor/utils/resolved-secret-trace-registry' import { buildFunctionExecuteBody, functionExecuteTool } from '@/tools/function/execute' @@ -369,6 +370,31 @@ describe('persistent workbench output confidentiality', () => { expect(result.raw.output).toHaveProperty('result.length', 100_001) expect(result.projected.safe).toBe(true) }) + it.each([ + ['array', '[', ']'], + ['object', '{"nested":', '}'], + ])( + 'classifies deeply nested %s output without exhausting the call stack', + async (_kind, open, close) => { + await inResourceScope(async () => { + const session = await buildMothershipSandboxSession({ + ...scope, + sessionKey: chatSandboxSessionKey(chatId), + }) + const credential = session.envs!.SIM_API_KEY + const nested = (leaf: string) => + JSON.parse(open.repeat(12_000) + JSON.stringify(leaf) + close.repeat(12_000)) + expect(session.outputProvenance!(nested('ordinary output'))).toEqual({ + status: 'exact', + entries: [], + }) + expect(session.outputProvenance!(nested(credential))).toMatchObject({ + status: 'exact', + entries: [{ name: 'SIM_API_KEY' }], + }) + }) + } + ) it('revokes callback authentication before a result reaches the model', async () => { const result = await run( 'printf "%s" "$SIM_API_KEY" > session-key.txt; printf "%s" "$SIM_ENDPOINT" > session-endpoint.txt; printf done' diff --git a/apps/sim/lib/mothership/tools/sandbox-session.ts b/apps/sim/lib/mothership/tools/sandbox-session.ts index 0dfa6e92bd4..c262958226f 100644 --- a/apps/sim/lib/mothership/tools/sandbox-session.ts +++ b/apps/sim/lib/mothership/tools/sandbox-session.ts @@ -27,16 +27,30 @@ import { const logger = createLogger('MothershipSandboxSession') -/** Scans parsed sandbox JSON in place, including keys, without allocating a second payload. */ +/** Scans parsed sandbox JSON without copying the payload or consuming the call stack. */ function containsSessionCredential(value: unknown, matcher: ResolvedSecretMatcher): boolean { - if (typeof value === 'string') return containsResolvedSecret(value, matcher) - if (value === null || typeof value !== 'object') return false - if (Array.isArray(value)) return value.some((item) => containsSessionCredential(item, matcher)) - const record = toRecord(value) - for (const key in record) { - if (!Object.hasOwn(record, key)) continue - if (containsResolvedSecret(key, matcher) || containsSessionCredential(record[key], matcher)) { - return true + function* fields(record: Record): Generator { + for (const key in record) { + if (!Object.hasOwn(record, key)) continue + yield key + yield record[key] + } + } + + const pending: Iterator[] = [[value].values()] + while (pending.length > 0) { + const next = pending[pending.length - 1].next() + if (next.done) { + pending.pop() + continue + } + const current = next.value + if (typeof current === 'string') { + if (containsResolvedSecret(current, matcher)) return true + } else if (Array.isArray(current)) { + pending.push(current.values()) + } else if (current !== null && typeof current === 'object') { + pending.push(fields(toRecord(current))) } } return false