diff --git a/apps/sim/lib/mothership/agent-cli/index.ts b/apps/sim/lib/mothership/agent-cli/index.ts index 324a36dd401..2bf711263e1 100644 --- a/apps/sim/lib/mothership/agent-cli/index.ts +++ b/apps/sim/lib/mothership/agent-cli/index.ts @@ -3,16 +3,11 @@ import { withWorkspaceInvocationScope } from '@/lib/core/application/workspace-i import { getInternalApiBaseUrl } from '@/lib/core/utils/urls' import { curateBlockDetail } from '@/lib/mothership/agent-cli/curation' import { AUGMENTATION_ENGINES, runEngine } from '@/lib/mothership/agent-cli/engines' -import { createFileReadTransport } from '@/lib/mothership/agent-cli/file-read-transport' -import { createFileUploadTransport } from '@/lib/mothership/agent-cli/file-upload-transport' import { curateKnowledgeDocuments } from '@/lib/mothership/agent-cli/knowledge-curation' -import { createResourceEffectTransport } from '@/lib/mothership/agent-cli/resource-effects' import { runCli } from '@/lib/mothership/agent-cli/run-cli' -import { createScopedCliTransport } from '@/lib/mothership/agent-cli/scoped-transport' import { executeAgentCliService } from '@/lib/mothership/agent-cli/services' import { applySink } from '@/lib/mothership/agent-cli/sink' -import { createTableReadTransport } from '@/lib/mothership/agent-cli/table-read-transport' -import { createTracedCliTransport } from '@/lib/mothership/agent-cli/traced-transport' +import { createAgentCliTransport } from '@/lib/mothership/agent-cli/transport' import { createWorkbenchFileProvenance } from '@/lib/mothership/agent-cli/workbench-file-provenance' import { resolveInvocationWorkspace } from '@/lib/mothership/application/workspace-target' import { @@ -79,44 +74,22 @@ async function executeBoundAgentCliRequest( const endpoint = getInternalApiBaseUrl() const sessionKey = context.chatId ? chatSandboxSessionKey(context.chatId) : null const files = sessionKey ? createWorkbenchFileProvenance({ ...context, sessionKey }) : undefined - const reads = createFileReadTransport({ - endpoint, - transport: createTableReadTransport({ - endpoint, - transport: createTracedCliTransport( - endpoint, - createScopedCliTransport(endpoint, invocationIdentity) - ), - registry: context.resolvedSecretTraceRegistry, - }), - userId: context.userId, - invocation: invocationIdentity, - registry: context.resolvedSecretTraceRegistry, - ...(context.chatId !== undefined ? { chatId: context.chatId } : {}), - ...(files ? { trackDownload: files.trackDownload } : {}), - }) const resources: ResourceChange[] = [] const identity: EmbeddedCliIdentity = { endpoint, apiKey, workspaceId: context.workspaceId, - transport: createResourceEffectTransport( + transport: createAgentCliTransport({ endpoint, - files - ? createFileUploadTransport({ - endpoint, - workspaceId: context.workspaceId, - userId: context.userId, - invocation: invocationIdentity, - fallback: reads, - uploadProvenance: files.uploadProvenance, - }) - : reads, + invocation: invocationIdentity, + registry: context.resolvedSecretTraceRegistry, + ...(files ? { files } : {}), resources, - request.invocation.kind === 'cli' || + observeReads: + request.invocation.kind === 'cli' || (request.invocation.kind === 'augmentation' && - AUGMENTATION_ENGINES[request.invocation.name]?.openReadResources === true) - ), + AUGMENTATION_ENGINES[request.invocation.name]?.openReadResources === true), + }), ...(context.signal ? { signal: context.signal } : {}), } diff --git a/apps/sim/lib/mothership/agent-cli/transport.ts b/apps/sim/lib/mothership/agent-cli/transport.ts new file mode 100644 index 00000000000..06f6a0c88f8 --- /dev/null +++ b/apps/sim/lib/mothership/agent-cli/transport.ts @@ -0,0 +1,56 @@ +import { createFileReadTransport } from '@/lib/mothership/agent-cli/file-read-transport' +import { createFileUploadTransport } from '@/lib/mothership/agent-cli/file-upload-transport' +import { createResourceEffectTransport } from '@/lib/mothership/agent-cli/resource-effects' +import { createScopedCliTransport } from '@/lib/mothership/agent-cli/scoped-transport' +import { createTableReadTransport } from '@/lib/mothership/agent-cli/table-read-transport' +import { createTracedCliTransport } from '@/lib/mothership/agent-cli/traced-transport' +import type { createWorkbenchFileProvenance } from '@/lib/mothership/agent-cli/workbench-file-provenance' +import type { CopilotChatDelegationContext } from '@/lib/mothership/auth/application-delegation' +import type { ResourceChange } from '@/lib/mothership/generated/resources' +import type { ResolvedSecretTraceRegistry } from '@/executor/utils/resolved-secret-trace-registry' + +/** + * The transport every Chat CLI invocation and augmentation engine reaches Sim through. + * + * Order is load-bearing: the file read layer sits outside in-process admission so every file + * read and every other `GET` runs under a delivery observer that records the secret provenance + * of the bytes, and resource effects observe what the composed stack returns. + */ +export function createAgentCliTransport(context: { + endpoint: string + invocation: CopilotChatDelegationContext & { workspaceId: string } + registry?: ResolvedSecretTraceRegistry + files?: ReturnType + resources: ResourceChange[] + observeReads: boolean +}): typeof fetch { + const { endpoint, invocation, registry, files } = context + const reads = createFileReadTransport({ + endpoint, + transport: createTableReadTransport({ + endpoint, + transport: createTracedCliTransport(endpoint, createScopedCliTransport(endpoint, invocation)), + registry, + }), + userId: invocation.userId, + invocation, + registry, + ...(invocation.chatId !== undefined ? { chatId: invocation.chatId } : {}), + ...(files ? { trackDownload: files.trackDownload } : {}), + }) + return createResourceEffectTransport( + endpoint, + files + ? createFileUploadTransport({ + endpoint, + workspaceId: invocation.workspaceId, + userId: invocation.userId, + invocation, + fallback: reads, + uploadProvenance: files.uploadProvenance, + }) + : reads, + context.resources, + context.observeReads + ) +} 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 12781b3eb2b..caf7742b5d5 100644 --- a/apps/sim/lib/mothership/tools/sandbox-resource-transport.test.ts +++ b/apps/sim/lib/mothership/tools/sandbox-resource-transport.test.ts @@ -1,3 +1,4 @@ +import { createDelegatedPrincipal } from '@sim/testing/factories/principal.factory' import { mothershipWorkspaceTargetMock, mothershipWorkspaceTargetMockFns, @@ -7,6 +8,10 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import { isCopilotRequest } from '@/lib/api/server/routes/copilot-request' import { assertWorkspaceInvocationScope } from '@/lib/core/application/workspace-invocation-scope' import { OrchestrationError } from '@/lib/core/orchestration/types' +import { + reportWorkspaceFileDelivery, + requireCopilotWorkspaceFileDeliveryObserver, +} from '@/lib/workspace-files/application/file-delivery-observer' const { readScope, recordEffects, fetcher, routeMatcher, recordInput, mint } = vi.hoisted(() => ({ readScope: vi.fn(), @@ -275,6 +280,28 @@ it('cannot deliver a successful unclassified file response when recording its un expect(fetcher).toHaveBeenCalledOnce() }) +/** + * The file read and download use cases refuse a Chat principal that no delivery observer records, + * so the workbench's file reads depend on this proxy installing one around the route handler. + */ +it('dispatches a workbench file read under a delivery observer the Copilot guard admits', async () => { + routeMatcher.mockReturnValue({ params: { fileId: 'file' }, load: async () => ({ GET: fetcher }) }) + fetcher.mockImplementation(async () => { + requireCopilotWorkspaceFileDeliveryObserver(createDelegatedPrincipal({ serviceId: 'copilot' })) + await reportWorkspaceFileDelivery({ status: 'exact', entries: [] }) + return new Response('file text') + }) + recordInput.mockResolvedValueOnce(undefined) + + const response = await proxySandboxResourceRequest( + request('/api/v2/files/file/text?workspaceId=workspace'), + token + ) + + expect(response.status).toBe(200) + expect(await response.text()).toBe('file text') +}) + vi.mock('@/lib/mothership/chat/delegation', () => ({ mintDelegationToken: mint })) it('never resolves a real credential for an invalid callback scope', async () => { diff --git a/apps/sim/lib/workspace-files/__integration__/copilot-file-versions.integration.ts b/apps/sim/lib/workspace-files/__integration__/copilot-file-versions.integration.ts index 13d00d79666..f791f4c7c7b 100644 --- a/apps/sim/lib/workspace-files/__integration__/copilot-file-versions.integration.ts +++ b/apps/sim/lib/workspace-files/__integration__/copilot-file-versions.integration.ts @@ -1,4 +1,4 @@ -/** Chat's in-process CLI reading and reverting workspace file version history as the delegating user. */ +/** Chat's in-process CLI reading workspace files and reverting their version history as the delegating user. */ import { mkdtempSync } from 'node:fs' import { rm } from 'node:fs/promises' import { tmpdir } from 'node:os' @@ -35,15 +35,21 @@ import { createKnowledgeAclFixtureIds, seedKnowledgeAclFixture, } from '@/lib/knowledge/__integration__/seed-source-access-fixture' -import { createFileReadTransport } from '@/lib/mothership/agent-cli/file-read-transport' +import { runCli } from '@/lib/mothership/agent-cli/run-cli' import { createScopedCliTransport } from '@/lib/mothership/agent-cli/scoped-transport' +import { createAgentCliTransport } from '@/lib/mothership/agent-cli/transport' +import { createCopilotChatPrincipal } from '@/lib/mothership/auth/application-delegation' import { updateWorkspaceFileContent, uploadWorkspaceFile, } from '@/lib/uploads/contexts/workspace/workspace-file-manager' import type { WorkspaceFileSecretProvenance } from '@/lib/uploads/contexts/workspace/workspace-file-secret-provenance' +import { WORKSPACE_FILES_DELEGATION_AUDIENCE } from '@/lib/workspace-files/application/authorization' +import { downloadWorkspaceFileStream } from '@/lib/workspace-files/application/download-workspace-file' +import { WorkspaceFileDeliveryUnobservedError } from '@/lib/workspace-files/application/file-delivery-observer' import { projectResolvedSecretModelContent } from '@/executor/utils/resolved-secret-content-projection' import { ResolvedSecretTraceRegistry } from '@/executor/utils/resolved-secret-trace-registry' +import '@/app/api/v2/files/[fileId]/text/route' import '@/app/api/v2/files/[fileId]/versions/route' import '@/app/api/v2/files/[fileId]/versions/[version]/route' import '@/app/api/v2/files/[fileId]/versions/[version]/content/route' @@ -121,36 +127,40 @@ describe('chat-delegated file version history', () => { } /** - * Chat's composed CLI transport: the provenance-observing read layer over in-process admission. + * Chat's composed CLI transport, the same stack every Chat CLI invocation and engine runs on. * `layers` drops the outer layers to prove the inner ones hold on their own. */ - function chatTransport( + function chatFetch( fixture: { workspaceId: string; organizationId: string }, userId: string, registry?: ResolvedSecretTraceRegistry, layers: { observer?: boolean; invocationScope?: boolean } = {} - ) { + ): typeof fetch { const invocation = { userId, workspaceId: fixture.workspaceId, chatId: generateId() } - const scoped = createScopedCliTransport(ORIGIN, invocation) const transport = layers.observer === false - ? scoped - : createFileReadTransport({ + ? createScopedCliTransport(ORIGIN, invocation) + : createAgentCliTransport({ endpoint: ORIGIN, - transport: scoped, - userId, invocation, ...(registry ? { registry } : {}), + resources: [], + observeReads: true, }) - return (url: string, init?: RequestInit) => + return (input, init) => layers.invocationScope === false - ? transport(`${ORIGIN}${url}`, init) + ? transport(input, init) : withWorkspaceInvocationScope( { workspaceId: fixture.workspaceId, organizationId: fixture.organizationId }, - () => transport(`${ORIGIN}${url}`, init) + () => transport(input, init) ) } + function chatTransport(...args: Parameters) { + const transport = chatFetch(...args) + return (url: string, init?: RequestInit) => transport(`${ORIGIN}${url}`, init) + } + function registryFor(fixture: { workspaceId: string }, userId: string) { return new ResolvedSecretTraceRegistry([], { userId, workspaceId: fixture.workspaceId }) } @@ -402,4 +412,129 @@ describe('chat-delegated file version history', () => { expect(untracked.status).toBe(503) expect(await untracked.text()).not.toContain(SECRET) }) + + /** + * The current-file text read and download admitted Chat whether or not a delivery observer would + * record the secret provenance of the bytes; only the transport Chat composes today installed one. + */ + describe('current-file reads without a delivery observer', () => { + async function revertToSecretVersion(fixture: Awaited>) { + const reverted = await chatTransport(fixture, fixture.aliceId)( + `/api/v2/files/${fixture.fileId}/versions/2/revert`, + { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ workspaceId: fixture.workspaceId }), + } + ) + expect(reverted.status).toBe(200) + } + + function inWorkspace( + fixture: { workspaceId: string; organizationId: string }, + run: () => T + ) { + return withWorkspaceInvocationScope( + { workspaceId: fixture.workspaceId, organizationId: fixture.organizationId }, + run + ) + } + + function download( + fixture: { workspaceId: string; fileId: string }, + principal: Parameters[0]['principal'] + ) { + return downloadWorkspaceFileStream.execute({ + principal, + input: { fileId: fixture.fileId, assertedWorkspaceId: fixture.workspaceId }, + }) + } + + it('refuses a Chat text read before returning the secret', async () => { + const fixture = await seedVersionedFile() + await revertToSecretVersion(fixture) + + const response = await chatTransport(fixture, fixture.bobId, undefined, { observer: false })( + `/api/v2/files/${fixture.fileId}/text?workspaceId=${fixture.workspaceId}` + ) + + expect(response.status).toBe(503) + const body = await response.text() + expect(body).not.toContain(SECRET) + expect(JSON.parse(body)).toMatchObject({ error: { code: 'SERVICE_UNAVAILABLE' } }) + }) + + it('refuses a Chat download before streaming any bytes', async () => { + const fixture = await seedVersionedFile() + const principal = createCopilotChatPrincipal( + { userId: fixture.bobId, workspaceId: fixture.workspaceId, chatId: generateId() }, + WORKSPACE_FILES_DELEGATION_AUDIENCE + ) + + await expect(inWorkspace(fixture, () => download(fixture, principal))).rejects.toThrow( + WorkspaceFileDeliveryUnobservedError + ) + }) + + it('still serves a workflow run, which no Chat model reads', async () => { + const fixture = await seedVersionedFile() + await revertToSecretVersion(fixture) + const now = Date.now() + + const result = await inWorkspace(fixture, () => + download(fixture, { + kind: 'delegated', + serviceId: 'executor', + subjectUserId: fixture.bobId, + workspaceId: fixture.workspaceId, + delegationId: generateId(), + audience: WORKSPACE_FILES_DELEGATION_AUDIENCE, + issuedAt: new Date(now), + expiresAt: new Date(now + 60_000), + }) + ) + + expect(await new Response(result.stream).text()).toBe(`token=${SECRET}`) + }) + + it('serves a Chat `files read` through the composed CLI', async () => { + const fixture = await seedVersionedFile() + await revertToSecretVersion(fixture) + const registry = registryFor(fixture, fixture.bobId) + + const result = await runCli( + ['files', 'read', fixture.fileId], + { + endpoint: ORIGIN, + apiKey: 'mothership-in-process', + workspaceId: fixture.workspaceId, + transport: chatFetch(fixture, fixture.bobId, registry), + }, + null + ) + + expect(result.exitCode, result.stderr).toBe(0) + const projected = projectResolvedSecretModelContent(result.stdout, registry) + if (!projected.safe) throw new Error('File text was withheld') + expect(projected.value).not.toContain(SECRET) + expect(projected.value).toContain('token=[REDACTED_SECRET]') + }) + + it('serves Chat through the transport that records provenance', async () => { + const fixture = await seedVersionedFile() + await revertToSecretVersion(fixture) + const registry = registryFor(fixture, fixture.bobId) + + const response = await chatTransport( + fixture, + fixture.bobId, + registry + )(`/api/v2/files/${fixture.fileId}?workspaceId=${fixture.workspaceId}`) + + expect(response.status).toBe(200) + const projected = projectResolvedSecretModelContent(await response.text(), registry) + if (!projected.safe) throw new Error('Downloaded file was withheld') + expect(projected.value).toBe('token=[REDACTED_SECRET]') + }) + }) }) diff --git a/apps/sim/lib/workspace-files/application/download-workspace-file.ts b/apps/sim/lib/workspace-files/application/download-workspace-file.ts index 3f5cda4bb3a..c6bc8fa8f5e 100644 --- a/apps/sim/lib/workspace-files/application/download-workspace-file.ts +++ b/apps/sim/lib/workspace-files/application/download-workspace-file.ts @@ -17,6 +17,7 @@ import { defineAuthorizedWorkspaceFileUseCase } from '@/lib/workspace-files/appl import { hasWorkspaceFileDeliveryObserver, reportWorkspaceFileDelivery, + requireCopilotWorkspaceFileDeliveryObserver, } from '@/lib/workspace-files/application/file-delivery-observer' import { fileOperations } from '@/lib/workspace-files/application/operations' import { resolveRenderedWorkspaceArtifact } from '@/lib/workspace-files/application/resolve-rendered-workspace-artifact' @@ -100,6 +101,7 @@ async function executeDownloadWorkspaceFileStream({ DownloadWorkspaceFileInput, ActiveWorkspaceFileContext >): Promise { + requireCopilotWorkspaceFileDeliveryObserver(principal) const file = await getWorkspaceFile(context.workspaceId, context.fileId, { throwOnError: true, }) diff --git a/apps/sim/lib/workspace-files/application/file-delivery-observer.ts b/apps/sim/lib/workspace-files/application/file-delivery-observer.ts index b6796f242a2..edc304b1915 100644 --- a/apps/sim/lib/workspace-files/application/file-delivery-observer.ts +++ b/apps/sim/lib/workspace-files/application/file-delivery-observer.ts @@ -22,7 +22,7 @@ export async function reportWorkspaceFileDelivery( await observer.getStore()?.(provenance) } -/** A delegated read reached stored bytes with no observer to record their secret provenance. */ +/** A Chat read reached stored bytes with no observer to record their secret provenance. */ export class WorkspaceFileDeliveryUnobservedError extends Error { constructor() { super('File read provenance is unavailable. Retry the read.') @@ -31,12 +31,17 @@ export class WorkspaceFileDeliveryUnobservedError extends Error { } /** - * Refuses a delegated caller before any bytes load unless a delivery observer is installed, so a - * secret in the content cannot reach a model without its provenance being recorded, whatever - * transport composed the call. + * Refuses a Chat caller before any bytes load unless a delivery observer is installed, so a secret + * in the content cannot reach a model without its provenance being recorded, whatever transport + * composed the call. Keyed on the Copilot service, not on delegation: a workflow run is delegated + * too, but its reads feed blocks rather than Chat's model, and nothing observes them. */ -export function requireDelegatedWorkspaceFileDeliveryObserver(principal: Principal): void { - if (principal.kind === 'delegated' && !hasWorkspaceFileDeliveryObserver()) { +export function requireCopilotWorkspaceFileDeliveryObserver(principal: Principal): void { + if ( + principal.kind === 'delegated' && + principal.serviceId === 'copilot' && + !hasWorkspaceFileDeliveryObserver() + ) { throw new WorkspaceFileDeliveryUnobservedError() } } diff --git a/apps/sim/lib/workspace-files/application/file-versions.ts b/apps/sim/lib/workspace-files/application/file-versions.ts index 5a083ff55fb..022f4dc7082 100644 --- a/apps/sim/lib/workspace-files/application/file-versions.ts +++ b/apps/sim/lib/workspace-files/application/file-versions.ts @@ -37,7 +37,7 @@ import { import { hasWorkspaceFileDeliveryObserver, reportWorkspaceFileDelivery, - requireDelegatedWorkspaceFileDeliveryObserver, + requireCopilotWorkspaceFileDeliveryObserver, } from '@/lib/workspace-files/application/file-delivery-observer' import { parseWorkspaceFileRevision } from '@/lib/workspace-files/application/file-revision' import { resolveWorkspaceFileVersionWrite } from '@/lib/workspace-files/application/file-version-write' @@ -220,7 +220,7 @@ export const readWorkspaceFileVersionText = defineAuthorizedWorkspaceFileUseCase principal, request, }): Promise { - requireDelegatedWorkspaceFileDeliveryObserver(principal) + requireCopilotWorkspaceFileDeliveryObserver(principal) const file = await loadActiveFile(context) const version = await loadVersion(file, input.version) const fileAtVersion = recordAtVersion(file, version) diff --git a/apps/sim/lib/workspace-files/application/read-workspace-file-text.ts b/apps/sim/lib/workspace-files/application/read-workspace-file-text.ts index 5b972e57e5c..1a24001e36c 100644 --- a/apps/sim/lib/workspace-files/application/read-workspace-file-text.ts +++ b/apps/sim/lib/workspace-files/application/read-workspace-file-text.ts @@ -23,6 +23,7 @@ import { defineAuthorizedWorkspaceFileUseCase } from '@/lib/workspace-files/appl import { hasWorkspaceFileDeliveryObserver, reportWorkspaceFileDelivery, + requireCopilotWorkspaceFileDeliveryObserver, } from '@/lib/workspace-files/application/file-delivery-observer' import { fileOperations } from '@/lib/workspace-files/application/operations' import { resolveRenderedWorkspaceArtifact } from '@/lib/workspace-files/application/resolve-rendered-workspace-artifact' @@ -114,6 +115,7 @@ async function executeReadWorkspaceFileText({ >): Promise { const signal = request?.signal signal?.throwIfAborted() + requireCopilotWorkspaceFileDeliveryObserver(principal) return extractWorkspaceFileRecordText(context.file, input, principal, signal) }