diff --git a/apps/sim/lib/execution/remote-sandbox/index.ts b/apps/sim/lib/execution/remote-sandbox/index.ts index c4aef7154b2..275a6609284 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,25 @@ 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) +} + +/** + * 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, @@ -677,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 @@ -712,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) { @@ -725,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)) { @@ -752,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)) { @@ -925,6 +956,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 +966,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 +986,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 +1009,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 +1161,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 +1178,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..be51a1de4b8 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,152 @@ 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.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) 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, } }