Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
59 changes: 48 additions & 11 deletions apps/sim/lib/execution/remote-sandbox/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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')

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 security Non-ASCII endpoints escape masking

If the configured sandbox CLI endpoint contains a non-ASCII character and a program writes its UTF-8 bytes to a binary or harvested file, this code reads those bytes as Latin-1 before masking. The endpoint string no longer matches, so the returned file can retain the token-bearing URL. This leaves a copy in the exported output despite the session mask.

How this was verified: Binary file bytes are compared as Latin-1 text against the configured endpoint string, which is not Unicode-normalized.

Knowledge Base Used: Agent execution and sandbox tasks

return Buffer.from(mask(bytes), 'latin1').toString('base64')
Comment thread
waleedlatif1 marked this conversation as resolved.
}

function bindSandboxAbort(
sandbox: SandboxHandle,
provider: SandboxProviderId,
Expand Down Expand Up @@ -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<string, string>
Expand Down Expand Up @@ -712,6 +737,7 @@ async function collectExportedFiles(
}
}

const mask = req.session?.secretEnvs ? sessionSecretMask(req.session) : undefined
const exportedFiles: Record<string, string> = {}
let readOutputBytes = 0
for (const outputSandboxPath of readablePaths) {
Expand All @@ -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)) {
Expand All @@ -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)) {
Expand Down Expand Up @@ -925,6 +956,7 @@ async function executeInSandboxWithinBudget(
const executionEnvironment = {
...selected?.envs,
...req.session?.envs,
...req.session?.secretEnvs,
Comment thread
waleedlatif1 marked this conversation as resolved.
...(req.session?.cli
? { PATH: sessionCommandPath(req.session, selected?.envs?.PATH ?? SANDBOX_SYSTEM_PATH) }
: {}),
Expand All @@ -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
Expand All @@ -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,
Expand All @@ -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')
)
Comment thread
waleedlatif1 marked this conversation as resolved.

const extraction = extractSimResult(combinedOutput)
const cleanedStdout = extraction.cleanedStdout
Expand Down Expand Up @@ -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 } : {}),
Expand All @@ -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,
Expand Down
146 changes: 146 additions & 0 deletions apps/sim/lib/execution/remote-sandbox/session-sandbox.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string> = {}
const printEnv = (envs: Record<string, string> = {}) => {
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<string, Buffer>()
const write = (envs: Record<string, string> = {}) => {
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)
Expand Down
2 changes: 2 additions & 0 deletions apps/sim/lib/execution/remote-sandbox/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string>
/** Capability variables present on every execution; their values are masked in its output. */
secretEnvs?: Record<string, string>
/**
* 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.
Expand Down
10 changes: 6 additions & 4 deletions apps/sim/lib/mothership/tools/sandbox-session.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
})
})
Expand Down Expand Up @@ -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()
})
11 changes: 5 additions & 6 deletions apps/sim/lib/mothership/tools/sandbox-session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string> | undefined
let cliEnvs: Pick<SandboxSessionRequest, 'envs' | 'secretEnvs'> | 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) {
Expand All @@ -101,6 +100,6 @@ export async function buildMothershipSandboxSession(args: {
return {
key: args.sessionKey,
cli,
...(cliEnvs ? { envs: cliEnvs } : {}),
...cliEnvs,
}
}
Loading