From 919b56688a3fae3b1b0c5a86220839132fdb2637 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Wed, 30 Sep 2026 18:14:47 -0700 Subject: [PATCH 1/2] fix(sandbox): mask the session's CLI capability in returned sandbox output The Mothership session sandbox exports SIM_API_KEY and the token-bearing SIM_ENDPOINT to every execution, but nothing masked their values, so code that printed its environment returned them to the model and transcript. The session request now carries them as secretEnvs, and the sandbox masks their values in stdout, stderr, the result payload, and error text before any of it is parsed or returned. Provenance recording is unchanged. --- .../sim/lib/execution/remote-sandbox/index.ts | 31 +++++++--- .../remote-sandbox/session-sandbox.test.ts | 61 +++++++++++++++++++ .../sim/lib/execution/remote-sandbox/types.ts | 2 + .../mothership/tools/sandbox-session.test.ts | 10 +-- .../lib/mothership/tools/sandbox-session.ts | 11 ++-- 5 files changed, 98 insertions(+), 17 deletions(-) diff --git a/apps/sim/lib/execution/remote-sandbox/index.ts b/apps/sim/lib/execution/remote-sandbox/index.ts index c4aef7154b2..75cf0eaaafc 100644 --- a/apps/sim/lib/execution/remote-sandbox/index.ts +++ b/apps/sim/lib/execution/remote-sandbox/index.ts @@ -9,6 +9,7 @@ import { isTimeoutAbortReason, } from '@/lib/core/execution-limits' import { recordSandboxTeardownFailure } from '@/lib/core/execution-limits/metrics' +import { redactKnownSensitiveValues } from '@/lib/core/security/redaction' import { buildJavaScriptRuntimeBindingsSource } from '@/lib/execution/code-placeholders/javascript-runtime' import { SANDBOX_SYSTEM_PATH } from '@/lib/execution/remote-sandbox/cli-tools.server' import { @@ -236,6 +237,16 @@ function throwIfSandboxTimedOut(result: { timedOut?: boolean }): void { if (result.timedOut) throw new DOMException('timeout', 'AbortError') } +/** + * Masks the session's capability values in program output before it is parsed or returned. They + * are revoked when the call ends, but a printed copy would still reach the model and transcript. + */ +function sessionSecretMask(session: SandboxSessionRequest | undefined): (output: string) => string { + const secrets = Object.values(session?.secretEnvs ?? {}) + if (secrets.length === 0) return (output) => output + return (output) => redactKnownSensitiveValues(output, secrets) +} + function bindSandboxAbort( sandbox: SandboxHandle, provider: SandboxProviderId, @@ -925,6 +936,7 @@ async function executeInSandboxWithinBudget( const executionEnvironment = { ...selected?.envs, ...req.session?.envs, + ...req.session?.secretEnvs, ...(req.session?.cli ? { PATH: sessionCommandPath(req.session, selected?.envs?.PATH ?? SANDBOX_SYSTEM_PATH) } : {}), @@ -934,6 +946,7 @@ async function executeInSandboxWithinBudget( const hasExecutionEnvironment = selected?.envs !== undefined || req.session?.envs !== undefined || + req.session?.secretEnvs !== undefined || req.session?.cli !== undefined || (req.session !== undefined && req.outputSandboxDir !== undefined) || Object.keys(privateInputFiles.environment).length > 0 @@ -953,16 +966,17 @@ async function executeInSandboxWithinBudget( } throwIfAborted(signal) throwIfSandboxTimedOut(execution) + const mask = sessionSecretMask(req.session) if (execution.error) { - const errorMessage = `${execution.error.name}: ${execution.error.value}` + const errorMessage = mask(`${execution.error.name}: ${execution.error.value}`) logger.error('Sandbox execution failed', { sandboxId, hasTraceback: Boolean(execution.error.traceback), }) const executionResult = { result: null, - stdout: execution.error.traceback || errorMessage, + stdout: execution.error.traceback ? mask(execution.error.traceback) : errorMessage, error: errorMessage, sandboxId, ...sessionField, @@ -975,9 +989,9 @@ async function executeInSandboxWithinBudget( // the marker is found no matter which stream carried it. Each individual // stream is already concatenated verbatim by the provider, because injecting // a newline at chunk boundaries corrupted large single-line payloads. - const combinedOutput = [execution.text, execution.stdout, execution.stderr] - .filter(Boolean) - .join('\n') + const combinedOutput = mask( + [execution.text, execution.stdout, execution.stderr].filter(Boolean).join('\n') + ) const extraction = extractSimResult(combinedOutput) const cleanedStdout = extraction.cleanedStdout @@ -1127,6 +1141,7 @@ async function executeShellInSandboxWithinBudget( ...selected?.envs, ...envs, ...req.session?.envs, + ...req.session?.secretEnvs, PATH: sessionCommandPath(req.session, selected?.envs?.PATH ?? SANDBOX_SYSTEM_PATH), ...privateInputFiles.environment, ...(req.session && req.outputSandboxDir ? { SIM_OUTPUT_DIR: req.outputSandboxDir } : {}), @@ -1143,14 +1158,16 @@ async function executeShellInSandboxWithinBudget( } throwIfAborted(signal) throwIfSandboxTimedOut(result) + const mask = sessionSecretMask(req.session) - const stdout = [result.stdout, result.stderr].filter(Boolean).join('\n') + const stdout = mask([result.stdout, result.stderr].filter(Boolean).join('\n')) if (result.exitCode !== 0) { // Daytona merges both streams into stdout (stderr is always empty), so fall // back to stdout for the real command output before the generic message. - const errorMessage = + const errorMessage = mask( result.stderr || result.stdout || `Process exited with code ${result.exitCode}` + ) logger.error('Sandbox shell execution error', { sandboxId, exitCode: result.exitCode, diff --git a/apps/sim/lib/execution/remote-sandbox/session-sandbox.test.ts b/apps/sim/lib/execution/remote-sandbox/session-sandbox.test.ts index 0e18b1b07b6..a2cfefc7a61 100644 --- a/apps/sim/lib/execution/remote-sandbox/session-sandbox.test.ts +++ b/apps/sim/lib/execution/remote-sandbox/session-sandbox.test.ts @@ -596,6 +596,67 @@ describe('session sandbox lease', () => { expect(calls.killed).toBe(false) }) + it.each([ + ['code', 'completes'], + ['code', 'fails'], + ['shell', 'completes'], + ['shell', 'fails'], + ] as const)( + 'masks session capability values when printed by %s that %s', + async (kind, outcome) => { + const { handle } = fakeSandbox(`capability-${kind}-${outcome}`) + mockFindSessionSandbox.mockResolvedValue(handle) + const apiKey = 'mothership-sandbox:6f1c2a8e-3b4d-4e5f-8a9b-0c1d2e3f4a5b' + const endpoint = + 'https://sim.test/api/mothership/sandbox/9a8b7c6d-5e4f-4a3b-8c2d-1e0f9a8b7c6d' + let received: Record = {} + const printEnv = (envs: Record = {}) => { + received = envs + return Object.entries(envs) + .map(([name, value]) => `${name}=${value}`) + .join('\n') + } + handle.runCode = async (_code, options) => { + const printed = printEnv(options.envs) + return outcome === 'fails' + ? { + text: '', + stdout: printed, + stderr: '', + error: { name: 'Error', value: printed, traceback: printed }, + } + : { + text: `${SIM_RESULT_PREFIX}${JSON.stringify({ env: options.envs })}`, + stdout: printed, + stderr: encodeURIComponent(endpoint), + } + } + handle.runCommand = async (_command, options) => { + const printed = printEnv(options.envs) + return outcome === 'fails' + ? { stdout: '', stderr: printed, exitCode: 1 } + : { stdout: `${printed}\n${SIM_RESULT_PREFIX}${apiKey}`, stderr: '', exitCode: 0 } + } + const session = { + key: `capability-${kind}-${outcome}`, + envs: { SIM_WORKSPACE: 'workspace-visible' }, + secretEnvs: { SIM_API_KEY: apiKey, SIM_ENDPOINT: endpoint }, + } + const result = + kind === 'code' + ? await executeInSandbox({ ...CODE_REQUEST, session }) + : await executeShellInSandbox({ ...CODE_REQUEST, envs: {}, session }) + + expect(received).toMatchObject({ ...session.envs, ...session.secretEnvs }) + const output = JSON.stringify(result) + expect(output).not.toContain(apiKey) + expect(output).not.toContain(endpoint) + expect(output).not.toContain(encodeURIComponent(endpoint)) + expect(output).toContain('SIM_API_KEY=[REDACTED]') + expect(output).toContain('SIM_WORKSPACE=workspace-visible') + } + ) + it('keeps a completed result when temporary input cleanup is unavailable', async () => { const { handle, calls } = fakeSandbox('cleanup-failure') mockFindSessionSandbox.mockResolvedValue(handle) diff --git a/apps/sim/lib/execution/remote-sandbox/types.ts b/apps/sim/lib/execution/remote-sandbox/types.ts index d1b013ca3c5..87a1505fcd9 100644 --- a/apps/sim/lib/execution/remote-sandbox/types.ts +++ b/apps/sim/lib/execution/remote-sandbox/types.ts @@ -92,6 +92,8 @@ 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 + /** Capability variables present on every execution; their values are masked in its output. */ + secretEnvs?: Record /** * 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/mothership/tools/sandbox-session.test.ts b/apps/sim/lib/mothership/tools/sandbox-session.test.ts index 5f6aa239f8e..cb2aa224dc9 100644 --- a/apps/sim/lib/mothership/tools/sandbox-session.test.ts +++ b/apps/sim/lib/mothership/tools/sandbox-session.test.ts @@ -77,10 +77,10 @@ describe('deployment-owned workbench tooling', () => { expect(JSON.stringify(first.cli)).not.toContain('test-delegation') expect(mint).not.toHaveBeenCalled() expect(JSON.stringify(first)).not.toContain('test-delegation') - expect(first.envs?.SIM_API_KEY).not.toBe(second.envs?.SIM_API_KEY) - expect(first.envs).toEqual({ + expect(first.secretEnvs?.SIM_API_KEY).not.toBe(second.secretEnvs?.SIM_API_KEY) + expect(first.envs).toEqual({ SIM_WORKSPACE: 'workspace' }) + expect(first.secretEnvs).toEqual({ SIM_API_KEY: expect.stringMatching(/^mothership-sandbox:[0-9a-f-]{36}$/), - SIM_WORKSPACE: 'workspace', SIM_ENDPOINT: 'https://sim.test/api/mothership/sandbox/owned-token', }) }) @@ -134,7 +134,9 @@ it('does not inject authentication when no active scoped callback can be establi fetchBootstrap.mockResolvedValue(Response.json({ version: 1, entrypoint: 'private-entry' })) read.mockResolvedValue('bundle') endpoint.mockImplementation(async (url) => url) - expect((await buildMothershipSandboxSession(request)).envs).toBeUndefined() + const session = await buildMothershipSandboxSession(request) + expect(session.envs).toBeUndefined() + expect(session.secretEnvs).toBeUndefined() expect(endpoint).toHaveBeenCalledOnce() expect(mint).not.toHaveBeenCalled() }) diff --git a/apps/sim/lib/mothership/tools/sandbox-session.ts b/apps/sim/lib/mothership/tools/sandbox-session.ts index ae4c08a9cec..f54160b8bb2 100644 --- a/apps/sim/lib/mothership/tools/sandbox-session.ts +++ b/apps/sim/lib/mothership/tools/sandbox-session.ts @@ -78,18 +78,17 @@ export async function buildMothershipSandboxSession(args: { args.signal?.throwIfAborted() if (getSimConnection().mode === 'checkpoint') return { key: args.sessionKey } const cli = await workbenchCli(args.userId, args.signal) - let cliEnvs: Record | undefined + let cliEnvs: Pick | 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) { cliEnvs = { - SIM_API_KEY: apiKey, - ...(args.organizationId + envs: args.organizationId ? { SIM_ORGANIZATION_ID: args.organizationId } - : { SIM_WORKSPACE: args.workspaceId! }), - SIM_ENDPOINT: scopedEndpoint, + : { SIM_WORKSPACE: args.workspaceId! }, + secretEnvs: { SIM_API_KEY: apiKey, SIM_ENDPOINT: scopedEndpoint }, } } } catch (error) { @@ -101,6 +100,6 @@ export async function buildMothershipSandboxSession(args: { return { key: args.sessionKey, cli, - ...(cliEnvs ? { envs: cliEnvs } : {}), + ...cliEnvs, } } From 6cc8341cca96514097f30497fbb98f24c969304d Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Wed, 30 Sep 2026 18:39:19 -0700 Subject: [PATCH 2/2] fix(sandbox): mask the session's capability in exported and harvested files Code could write SIM_API_KEY or SIM_ENDPOINT to a declared output file or into SIM_OUTPUT_DIR, and those bytes were returned unmasked. Text exports are now masked like stdout; binary and harvested files are masked over their raw bytes through a latin1 round trip, so every other byte survives unchanged and the reported byteLength matches the masked content. --- .../sim/lib/execution/remote-sandbox/index.ts | 28 +++++- .../remote-sandbox/session-sandbox.test.ts | 85 +++++++++++++++++++ 2 files changed, 109 insertions(+), 4 deletions(-) diff --git a/apps/sim/lib/execution/remote-sandbox/index.ts b/apps/sim/lib/execution/remote-sandbox/index.ts index 75cf0eaaafc..275a6609284 100644 --- a/apps/sim/lib/execution/remote-sandbox/index.ts +++ b/apps/sim/lib/execution/remote-sandbox/index.ts @@ -247,6 +247,15 @@ function sessionSecretMask(session: SandboxSessionRequest | undefined): (output: return (output) => redactKnownSensitiveValues(output, secrets) } +/** + * Applies {@link sessionSecretMask} to a file's raw bytes. Latin-1 maps every byte to one code + * unit and back, so every byte outside a masked value, including non-UTF-8 binary, survives as-is. + */ +function maskFileBytes(contentBase64: string, mask: (output: string) => string): string { + const bytes = Buffer.from(contentBase64, 'base64').toString('latin1') + return Buffer.from(mask(bytes), 'latin1').toString('base64') +} + function bindSandboxAbort( sandbox: SandboxHandle, provider: SandboxProviderId, @@ -688,7 +697,12 @@ async function ensureSandboxOutputDir( async function collectExportedFiles( sandbox: SandboxHandle, - req: { outputSandboxPath?: string; outputSandboxPaths?: string[]; outputSandboxDir?: string }, + req: { + outputSandboxPath?: string + outputSandboxPaths?: string[] + outputSandboxDir?: string + session?: SandboxSessionRequest + }, options: { signal: AbortSignal } ): Promise<{ exportedFiles?: Record @@ -723,6 +737,7 @@ async function collectExportedFiles( } } + const mask = req.session?.secretEnvs ? sessionSecretMask(req.session) : undefined const exportedFiles: Record = {} let readOutputBytes = 0 for (const outputSandboxPath of readablePaths) { @@ -736,7 +751,11 @@ async function collectExportedFiles( if (file !== undefined) { remainingSandboxBudgetMs(options.signal) readOutputBytes += file.byteLength - exportedFiles[outputSandboxPath] = file.content + exportedFiles[outputSandboxPath] = !mask + ? file.content + : isBinarySandboxPath(outputSandboxPath) + ? maskFileBytes(file.content, mask) + : mask(file.content) } } catch (error) { if (isSandboxOutputLimitError(error)) { @@ -763,11 +782,12 @@ async function collectExportedFiles( }) remainingSandboxBudgetMs(options.signal) readOutputBytes += file.byteLength + const contentBase64 = mask ? maskFileBytes(file.content, mask) : file.content collectedFiles.push({ path: entry.path, relativePath: entry.relativePath, - contentBase64: file.content, - byteLength: file.byteLength, + contentBase64, + byteLength: mask ? Buffer.byteLength(contentBase64, 'base64') : file.byteLength, }) } catch (error) { if (isSandboxOutputLimitError(error)) { diff --git a/apps/sim/lib/execution/remote-sandbox/session-sandbox.test.ts b/apps/sim/lib/execution/remote-sandbox/session-sandbox.test.ts index a2cfefc7a61..be51a1de4b8 100644 --- a/apps/sim/lib/execution/remote-sandbox/session-sandbox.test.ts +++ b/apps/sim/lib/execution/remote-sandbox/session-sandbox.test.ts @@ -657,6 +657,91 @@ describe('session sandbox lease', () => { } ) + it.each(['code', 'shell'] as const)( + 'masks session capability values in files exported by %s', + async (kind) => { + const { handle } = fakeSandbox(`capability-files-${kind}`) + mockFindSessionSandbox.mockResolvedValue(handle) + const apiKey = 'mothership-sandbox:6f1c2a8e-3b4d-4e5f-8a9b-0c1d2e3f4a5b' + const endpoint = + 'https://sim.test/api/mothership/sandbox/9a8b7c6d-5e4f-4a3b-8c2d-1e0f9a8b7c6d' + const outputDir = '/tmp/sim/outputs/capability' + const files = new Map() + const write = (envs: Record = {}) => { + const binary = Buffer.concat([ + Buffer.from([0xff, 0x00]), + Buffer.from(`${envs.SIM_API_KEY} ${encodeURIComponent(envs.SIM_ENDPOINT)}`), + Buffer.from([0x80, 0xfe]), + ]) + files.set('/home/user/report.txt', Buffer.from(`key=${envs.SIM_API_KEY}\n`)) + files.set('/home/user/chart.png', binary) + files.set(`${envs.SIM_OUTPUT_DIR}/dump.bin`, binary) + } + handle.runCode = async (_code, options) => { + write(options.envs) + return { text: `${SIM_RESULT_PREFIX}true`, stdout: '', stderr: '' } + } + handle.runCommand = async (_command, options) => { + write(options.envs) + return { stdout: '', stderr: '', exitCode: 0 } + } + handle.getFileSize = async (path) => files.get(path)?.byteLength ?? 0 + handle.listFiles = async (directory) => + [...files].flatMap(([path, content]) => + path.startsWith(`${directory}/`) + ? [ + { + path, + relativePath: path.slice(directory.length + 1), + kind: 'file', + size: content.byteLength, + }, + ] + : [] + ) + handle.readFileWithLimit = async (path, options) => { + const content = files.get(path) + if (content === undefined) throw new Error('Missing file') + return { + content: content.toString(options.encoding === 'base64' ? 'base64' : 'utf8'), + byteLength: content.byteLength, + } + } + const request = { + session: { + key: `capability-files-${kind}`, + secretEnvs: { SIM_API_KEY: apiKey, SIM_ENDPOINT: endpoint }, + }, + outputSandboxPath: '/home/user/report.txt', + outputSandboxPaths: ['/home/user/chart.png'], + outputSandboxDir: outputDir, + } + const result = + kind === 'code' + ? await executeInSandbox({ ...CODE_REQUEST, ...request }) + : await executeShellInSandbox({ ...CODE_REQUEST, envs: {}, ...request }) + + const masked = Buffer.concat([ + Buffer.from([0xff, 0x00]), + Buffer.from('[REDACTED] [REDACTED]'), + Buffer.from([0x80, 0xfe]), + ]) + expect(result.exportedFileContent).toBe('key=[REDACTED]\n') + expect(result.exportedFiles).toEqual({ + '/home/user/report.txt': 'key=[REDACTED]\n', + '/home/user/chart.png': masked.toString('base64'), + }) + expect(result.collectedFiles).toEqual([ + { + path: `${outputDir}/dump.bin`, + relativePath: 'dump.bin', + contentBase64: masked.toString('base64'), + byteLength: masked.byteLength, + }, + ]) + } + ) + it('keeps a completed result when temporary input cleanup is unavailable', async () => { const { handle, calls } = fakeSandbox('cleanup-failure') mockFindSessionSandbox.mockResolvedValue(handle)