Skip to content

Commit 3fc9011

Browse files
committed
fix(sandbox): redact temporary session credentials in model output
1 parent 55d877b commit 3fc9011

5 files changed

Lines changed: 196 additions & 31 deletions

File tree

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

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,11 @@ export interface SandboxSessionRequest {
9292
cli?: { path: string; content: string; runtime?: { path: string; content: string } }
9393
/** Extra environment variables present on every execution in the session. */
9494
envs?: Record<string, string>
95+
/**
96+
* Ephemeral callback credentials for model-output redaction after execution. They expire with
97+
* the tool lease and do not contribute to durable machine or exported-file provenance.
98+
*/
99+
outputProvenance?: DurableSecretProvenance
95100
/**
96101
* This execution mounts bytes whose secret provenance is unknown, so the machine's input
97102
* history must not stay certified clean even when the caller's own inputs are.

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

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1030,6 +1030,7 @@ interface FunctionRouteExecutionContext {
10301030
runtimeFileSecretTraceRegistry?: ResolvedSecretTraceRegistry
10311031
runtimeInputProvenanceUnrecorded?: boolean
10321032
resolvedSecretTraceRegistry?: ResolvedSecretTraceRegistry
1033+
sessionOutputProvenance?: DurableSecretProvenance
10331034
}
10341035

10351036
/** Keeps bound file provenance in both ordinary Function results and exported artifact bytes. */
@@ -1276,6 +1277,17 @@ async function functionJsonResponse<T>(
12761277
context: FunctionRouteExecutionContext,
12771278
init?: ResponseInit
12781279
) {
1280+
/**
1281+
* Narrow callback receipts to the returned JSON before compaction. Scanning serialized bytes
1282+
* avoids activating secret-only traversal limits on large results that contain no credential.
1283+
*/
1284+
if (context.sessionOutputProvenance && context.resolvedSecretTraceRegistry) {
1285+
await importDurableSecretProvenance(
1286+
context.resolvedSecretTraceRegistry,
1287+
context.sessionOutputProvenance,
1288+
JSON.stringify(body)
1289+
)
1290+
}
12791291
const responseBody = {
12801292
...body,
12811293
largeValueKeys: context.largeValueKeys,
@@ -2487,6 +2499,7 @@ export async function executeFunctionRequest(
24872499
),
24882500
mountedFileSecretProvenanceScanner,
24892501
resolvedSecretTraceRegistry: auth.resolvedSecretTraceRegistry,
2502+
sessionOutputProvenance: admittedSession?.outputProvenance,
24902503
}
24912504

24922505
const lang = isValidCodeLanguage(language) ? language : DEFAULT_CODE_LANGUAGE

‎apps/sim/lib/mothership/tools/handlers/workbench-confidentiality.live.test.ts‎

Lines changed: 162 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,18 @@ import { createDelegatedPrincipal } from '@sim/testing/factories/principal.facto
66
import { createDeferred } from '@sim/testing/helpers/deferred'
77
import { setEnv } from '@sim/testing/mocks/env.mock'
88
import { envFlagsMock } from '@sim/testing/mocks/env-flags.mock'
9+
import {
10+
mothershipAgentUrlMock,
11+
mothershipAgentUrlMockFns,
12+
} from '@sim/testing/mocks/mothership-agent-url.mock'
13+
import {
14+
mothershipAsyncRunsMock,
15+
mothershipAsyncRunsMockFns,
16+
} from '@sim/testing/mocks/mothership-async-runs.mock'
17+
import {
18+
mothershipGoFetchMock,
19+
mothershipGoFetchMockFns,
20+
} from '@sim/testing/mocks/mothership-go-fetch.mock'
921
import { redisConfigMockFns } from '@sim/testing/mocks/redis-config.mock'
1022
import {
1123
remoteSandboxProviderMock,
@@ -30,9 +42,9 @@ vi.mock('@/lib/execution/remote-sandbox/resolve', () => ({
3042
repairMissingSandboxImage: async () => null,
3143
RUNTIME_INSTALL_TIMEOUT_MS: 60_000,
3244
}))
33-
vi.mock('@/lib/mothership/tools/sandbox-session', () => ({
34-
buildMothershipSandboxSession: async (args: { sessionKey: string }) => ({ key: args.sessionKey }),
35-
}))
45+
vi.mock('@/lib/mothership/async-runs/repository', () => mothershipAsyncRunsMock)
46+
vi.mock('@/lib/mothership/request/go/fetch', () => mothershipGoFetchMock)
47+
vi.mock('@/lib/mothership/server/agent-url', () => mothershipAgentUrlMock)
3648
vi.mock('@/lib/workspace-files/application/delegated-principal', () => ({
3749
rebindWorkspaceFileDelegatedPrincipal: ({ principal }: { principal: unknown }) => principal,
3850
}))
@@ -61,6 +73,10 @@ import { inspectToolResultForCopilot } from '@/lib/mothership/request/tools/reso
6173
import type { ToolExecutionContext } from '@/lib/mothership/tool-executor/types'
6274
import { executeFunctionExecute } from '@/lib/mothership/tools/handlers/function-execute'
6375
import { executeRunCode } from '@/lib/mothership/tools/handlers/run-code'
76+
import {
77+
readSandboxResourceScope,
78+
withSandboxResourceScope,
79+
} from '@/lib/mothership/tools/sandbox-resources'
6480
import { chatSandboxSessionKey } from '@/lib/mothership/tools/sandbox-session-key'
6581
import { ResolvedSecretTraceRegistry } from '@/executor/utils/resolved-secret-trace-registry'
6682
import { buildFunctionExecuteBody, functionExecuteTool } from '@/tools/function/execute'
@@ -83,34 +99,47 @@ function workerPath(path: string) {
8399
return path.startsWith('/') ? join(root, path.slice(1)) : join(root, 'home/user', path)
84100
}
85101

102+
async function runWorkerProcess(
103+
executable: string,
104+
args: string[],
105+
options: Parameters<SandboxHandle['runCommand']>[1]
106+
) {
107+
const envs = Object.fromEntries(
108+
Object.entries(options.envs ?? {}).map(([key, value]) => [
109+
key,
110+
value
111+
.replaceAll('/home/user', workerPath('/home/user'))
112+
.replaceAll('/tmp/sim/', `${workerPath('/tmp/sim')}/`)
113+
.replaceAll('/tmp/.sim-private-input-', workerPath('/tmp/.sim-private-input-')),
114+
])
115+
)
116+
try {
117+
const output = await execute(executable, args, {
118+
cwd: workerPath('/home/user'),
119+
env: { PATH: '/usr/bin:/bin:/opt/homebrew/bin', ...envs },
120+
timeout: options.timeoutMs,
121+
maxBuffer: options.maxOutputBytes,
122+
})
123+
return { ...output, exitCode: 0 }
124+
} catch (error) {
125+
const failure = error as { stdout: string; stderr: string; code: number }
126+
return { stdout: failure.stdout, stderr: failure.stderr, exitCode: failure.code }
127+
}
128+
}
129+
86130
function localWorker(): SandboxHandle {
87131
return {
88132
sandboxId: `local-${generateShortId(12)}`,
89-
runCode: async () => {
90-
throw new Error('This reproduction uses actual shell processes')
91-
},
92-
async runCommand(command, options) {
93-
const envs = Object.fromEntries(
94-
Object.entries(options.envs ?? {}).map(([key, value]) => [
95-
key,
96-
value
97-
.replaceAll('/home/user', workerPath('/home/user'))
98-
.replaceAll('/tmp/sim/', `${workerPath('/tmp/sim')}/`),
99-
])
100-
)
101-
try {
102-
const output = await execute('/bin/bash', ['-c', command], {
103-
cwd: workerPath('/home/user'),
104-
env: { PATH: '/usr/bin:/bin:/opt/homebrew/bin', ...envs },
105-
timeout: options.timeoutMs,
106-
maxBuffer: options.maxOutputBytes,
107-
})
108-
return { ...output, exitCode: 0 }
109-
} catch (error) {
110-
const failure = error as { stdout: string; stderr: string; code: number }
111-
return { stdout: failure.stdout, stderr: failure.stderr, exitCode: failure.code }
133+
async runCode(code, options) {
134+
const result = await runWorkerProcess(process.execPath, ['-e', code], options)
135+
return {
136+
text: '',
137+
stdout: result.stdout,
138+
stderr: result.stderr,
139+
...(result.exitCode ? { error: { name: 'RuntimeError', value: result.stderr } } : {}),
112140
}
113141
},
142+
runCommand: (command, options) => runWorkerProcess('/bin/bash', ['-c', command], options),
114143
extendLifetime: async () => {},
115144
getFileSize: async (path) => (await stat(workerPath(path))).size,
116145
readFile: async (path) => readFile(workerPath(path), 'utf8'),
@@ -175,7 +204,16 @@ beforeEach(async () => {
175204
throw new Error('Only the existing disposable worker may be used')
176205
},
177206
})
178-
setEnv({ ENCRYPTION_KEY: 'a'.repeat(64) })
207+
setEnv({
208+
ENCRYPTION_KEY: 'a'.repeat(64),
209+
MOTHERSHIP_SIM_TRANSPORT: 'direct',
210+
MOTHERSHIP_SANDBOX_CLI_ENDPOINT: 'https://callback.test',
211+
})
212+
mothershipAsyncRunsMockFns.mockIsActiveSandboxResourceOwner.mockResolvedValue(true)
213+
mothershipAgentUrlMockFns.mockGetMothershipBaseURL.mockResolvedValue('https://worker.test')
214+
mothershipGoFetchMockFns.mockFetchGo.mockImplementation(async () =>
215+
Response.json({ version: 1, entrypoint: 'fixture-bootstrap' })
216+
)
179217
envFlagsMock.isMothershipSandboxEnabled = true
180218
envFlagsMock.isRemoteSandboxEnabled = true
181219
root = await mkdtemp('/private/tmp/sim-workbench-test-')
@@ -250,9 +288,13 @@ function context(): ToolExecutionContext {
250288
}
251289
}
252290

253-
async function run(code: string, secrets: string[] = []) {
291+
async function run(
292+
code: string,
293+
secrets: string[] = [],
294+
language: 'shell' | 'javascript' = 'shell'
295+
) {
254296
const current = context()
255-
const raw = await executeRunCode({ code, language: 'shell', secrets }, current)
297+
const raw = await inResourceScope(() => executeRunCode({ code, language, secrets }, current))
256298
const projected = inspectToolResultForCopilot(
257299
raw,
258300
current.resolvedSecretTraceRegistry,
@@ -262,7 +304,82 @@ async function run(code: string, secrets: string[] = []) {
262304
return { raw, projected }
263305
}
264306

307+
function inResourceScope<T>(action: () => Promise<T>) {
308+
return withSandboxResourceScope(
309+
{
310+
...scope,
311+
chatId,
312+
runId: 'fixture-run',
313+
toolCallId: 'fixture-call',
314+
ownerToken: 'fixture-owner',
315+
},
316+
AbortSignal.timeout(15_000),
317+
undefined,
318+
action
319+
)
320+
}
321+
265322
describe('persistent workbench output confidentiality', () => {
323+
it.each(['javascript', 'shell'] as const)(
324+
'redacts session credentials in %s output while preserving routing metadata',
325+
async (language) => {
326+
const code =
327+
language === 'shell'
328+
? 'printf "%s" "$SIM_API_KEY" > session-key.txt; printf "%s %s" "$SIM_API_KEY" "$SIM_WORKSPACE"'
329+
: '(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)'
330+
const result = await run(code, [], language)
331+
expect(result.raw.success).toBe(true)
332+
const credential = await readFile(workerPath('session-key.txt'), 'utf8')
333+
expect(credential).toMatch(/^mothership-sandbox:/)
334+
expect(result.projected.safe).toBe(true)
335+
expect(JSON.stringify(result.projected.result)).not.toContain(credential)
336+
expect(JSON.stringify(result.projected.result)).toContain('{{SIM_API_KEY}}')
337+
expect(JSON.stringify(result.projected.result)).toContain(scope.workspaceId)
338+
expect(
339+
await readSessionSecretProvenance(chatSandboxSessionKey(chatId), {
340+
providerId: 'e2b',
341+
sandboxId: machine.sandboxId,
342+
})
343+
).toEqual({ status: 'exact', entries: [] })
344+
}
345+
)
346+
it('redacts session credentials when the provider falls back to a one-shot machine', async () => {
347+
remoteSandboxProviderMockFns.mockResolveProvider.mockReturnValue({
348+
id: 'e2b',
349+
dependencyStrategy: 'prebuilt',
350+
resolveLifetimeMs: (ms: number) => ms,
351+
create: async () => machine,
352+
})
353+
const result = await run('printf "%s" "$SIM_API_KEY" > session-key.txt; cat session-key.txt')
354+
const credential = await readFile(workerPath('session-key.txt'), 'utf8')
355+
expect(result.raw.success).toBe(true)
356+
expect(result.projected.safe).toBe(true)
357+
expect(JSON.stringify(result.projected.result)).not.toContain(credential)
358+
expect(JSON.stringify(result.projected.result)).toContain('{{SIM_API_KEY}}')
359+
})
360+
it('omits session authentication if its encrypted receipt cannot be created', async () => {
361+
setEnv({ ENCRYPTION_KEY: '' })
362+
const result = await run('test -z "$SIM_API_KEY" && printf allowed')
363+
expect(result.raw.success).toBe(true)
364+
expect(JSON.stringify(result.projected.result)).toContain('allowed')
365+
})
366+
it('keeps large ordinary results readable when callback credentials are absent from them', async () => {
367+
const result = await run('return Array.from({ length: 100_001 }, () => 0)', [], 'javascript')
368+
expect(result.raw.success).toBe(true)
369+
expect(result.raw.output).toHaveProperty('result.length', 100_001)
370+
expect(result.projected.safe).toBe(true)
371+
})
372+
it('revokes callback authentication before a result reaches the model', async () => {
373+
const result = await run(
374+
'printf "%s" "$SIM_API_KEY" > session-key.txt; printf "%s" "$SIM_ENDPOINT" > session-endpoint.txt; printf done'
375+
)
376+
const credential = await readFile(workerPath('session-key.txt'), 'utf8')
377+
const endpoint = await readFile(workerPath('session-endpoint.txt'), 'utf8')
378+
expect(result.raw.success).toBe(true)
379+
expect(result.projected.safe).toBe(true)
380+
expect(await readSandboxResourceScope(endpoint.split('/').at(-1)!, credential)).toBeNull()
381+
})
382+
266383
it('allows a mounted empty value without requiring a redaction receipt', async () => {
267384
const emptyCatalog = [
268385
{ name: 'TOKEN', plaintext: '', encryptedValue: (await encryptSecret('')).encrypted },
@@ -395,6 +512,22 @@ describe('persistent workbench output confidentiality', () => {
395512
expect(JSON.stringify(output.projected.result)).not.toContain(canary)
396513
expect(JSON.stringify(output.projected.result)).toContain('{{TOKEN}}')
397514
})
515+
it('keeps ordinary binary exports usable when only callback authentication is present', async () => {
516+
const result = await inResourceScope(() =>
517+
executeFunctionExecute(
518+
{
519+
code: "printf '\\211PNG\\000\\001' > image.png",
520+
language: 'shell',
521+
outputs: { files: [{ path: 'files/image.png', sandboxPath: 'image.png' }] },
522+
},
523+
context()
524+
)
525+
)
526+
expect(result.success).toBe(true)
527+
const saved = io.write.mock.calls.at(-1)![0]
528+
expect(saved.buffer).toEqual(Buffer.from([0x89, 0x50, 0x4e, 0x47, 0, 1]))
529+
expect(saved.secretProvenance).toEqual({ status: 'exact', entries: [] })
530+
})
398531
it('retains historical secret provenance on a text export', async () => {
399532
await run('printf "%s" "$TOKEN" > saved.txt', ['TOKEN'])
400533
const current = context()

‎apps/sim/lib/mothership/tools/sandbox-session.test.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@ const fetchBootstrap = mothershipGoFetchMockFns.mockFetchGo
3131
const baseURL = mothershipAgentUrlMockFns.mockGetMothershipBaseURL
3232

3333
urlsMockFns.mockGetBaseUrl.mockReturnValue('https://unused.test')
34-
setEnv({ MOTHERSHIP_SANDBOX_CLI_ENDPOINT: 'https://sim.test' })
34+
setEnv({ MOTHERSHIP_SANDBOX_CLI_ENDPOINT: 'https://sim.test', ENCRYPTION_KEY: 'a'.repeat(64) })
3535

3636
const request = { sessionKey: 'chat', workspaceId: 'workspace', userId: 'user' }
3737

‎apps/sim/lib/mothership/tools/sandbox-session.ts‎

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,9 @@ import { createLogger } from '@sim/logger'
55
import { getErrorMessage } from '@sim/utils/errors'
66
import { generateId } from '@sim/utils/id'
77
import { env } from '@/lib/core/config/env'
8+
import { encryptSecret } from '@/lib/core/security/encryption'
89
import { getBaseUrl } from '@/lib/core/utils/urls'
10+
import type { DurableSecretProvenance } from '@/lib/execution/durable-secret-provenance'
911
import type { SandboxSessionRequest } from '@/lib/execution/remote-sandbox/types'
1012
import { WorkbenchBootstrap } from '@/lib/mothership/generated/workbench'
1113
import { fetchGo } from '@/lib/mothership/request/go/fetch'
@@ -79,11 +81,23 @@ export async function buildMothershipSandboxSession(args: {
7981
if (getSimConnection().mode === 'checkpoint') return { key: args.sessionKey }
8082
const cli = await workbenchCli(args.userId, args.signal)
8183
let cliEnvs: Record<string, string> | undefined
84+
let outputProvenance: DurableSecretProvenance | undefined
8285
try {
8386
const apiKey = `mothership-sandbox:${generateId()}`
8487
const endpoint = env.MOTHERSHIP_SANDBOX_CLI_ENDPOINT?.trim() || getBaseUrl()
8588
const scopedEndpoint = await sandboxResourceEndpoint(endpoint, args, apiKey)
8689
if (scopedEndpoint !== endpoint) {
90+
outputProvenance = {
91+
status: 'exact',
92+
entries: [
93+
{
94+
name: 'SIM_API_KEY',
95+
encryptedValue: (await encryptSecret(apiKey)).encrypted,
96+
sourceUserId: args.userId,
97+
...(args.workspaceId ? { sourceWorkspaceId: args.workspaceId } : {}),
98+
},
99+
],
100+
}
87101
cliEnvs = {
88102
SIM_API_KEY: apiKey,
89103
...(args.organizationId
@@ -101,6 +115,6 @@ export async function buildMothershipSandboxSession(args: {
101115
return {
102116
key: args.sessionKey,
103117
cli,
104-
...(cliEnvs ? { envs: cliEnvs } : {}),
118+
...(cliEnvs ? { envs: cliEnvs, outputProvenance } : {}),
105119
}
106120
}

0 commit comments

Comments
 (0)