Skip to content
Merged
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
118 changes: 118 additions & 0 deletions apps/sim/app/api/function/execute/route.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -858,6 +858,124 @@ describe('Function Execute API Route', () => {
expect(JSON.stringify(data)).toContain('files/report-secret-value.txt')
})

it('classifies a binary export exact-empty when no secret was in scope', async () => {
envFlagsMock.isRemoteSandboxEnabled = true
mockExecuteInSandbox.mockResolvedValueOnce({
result: 'done',
stdout: '',
sandboxId: 'sandbox-123',
exportedFiles: { '/home/user/small.jpg': '/9j/4AAQ' },
})

const response = await POST(
createMockRequest('POST', {
code: 'print("done")',
language: 'python',
workspaceId: 'workspace-1',
outputs: {
files: [
{
path: 'files/small.jpg',
sandboxPath: '/home/user/small.jpg',
mimeType: 'image/jpeg',
},
],
},
})
)

expect(response.status).toBe(200)
expect(mockWriteWorkspaceFileByPath).toHaveBeenCalledWith(
expect.objectContaining({ secretProvenance: { status: 'exact', entries: [] } })
)
})

it('keeps a binary export unknown when files were mounted without a provenance envelope', async () => {
envFlagsMock.isRemoteSandboxEnabled = true
mockExecuteInSandbox.mockResolvedValueOnce({
result: 'done',
stdout: '',
sandboxId: 'sandbox-123',
exportedFiles: { '/home/user/small.jpg': '/9j/4AAQ' },
})

const response = await POST(
createMockRequest('POST', {
code: 'print("done")',
language: 'python',
workspaceId: 'workspace-1',
_sandboxFiles: [{ path: '/home/user/in.bin', content: 'mounted bytes' }],
outputs: {
files: [
{
path: 'files/small.jpg',
sandboxPath: '/home/user/small.jpg',
mimeType: 'image/jpeg',
},
],
},
})
)

expect(response.status).toBe(200)
expect(mockWriteWorkspaceFileByPath).toHaveBeenCalledWith(
expect.objectContaining({ secretProvenance: { status: 'unknown' } })
)
})

it('keeps a binary export unknown when a mounted input file carried a secret', async () => {
envFlagsMock.isRemoteSandboxEnabled = true
mockExecuteInSandbox.mockResolvedValueOnce({
result: 'done',
stdout: '',
sandboxId: 'sandbox-123',
exportedFiles: { '/home/user/small.jpg': '/9j/4AAQ' },
})

const response = await POST(
createMockRequest(
'POST',
{
code: 'print("done")',
language: 'python',
workspaceId: 'workspace-1',
outputs: {
files: [
{
path: 'files/small.jpg',
sandboxPath: '/home/user/small.jpg',
mimeType: 'image/jpeg',
},
],
},
[PRIVATE_SECRET_PROVENANCE_FIELD]: {
version: 1,
complete: true,
selections: [
{
key: MOUNTED_WORKSPACE_FILES_PROVENANCE_KEY,
provenance: {
version: 1,
complete: true,
entries: [{ encryptedValue: 'encrypted:mounted-secret' }],
scope: { userId: 'user-123', workspaceId: 'workspace-1' },
},
},
],
},
},
{
[PRIVATE_SECRET_PROVENANCE_HEADER]: PRIVATE_SECRET_PROVENANCE_BUNDLE_V1,
}
)
)

expect(response.status).toBe(200)
expect(mockWriteWorkspaceFileByPath).toHaveBeenCalledWith(
expect.objectContaining({ secretProvenance: { status: 'unknown' } })
)
})

it('marks binary exports unknown without failing the Function execution', async () => {
envFlagsMock.isRemoteSandboxEnabled = true
mockExecuteInSandbox.mockResolvedValueOnce({
Expand Down
34 changes: 33 additions & 1 deletion apps/sim/app/api/function/execute/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,7 @@ import {
resolveWorkspaceFileReference,
} from '@/lib/uploads/contexts/workspace/workspace-file-manager'
import {
EXACT_EMPTY_WORKSPACE_FILE_SECRET_PROVENANCE,
mergeWorkspaceFileSecretProvenance,
type WorkspaceFileSecretProvenance,
} from '@/lib/uploads/contexts/workspace/workspace-file-secret-provenance'
Expand Down Expand Up @@ -988,6 +989,7 @@ interface FunctionRouteExecutionContext {
outputSecretNamesByScanLiteral: Map<string, string[]>
outputSecretPlaintextsByName: Map<string, string>
mountedFileSecretProvenanceScanner?: MountedFileSecretProvenanceScanner
hasMountedSandboxFiles: boolean
}

type ResolvedSecretNamesMetadataType =
Expand Down Expand Up @@ -1208,13 +1210,42 @@ function activateOutputSecretProvenance(
}
}

/**
* True when any secret material was in scope for this execution — a mounted environment secret, or
* a secret carried by a mounted input file. When false, nothing secret ever reached the sandbox, so
* no export of any kind can carry one.
*
* Mounted bytes are classified from the caller's provenance envelope. Files mounted *without* one
* are unclassifiable rather than clean: absence of an envelope is absence of evidence, not evidence
* the mount carried nothing. Those fail closed here so the classification can never be stronger
* than what the caller actually attested to.
*/
function hasSecretMaterialInScope(context: FunctionRouteExecutionContext): boolean {
if (context.outputSecretPlaintextsByName.size > 0) return true
const scanner = context.mountedFileSecretProvenanceScanner
return scanner ? scanner.hasSecrets : context.hasMountedSandboxFiles
Comment thread
icecrasher321 marked this conversation as resolved.
}
Comment thread
icecrasher321 marked this conversation as resolved.

/**
* Classifies the secret provenance of one exported sandbox file.
*
* Text exports are scanned for the exact resolved-secret plaintexts in scope. Binary exports cannot
* be scanned soundly — re-encoding can carry a secret without leaving a literal substring — so they
* are classified only when no secret material was in scope at all; with nothing available to embed,
* the bytes are provably secret-free. Otherwise they stay unknown, which fails closed at every
* model and runtime boundary that later reads the file.
*/
async function getOutputFileSecretProvenance(
buffer: Buffer,
isBinary: boolean,
context: FunctionRouteExecutionContext,
scope: { userId: string; workspaceId: string }
): Promise<WorkspaceFileSecretProvenance> {
if (isBinary) return { status: 'unknown' }
if (isBinary) {
return hasSecretMaterialInScope(context)
? { status: 'unknown' }
: EXACT_EMPTY_WORKSPACE_FILE_SECRET_PROVENANCE
}
const mountedFileProvenance = context.mountedFileSecretProvenanceScanner?.scan(buffer) ?? {
status: 'exact' as const,
entries: [],
Expand Down Expand Up @@ -1999,6 +2030,7 @@ export const POST = withRouteHandler(async (req: NextRequest) => {
outputSecretNamesByScanLiteral: new Map(),
outputSecretPlaintextsByName: new Map(),
mountedFileSecretProvenanceScanner,
hasMountedSandboxFiles: (_sandboxFiles?.length ?? 0) > 0,
Comment thread
icecrasher321 marked this conversation as resolved.
}
for (const [name, plaintext] of Object.entries(envVars)) {
if (!plaintext) continue
Expand Down
16 changes: 16 additions & 0 deletions apps/sim/lib/copilot/tools/handlers/function-execute.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -759,6 +759,22 @@ describe('executeFunctionExecute file mounts', () => {
expect(mockExecuteTool).not.toHaveBeenCalled()
})

it('omits the envelope when mounts it cannot attest to are already on the params', async () => {
mockExecuteTool.mockResolvedValue({ success: true, output: { result: 'ok' } })

await executeFunctionExecute(
{
inputFiles: ['files/data.csv'],
_sandboxFiles: [{ path: '/home/user/preserved.bin', content: 'from another resolver' }],
},
context
)

const call = mockExecuteTool.mock.calls[0]?.[1]
expect(call?._sandboxFiles?.length).toBeGreaterThan(1)
expect(call?.[PRIVATE_SECRET_PROVENANCE_FIELD]).toBeUndefined()
})

it('projects only mounted-file secrets that cross the settled Function result', async () => {
mockImportWorkspaceFileSecretProvenanceForRuntime.mockImplementation(
async ({ registry }: { registry?: ResolvedSecretTraceRegistry }) =>
Expand Down
29 changes: 19 additions & 10 deletions apps/sim/lib/copilot/tools/handlers/function-execute.ts
Original file line number Diff line number Diff line change
Expand Up @@ -636,21 +636,30 @@ export async function executeFunctionExecute(
secretActorUserId ?? context.userId,
mountedRegistry
)
// Every mount ships its provenance envelope, tables included. The route classifies an
// output file from that envelope, so a mount without one is unclassifiable there — it
// cannot tell "nothing secret was mounted" from "nobody said". Emitting on the same
// condition that produces the mount keeps the two from drifting apart.
if (resolved.length > 0) {
const existing = (enrichedParams._sandboxFiles as SandboxFile[]) || []
enrichedParams._sandboxFiles = [...existing, ...resolved]
}

if (inputFiles.length > 0 || inputDirectories.length > 0) {
const provenance = mountedRegistry.exportProvenance()
const bundle: PrivateSecretProvenanceBundleV1 = {
version: 1,
complete: provenance.complete,
selections: provenance.complete
? [{ key: MOUNTED_WORKSPACE_FILES_PROVENANCE_KEY, provenance }]
: [],
// The envelope attests to the WHOLE mounted set or it is not emitted at all. Mounts
// that arrived already on the params came from outside this resolver, so
// `mountedRegistry` knows nothing about them; an envelope covering only `resolved`
// would read at the route as a complete attestation over every mounted byte. With no
// envelope the route fails closed instead, which is the honest answer.
if (existing.length === 0) {
const provenance = mountedRegistry.exportProvenance()
const bundle: PrivateSecretProvenanceBundleV1 = {
version: 1,
complete: provenance.complete,
selections: provenance.complete
? [{ key: MOUNTED_WORKSPACE_FILES_PROVENANCE_KEY, provenance }]
: [],
}
enrichedParams[PRIVATE_SECRET_PROVENANCE_FIELD] = bundle
}
enrichedParams[PRIVATE_SECRET_PROVENANCE_FIELD] = bundle
}
}
}
Expand Down
44 changes: 44 additions & 0 deletions apps/sim/lib/copilot/tools/handlers/vfs.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -292,6 +292,50 @@ describe('vfs handlers oversize policy', () => {
)
})

it('windows against the real line count when a read under-reports totalLines', async () => {
const vfs = makeVfs()
vfs.readFileContentWithProvenance.mockResolvedValue({
// `/extract` synthesizes a whole extracted document but reports totalLines: 1.
value: { content: 'page one\npage two\npage three', totalLines: 1 },
file: { fileId: 'file-1', key: 'workspace/key-1', context: 'workspace' },
})
getOrMaterializeVFS.mockResolvedValue(vfs)

const result = await executeVfsRead(
{ path: 'files/report.pdf/extract', offset: 1, limit: 2 },
GREP_CTX
)

expect(result.success).toBe(true)
expect(result.output).toEqual({ content: 'page two\npage three', totalLines: 1 })
})

it('leaves an attachment read unwindowed so its label is never blanked', async () => {
const vfs = makeVfs()
const imageResult = {
content: 'Image: photo.jpeg (157.0KB, image/jpeg, resized for vision)',
totalLines: 1,
attachment: {
type: 'image',
name: 'photo.jpeg',
source: { type: 'base64', media_type: 'image/jpeg', data: 'AAAA' },
},
}
vfs.readFileContentWithProvenance.mockResolvedValue({
value: imageResult,
file: { fileId: 'file-1', key: 'workspace/key-1', context: 'workspace' },
})
getOrMaterializeVFS.mockResolvedValue(vfs)

const result = await executeVfsRead(
{ path: 'files/photo.jpeg/content', offset: 1, limit: 100 },
GREP_CTX
)

expect(result.success).toBe(true)
expect(result.output).toEqual(imageResult)
})

it('rejects only the file read when durable provenance cannot be verified', async () => {
const vfs = makeVfs()
vfs.readFileContentWithProvenance.mockResolvedValue({
Expand Down
15 changes: 12 additions & 3 deletions apps/sim/lib/copilot/tools/handlers/vfs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -285,12 +285,21 @@ export async function executeVfsRead(
}
const offset = parseOptionalNumber(params.offset)
const limit = parseOptionalNumber(params.limit)
/**
* Applies the caller's line window, clamped against the content's ACTUAL line count rather than
* the self-reported `totalLines`. Synthesized results report `totalLines: 1` for content that is
* not one line (e.g. `files/x.pdf/extract` returns a whole extracted document), and clamping to
* that collapses the read to its first line — or to nothing for any nonzero offset. An
* attachment result is skipped outright: its `content` is a one-line label beside the bytes, so
* a window can only blank the label while the model still receives the attachment.
*/
const applyWindow = <T extends { content: string; totalLines: number }>(result: T): T => {
if (offset === undefined && limit === undefined) return result
if (hasModelAttachment(result)) return result
const lines = result.content.split('\n')
const start = Math.max(0, Math.min(result.totalLines, offset ?? 0))
const endRaw = limit !== undefined ? start + Math.max(0, limit) : result.totalLines
const end = Math.max(start, Math.min(result.totalLines, endRaw))
const start = Math.max(0, Math.min(lines.length, offset ?? 0))
const endRaw = limit !== undefined ? start + Math.max(0, limit) : lines.length
const end = Math.max(start, Math.min(lines.length, endRaw))
return {
...result,
content: lines.slice(start, end).join('\n'),
Expand Down
2 changes: 1 addition & 1 deletion apps/sim/lib/embeddings/client.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { createLogger } from '@sim/logger'
import { chunkArray } from '@sim/utils'
import { chunkArray } from '@sim/utils/helpers'
import { env, envNumber } from '@/lib/core/config/env'
import { mapWithConcurrency } from '@/lib/core/utils/concurrency'
import {
Expand Down
32 changes: 32 additions & 0 deletions apps/sim/lib/execution/mounted-file-secret-provenance.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,38 @@ describe('mounted file output provenance scanner', () => {
})
})

it('reports whether the mount carried any secret material', async () => {
const withSecrets = await createMountedFileSecretProvenanceScanner({
version: 1,
complete: true,
entries: [{ encryptedValue: 'encrypted-a' }],
scope: { userId: 'user-1', workspaceId: 'workspace-1' },
})
expect(withSecrets?.hasSecrets).toBe(true)

const withoutSecrets = await createMountedFileSecretProvenanceScanner({
version: 1,
complete: true,
entries: [],
scope: { userId: 'user-1', workspaceId: 'workspace-1' },
})
expect(withoutSecrets?.hasSecrets).toBe(false)
expect(withoutSecrets?.scan(Buffer.from('anything'))).toEqual({ status: 'exact', entries: [] })
})

it('keeps hasSecrets true when attested entries yield no scannable plaintext', async () => {
encryptionMockFns.mockDecryptSecret.mockImplementation(async () => ({ decrypted: '' }))

const scanner = await createMountedFileSecretProvenanceScanner({
version: 1,
complete: true,
entries: [{ encryptedValue: 'encrypted-a' }],
scope: { userId: 'user-1', workspaceId: 'workspace-1' },
})

expect(scanner?.hasSecrets).toBe(true)
})

it('fails closed when encrypted provenance is incomplete or cannot be decrypted', async () => {
await expect(
createMountedFileSecretProvenanceScanner({ version: 1, complete: false, entries: [] })
Expand Down
Loading
Loading