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 e1457dc6259..83042c5f896 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 @@ -18,6 +18,10 @@ import { mothershipGoFetchMock, mothershipGoFetchMockFns, } from '@sim/testing/mocks/mothership-go-fetch.mock' +import { + mothershipWorkspaceTargetMock, + mothershipWorkspaceTargetMockFns, +} from '@sim/testing/mocks/mothership-workspace-target.mock' import { redisConfigMockFns } from '@sim/testing/mocks/redis-config.mock' import { remoteSandboxProviderMock, @@ -30,6 +34,7 @@ import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest' const io = vi.hoisted(() => ({ mount: vi.fn(), find: vi.fn(), write: vi.fn() })) vi.mock('@/tools', () => toolsMock) +vi.mock('@/lib/mothership/application/workspace-target', () => mothershipWorkspaceTargetMock) vi.mock('@/lib/mothership/tools/secret-mount-materializer.server', () => ({ materializeCopilotCodeSecrets: io.mount, CopilotCodeSecretAccessError: class extends Error {}, @@ -54,6 +59,7 @@ vi.mock('@/lib/mothership/vfs/resource-writer', () => ({ })) import { functionExecuteBodySchema } from '@/lib/api/contracts' +import * as inProcessTransport from '@/lib/api/server/routes/in-process-transport' import { encryptSecret } from '@/lib/core/security/encryption' import { PRIVATE_TOOL_METADATA_REQUEST_HEADER, @@ -73,12 +79,15 @@ 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 { proxySandboxResourceRequest } from '@/lib/mothership/tools/sandbox-resource-transport' 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 { reportTableRowDelivery } from '@/lib/table/application/row-delivery-observer' +import { reportWorkspaceFileDelivery } from '@/lib/workspace-files/application/file-delivery-observer' import { ResolvedSecretTraceRegistry } from '@/executor/utils/resolved-secret-trace-registry' import { buildFunctionExecuteBody, functionExecuteTool } from '@/tools/function/execute' import type { CodeExecutionInput } from '@/tools/function/types' @@ -320,6 +329,161 @@ function inResourceScope(action: () => Promise) { ) } +async function sandboxApi(path: string, handler: () => Promise, method = 'GET') { + mothershipWorkspaceTargetMockFns.mockResolveInvocationWorkspace.mockResolvedValue(scope) + vi.spyOn(inProcessTransport, 'matchV2Route').mockReturnValue({ + pattern: path, + params: { fileId: 'fixture', tableId: 'fixture' }, + literals: 3, + load: async () => ({ GET: handler, POST: handler }), + }) + return inResourceScope(async () => { + const session = await buildMothershipSandboxSession({ + ...scope, + sessionKey: chatSandboxSessionKey(chatId), + }) + const endpoint = session.envs!.SIM_ENDPOINT + return proxySandboxResourceRequest( + new Request(`${endpoint}${path}`, { + method, + headers: { 'x-api-key': session.envs!.SIM_API_KEY }, + }), + endpoint.split('/').at(-1)! + ) + }) +} + +describe('sandbox API provenance admission', () => { + it('keeps ordinary API mutations usable for later code output and generated CLI input', async () => { + const response = await sandboxApi( + '/api/v2/custom-tools', + async () => Response.json({ data: { id: 'fixture-tool', title: 'fixture' } }), + 'POST' + ) + expect(response.status).toBe(200) + const result = await run('printf "[]" > operations.json; printf "ready"') + expect(result.projected.safe).toBe(true) + expect(result.projected.result).toMatchObject({ success: true, output: { stdout: 'ready' } }) + expect( + (await readCliInputFile(chatSandboxSessionKey(chatId), 'operations.json')).toString() + ).toBe('[]') + }) + + it('retains earlier secret protection after an API response without provenance', async () => { + await run('printf "%s" "$TOKEN" > saved.txt', ['TOKEN']) + await sandboxApi('/api/v2/custom-tools', async () => Response.json({ data: [] })) + const result = await run('cat saved.txt') + expect(result.projected.safe).toBe(true) + expect(result.projected.result).toMatchObject({ + success: true, + output: { stdout: '{{TOKEN}}' }, + }) + await expect(readCliInputFile(chatSandboxSessionKey(chatId), 'saved.txt')).rejects.toThrow( + 'protected workbench values' + ) + }) + + it.each(['file', 'table'] as const)( + 'imports explicit %s delivery evidence before later output', + async (source) => { + const response = await sandboxApi( + `/api/v2/${source === 'file' ? 'files/fixture' : 'tables/fixture/rows'}`, + async () => { + if (source === 'file') { + await reportWorkspaceFileDelivery({ + status: 'exact', + entries: [ + { + name: 'TOKEN', + encryptedValue: catalog[0].encryptedValue, + sourceUserId: scope.userId, + sourceWorkspaceId: scope.workspaceId, + }, + ], + }) + } else { + await reportTableRowDelivery( + { + version: 1, + complete: true, + scope, + entries: [{ name: 'TOKEN', encryptedValue: catalog[0].encryptedValue }], + }, + [{ value: canary }] + ) + } + return new Response(canary) + } + ) + await machine.writeFile('/home/user/delivered.txt', await response.text()) + const result = await run('cat delivered.txt') + expect(result.projected.safe).toBe(true) + expect(result.projected.result).toMatchObject({ + success: true, + output: { stdout: '{{TOKEN}}' }, + }) + } + ) + + it('preserves mutation completion and withholds its body when provenance storage fails', async () => { + const mutationPath = join(root, 'mutation.json') + const response = await sandboxApi( + '/api/v2/tables/fixture/rows', + async () => { + await writeFile(mutationPath, JSON.stringify({ committed: true, completed: false })) + const evalCommand = redis.eval.bind(redis) + vi.spyOn(redis, 'eval').mockImplementation((...args) => { + if (String(args[2]).startsWith('mothership:workbench-provenance:v2:')) { + return Promise.reject(new Error('Synthetic provenance storage failure')) + } + return evalCommand(...args) + }) + await reportTableRowDelivery( + { + version: 1, + complete: true, + scope, + entries: [{ name: 'TOKEN', encryptedValue: catalog[0].encryptedValue }], + }, + [{ value: canary }] + ) + await writeFile(mutationPath, JSON.stringify({ committed: true, completed: true })) + return Response.json({ data: { value: canary } }, { status: 201 }) + }, + 'POST' + ) + expect(JSON.parse(await readFile(mutationPath, 'utf8'))).toEqual({ + committed: true, + completed: true, + }) + expect(response.status).toBe(502) + const body = await response.text() + expect(body).not.toContain(canary) + expect(body).toContain('completed with HTTP 201') + expect(body).toContain('Do not retry a mutation automatically') + }) + + it.each(['file', 'table'] as const)( + 'preserves an explicit unknown %s delivery as unknown', + async (source) => { + await sandboxApi( + `/api/v2/${source === 'file' ? 'files/fixture' : 'tables/fixture/rows'}`, + async () => { + if (source === 'file') await reportWorkspaceFileDelivery({ status: 'unknown' }) + else + await reportTableRowDelivery({ version: 1, complete: false, entries: [] }, [ + { value: 'unknown' }, + ]) + return new Response('unknown') + } + ) + const result = await run('printf "ready"') + expect(result.projected.safe).toBe(false) + expect(JSON.stringify(result.projected.result)).not.toContain('ready') + } + ) +}) + describe('persistent workbench output confidentiality', () => { it.each(['javascript', 'shell'] as const)( 'redacts session credentials in %s output while preserving routing metadata', diff --git a/apps/sim/lib/mothership/tools/sandbox-resource-transport.test.ts b/apps/sim/lib/mothership/tools/sandbox-resource-transport.test.ts index 952b40b5f3b..2f0659341dc 100644 --- a/apps/sim/lib/mothership/tools/sandbox-resource-transport.test.ts +++ b/apps/sim/lib/mothership/tools/sandbox-resource-transport.test.ts @@ -90,10 +90,6 @@ describe('private sandbox v2 resource transport', () => { token ) expect(await response.json()).toEqual({ data: { inserted: 1 } }) - expect(recordInput).toHaveBeenCalledWith('mothership-chat:chat', false) - expect(recordInput.mock.invocationCallOrder[0]).toBeLessThan( - fetcher.mock.invocationCallOrder[0]! - ) expect(fetcher).toHaveBeenCalledTimes(1) expect(recordEffects).toHaveBeenCalledWith(token, scope, [ { @@ -261,14 +257,6 @@ vi.mock('@/lib/execution/remote-sandbox/session-file-provenance', () => ({ recordExistingSessionFileInput: recordInput, })) -it('refuses delivery before dispatch when provenance cannot be recorded', async () => { - recordInput.mockRejectedValueOnce(new Error('storage unavailable')) - await expect(proxySandboxResourceRequest(request('/api/v2/tables/table'), token)).rejects.toThrow( - 'storage unavailable' - ) - expect(fetcher).not.toHaveBeenCalled() -}) - it('does not poison public scratch after authenticated static catalog discovery', async () => { routeMatcher.mockReturnValue({ params: { toolId: 'function_execute' }, @@ -333,40 +321,3 @@ it('keeps the server identity out of callback response headers and body', async expect(JSON.stringify([...response.headers])).not.toContain('server-only-identity') expect(await response.text()).not.toContain('server-only-identity') }) - -it.each(['builtin', 'custom'] as const)( - 'preserves public research scratch only for producer-classified %s block catalog content', - async (source) => { - routeMatcher.mockReturnValue({ params: {}, load: async () => ({ GET: fetcher }) }) - fetcher.mockResolvedValue( - Response.json({ - data: [ - { - id: 'agent', - name: 'Agent', - description: 'Build an agent', - category: 'blocks', - source, - triggerAllowed: false, - triggerCapable: false, - triggerIds: [], - toolIds: [], - operationIds: [], - preview: false, - tags: [], - }, - ], - nextCursor: null, - }) - ) - expect((await proxySandboxResourceRequest(request('/api/v2/blocks'), token)).status).toBe(200) - if (source === 'builtin') expect(recordInput).not.toHaveBeenCalled() - else expect(recordInput).toHaveBeenCalledWith('mothership-chat:chat', false) - } -) -it('does not trust a source label in a malformed catalog result', async () => { - routeMatcher.mockReturnValue({ params: {}, load: async () => ({ GET: fetcher }) }) - fetcher.mockResolvedValue(Response.json({ data: [{ source: 'builtin' }], nextCursor: null })) - expect((await proxySandboxResourceRequest(request('/api/v2/blocks'), token)).status).toBe(200) - expect(recordInput).toHaveBeenCalledWith('mothership-chat:chat', false) -}) diff --git a/apps/sim/lib/mothership/tools/sandbox-resource-transport.ts b/apps/sim/lib/mothership/tools/sandbox-resource-transport.ts index 635dbf15591..e35d6ffaf9b 100644 --- a/apps/sim/lib/mothership/tools/sandbox-resource-transport.ts +++ b/apps/sim/lib/mothership/tools/sandbox-resource-transport.ts @@ -1,20 +1,18 @@ import { createLogger } from '@sim/logger' import { generateId } from '@sim/utils/id' import { NextRequest } from 'next/server' -import { - v2GetBlockContract, - v2GetToolContract, - v2ListBlocksContract, - v2ListConnectorTypesContract, - v2ListToolsContract, -} from '@/lib/api/contracts/v2/catalog' import { v2DownloadFileContract, v2ReadFileTextContract } from '@/lib/api/contracts/v2/files' import { markCopilotRequest } from '@/lib/api/server/routes/copilot-request' import { matchV2Route } from '@/lib/api/server/routes/in-process-transport' import { withWorkspaceInvocationScope } from '@/lib/core/application/workspace-invocation-scope' import { asOrchestrationError, statusForOrchestrationError } from '@/lib/core/orchestration/types' import { getInternalApiBaseUrl } from '@/lib/core/utils/urls' -import type { DurableSecretProvenance } from '@/lib/execution/durable-secret-provenance' +import { + type DurableSecretProvenance, + durableSecretProvenanceFromEnvelope, + EXACT_EMPTY_DURABLE_SECRET_PROVENANCE, + mergeDurableSecretProvenance, +} from '@/lib/execution/durable-secret-provenance' import { recordExistingSessionFileInput } from '@/lib/execution/remote-sandbox/session-file-provenance' import { createResourceEffectTransport } from '@/lib/mothership/agent-cli/resource-effects' import { resolveInvocationWorkspace } from '@/lib/mothership/application/workspace-target' @@ -24,6 +22,7 @@ import { recordSandboxResourceEffects, } from '@/lib/mothership/tools/sandbox-resources' import { chatSandboxSessionKey } from '@/lib/mothership/tools/sandbox-session-key' +import { observeTableRowDelivery } from '@/lib/table/application/row-delivery-observer' import { observeWorkspaceFileDelivery } from '@/lib/workspace-files/application/file-delivery-observer' const logger = createLogger('MothershipSandboxResourceTransport') @@ -101,6 +100,7 @@ async function proxyAuthorizedSandboxRequest( const forwarded = new Request(target, init) const effects: ResourceChange[] = [] let response: Response | undefined + let rowProvenance: DurableSecretProvenance | undefined let dispatched = false /** Invoke the same handler with private request identity; the declared use case still authorizes current domain access. */ const matched = matchV2Route(path) @@ -122,23 +122,6 @@ async function proxyAuthorizedSandboxRequest( if (!(result instanceof Response)) throw new Error('Invalid sandbox API response') return result } - // Only these producer-owned catalog responses contain no workspace data or execution output. - const publicCatalog = - method === 'GET' && - [v2ListToolsContract, v2GetToolContract, v2ListConnectorTypesContract].some( - (contract) => - contract.path.replace(/\[([^\]]+)\]/g, (_match, key) => - encodeURIComponent(matched.params[key] ?? '') - ) === path - ) - const blockCatalog = - method === 'GET' && - [v2ListBlocksContract, v2GetBlockContract].find( - (contract) => - contract.path.replace(/\[([^\]]+)\]/g, (_match, key) => - encodeURIComponent(matched.params[key] ?? '') - ) === path - ) const recordInput = (provenance: boolean | DurableSecretProvenance) => recordExistingSessionFileInput(chatSandboxSessionKey(scope.chatId), provenance) const fileRead = @@ -151,21 +134,31 @@ async function proxyAuthorizedSandboxRequest( ) const deliver = async () => { dispatched = true - if (!publicCatalog && !fileRead && !blockCatalog) await recordInput(false) - let observed = false - const result = await observeWorkspaceFileDelivery(async (provenance) => { - await recordInput(provenance?.status === 'exact' ? provenance : false) - observed = true - }, dispatch) + let fileObserved = false + const result = await observeTableRowDelivery( + async (provenance, _values, extras) => { + rowProvenance = mergeDurableSecretProvenance( + rowProvenance ?? EXACT_EMPTY_DURABLE_SECRET_PROVENANCE, + extras.unprovenancedErrorText + ? { status: 'unknown' } + : durableSecretProvenanceFromEnvelope(provenance) + ) + }, + () => + observeWorkspaceFileDelivery(async (provenance) => { + await recordInput(provenance?.status === 'exact' ? provenance : false) + fileObserved = true + }, dispatch) + ) try { - if (fileRead && !observed && result.ok && result.body) await recordInput(false) - if (blockCatalog && result.ok && result.body) { - const parsed = blockCatalog.response.schema.safeParse(await result.clone().json()) - const data = parsed.success ? parsed.data.data : undefined - const safe = Array.isArray(data) - ? data.every((block) => block.source === 'builtin') - : data?.source === 'builtin' - if (!safe) await recordInput(false) + if (fileRead && !fileObserved && result.ok && result.body) await recordInput(false) + else if (!fileObserved && !rowProvenance) { + /** Missing producer evidence is unrecorded, not proof that the machine received a secret. */ + logger.warn('Sandbox API response has no recorded secret provenance', { + method, + route: matched.pattern, + toolCallId: scope.toolCallId, + }) } } catch (error) { await result.body?.cancel().catch(() => {}) @@ -219,6 +212,24 @@ async function proxyAuthorizedSandboxRequest( } }) ) + if (rowProvenance) { + // Row mutations have already committed; admission must not interrupt completion or effects. + try { + await recordInput(rowProvenance) + } catch { + await response.body?.cancel().catch(() => {}) + logger.warn('Sandbox response provenance could not be recorded after API completion', { + toolCallId: scope.toolCallId, + status: response.status, + }) + response = Response.json( + { + error: `API request completed with HTTP ${response.status}, but its result could not be returned safely. Do not retry a mutation automatically; read the resource to check its current state.`, + }, + { status: 502 } + ) + } + } return new Response(request.method === 'HEAD' ? null : response.body, { status: response.status, statusText: response.statusText,