Skip to content

Commit d1c543d

Browse files
committed
Merge remote-tracking branch 'origin/staging' into worktree-smartlead-integration
2 parents aff9590 + 5ad820d commit d1c543d

8 files changed

Lines changed: 286 additions & 16 deletions

File tree

apps/sim/app/api/function/execute/route.test.ts

Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -858,6 +858,124 @@ describe('Function Execute API Route', () => {
858858
expect(JSON.stringify(data)).toContain('files/report-secret-value.txt')
859859
})
860860

861+
it('classifies a binary export exact-empty when no secret was in scope', async () => {
862+
envFlagsMock.isRemoteSandboxEnabled = true
863+
mockExecuteInSandbox.mockResolvedValueOnce({
864+
result: 'done',
865+
stdout: '',
866+
sandboxId: 'sandbox-123',
867+
exportedFiles: { '/home/user/small.jpg': '/9j/4AAQ' },
868+
})
869+
870+
const response = await POST(
871+
createMockRequest('POST', {
872+
code: 'print("done")',
873+
language: 'python',
874+
workspaceId: 'workspace-1',
875+
outputs: {
876+
files: [
877+
{
878+
path: 'files/small.jpg',
879+
sandboxPath: '/home/user/small.jpg',
880+
mimeType: 'image/jpeg',
881+
},
882+
],
883+
},
884+
})
885+
)
886+
887+
expect(response.status).toBe(200)
888+
expect(mockWriteWorkspaceFileByPath).toHaveBeenCalledWith(
889+
expect.objectContaining({ secretProvenance: { status: 'exact', entries: [] } })
890+
)
891+
})
892+
893+
it('keeps a binary export unknown when files were mounted without a provenance envelope', async () => {
894+
envFlagsMock.isRemoteSandboxEnabled = true
895+
mockExecuteInSandbox.mockResolvedValueOnce({
896+
result: 'done',
897+
stdout: '',
898+
sandboxId: 'sandbox-123',
899+
exportedFiles: { '/home/user/small.jpg': '/9j/4AAQ' },
900+
})
901+
902+
const response = await POST(
903+
createMockRequest('POST', {
904+
code: 'print("done")',
905+
language: 'python',
906+
workspaceId: 'workspace-1',
907+
_sandboxFiles: [{ path: '/home/user/in.bin', content: 'mounted bytes' }],
908+
outputs: {
909+
files: [
910+
{
911+
path: 'files/small.jpg',
912+
sandboxPath: '/home/user/small.jpg',
913+
mimeType: 'image/jpeg',
914+
},
915+
],
916+
},
917+
})
918+
)
919+
920+
expect(response.status).toBe(200)
921+
expect(mockWriteWorkspaceFileByPath).toHaveBeenCalledWith(
922+
expect.objectContaining({ secretProvenance: { status: 'unknown' } })
923+
)
924+
})
925+
926+
it('keeps a binary export unknown when a mounted input file carried a secret', async () => {
927+
envFlagsMock.isRemoteSandboxEnabled = true
928+
mockExecuteInSandbox.mockResolvedValueOnce({
929+
result: 'done',
930+
stdout: '',
931+
sandboxId: 'sandbox-123',
932+
exportedFiles: { '/home/user/small.jpg': '/9j/4AAQ' },
933+
})
934+
935+
const response = await POST(
936+
createMockRequest(
937+
'POST',
938+
{
939+
code: 'print("done")',
940+
language: 'python',
941+
workspaceId: 'workspace-1',
942+
outputs: {
943+
files: [
944+
{
945+
path: 'files/small.jpg',
946+
sandboxPath: '/home/user/small.jpg',
947+
mimeType: 'image/jpeg',
948+
},
949+
],
950+
},
951+
[PRIVATE_SECRET_PROVENANCE_FIELD]: {
952+
version: 1,
953+
complete: true,
954+
selections: [
955+
{
956+
key: MOUNTED_WORKSPACE_FILES_PROVENANCE_KEY,
957+
provenance: {
958+
version: 1,
959+
complete: true,
960+
entries: [{ encryptedValue: 'encrypted:mounted-secret' }],
961+
scope: { userId: 'user-123', workspaceId: 'workspace-1' },
962+
},
963+
},
964+
],
965+
},
966+
},
967+
{
968+
[PRIVATE_SECRET_PROVENANCE_HEADER]: PRIVATE_SECRET_PROVENANCE_BUNDLE_V1,
969+
}
970+
)
971+
)
972+
973+
expect(response.status).toBe(200)
974+
expect(mockWriteWorkspaceFileByPath).toHaveBeenCalledWith(
975+
expect.objectContaining({ secretProvenance: { status: 'unknown' } })
976+
)
977+
})
978+
861979
it('marks binary exports unknown without failing the Function execution', async () => {
862980
envFlagsMock.isRemoteSandboxEnabled = true
863981
mockExecuteInSandbox.mockResolvedValueOnce({

apps/sim/app/api/function/execute/route.ts

Lines changed: 33 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,7 @@ import {
8383
resolveWorkspaceFileReference,
8484
} from '@/lib/uploads/contexts/workspace/workspace-file-manager'
8585
import {
86+
EXACT_EMPTY_WORKSPACE_FILE_SECRET_PROVENANCE,
8687
mergeWorkspaceFileSecretProvenance,
8788
type WorkspaceFileSecretProvenance,
8889
} from '@/lib/uploads/contexts/workspace/workspace-file-secret-provenance'
@@ -988,6 +989,7 @@ interface FunctionRouteExecutionContext {
988989
outputSecretNamesByScanLiteral: Map<string, string[]>
989990
outputSecretPlaintextsByName: Map<string, string>
990991
mountedFileSecretProvenanceScanner?: MountedFileSecretProvenanceScanner
992+
hasMountedSandboxFiles: boolean
991993
}
992994

993995
type ResolvedSecretNamesMetadataType =
@@ -1208,13 +1210,42 @@ function activateOutputSecretProvenance(
12081210
}
12091211
}
12101212

1213+
/**
1214+
* True when any secret material was in scope for this execution — a mounted environment secret, or
1215+
* a secret carried by a mounted input file. When false, nothing secret ever reached the sandbox, so
1216+
* no export of any kind can carry one.
1217+
*
1218+
* Mounted bytes are classified from the caller's provenance envelope. Files mounted *without* one
1219+
* are unclassifiable rather than clean: absence of an envelope is absence of evidence, not evidence
1220+
* the mount carried nothing. Those fail closed here so the classification can never be stronger
1221+
* than what the caller actually attested to.
1222+
*/
1223+
function hasSecretMaterialInScope(context: FunctionRouteExecutionContext): boolean {
1224+
if (context.outputSecretPlaintextsByName.size > 0) return true
1225+
const scanner = context.mountedFileSecretProvenanceScanner
1226+
return scanner ? scanner.hasSecrets : context.hasMountedSandboxFiles
1227+
}
1228+
1229+
/**
1230+
* Classifies the secret provenance of one exported sandbox file.
1231+
*
1232+
* Text exports are scanned for the exact resolved-secret plaintexts in scope. Binary exports cannot
1233+
* be scanned soundly — re-encoding can carry a secret without leaving a literal substring — so they
1234+
* are classified only when no secret material was in scope at all; with nothing available to embed,
1235+
* the bytes are provably secret-free. Otherwise they stay unknown, which fails closed at every
1236+
* model and runtime boundary that later reads the file.
1237+
*/
12111238
async function getOutputFileSecretProvenance(
12121239
buffer: Buffer,
12131240
isBinary: boolean,
12141241
context: FunctionRouteExecutionContext,
12151242
scope: { userId: string; workspaceId: string }
12161243
): Promise<WorkspaceFileSecretProvenance> {
1217-
if (isBinary) return { status: 'unknown' }
1244+
if (isBinary) {
1245+
return hasSecretMaterialInScope(context)
1246+
? { status: 'unknown' }
1247+
: EXACT_EMPTY_WORKSPACE_FILE_SECRET_PROVENANCE
1248+
}
12181249
const mountedFileProvenance = context.mountedFileSecretProvenanceScanner?.scan(buffer) ?? {
12191250
status: 'exact' as const,
12201251
entries: [],
@@ -1999,6 +2030,7 @@ export const POST = withRouteHandler(async (req: NextRequest) => {
19992030
outputSecretNamesByScanLiteral: new Map(),
20002031
outputSecretPlaintextsByName: new Map(),
20012032
mountedFileSecretProvenanceScanner,
2033+
hasMountedSandboxFiles: (_sandboxFiles?.length ?? 0) > 0,
20022034
}
20032035
for (const [name, plaintext] of Object.entries(envVars)) {
20042036
if (!plaintext) continue

apps/sim/lib/copilot/tools/handlers/function-execute.test.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -759,6 +759,22 @@ describe('executeFunctionExecute file mounts', () => {
759759
expect(mockExecuteTool).not.toHaveBeenCalled()
760760
})
761761

762+
it('omits the envelope when mounts it cannot attest to are already on the params', async () => {
763+
mockExecuteTool.mockResolvedValue({ success: true, output: { result: 'ok' } })
764+
765+
await executeFunctionExecute(
766+
{
767+
inputFiles: ['files/data.csv'],
768+
_sandboxFiles: [{ path: '/home/user/preserved.bin', content: 'from another resolver' }],
769+
},
770+
context
771+
)
772+
773+
const call = mockExecuteTool.mock.calls[0]?.[1]
774+
expect(call?._sandboxFiles?.length).toBeGreaterThan(1)
775+
expect(call?.[PRIVATE_SECRET_PROVENANCE_FIELD]).toBeUndefined()
776+
})
777+
762778
it('projects only mounted-file secrets that cross the settled Function result', async () => {
763779
mockImportWorkspaceFileSecretProvenanceForRuntime.mockImplementation(
764780
async ({ registry }: { registry?: ResolvedSecretTraceRegistry }) =>

apps/sim/lib/copilot/tools/handlers/function-execute.ts

Lines changed: 19 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -636,21 +636,30 @@ export async function executeFunctionExecute(
636636
secretActorUserId ?? context.userId,
637637
mountedRegistry
638638
)
639+
// Every mount ships its provenance envelope, tables included. The route classifies an
640+
// output file from that envelope, so a mount without one is unclassifiable there — it
641+
// cannot tell "nothing secret was mounted" from "nobody said". Emitting on the same
642+
// condition that produces the mount keeps the two from drifting apart.
639643
if (resolved.length > 0) {
640644
const existing = (enrichedParams._sandboxFiles as SandboxFile[]) || []
641645
enrichedParams._sandboxFiles = [...existing, ...resolved]
642-
}
643646

644-
if (inputFiles.length > 0 || inputDirectories.length > 0) {
645-
const provenance = mountedRegistry.exportProvenance()
646-
const bundle: PrivateSecretProvenanceBundleV1 = {
647-
version: 1,
648-
complete: provenance.complete,
649-
selections: provenance.complete
650-
? [{ key: MOUNTED_WORKSPACE_FILES_PROVENANCE_KEY, provenance }]
651-
: [],
647+
// The envelope attests to the WHOLE mounted set or it is not emitted at all. Mounts
648+
// that arrived already on the params came from outside this resolver, so
649+
// `mountedRegistry` knows nothing about them; an envelope covering only `resolved`
650+
// would read at the route as a complete attestation over every mounted byte. With no
651+
// envelope the route fails closed instead, which is the honest answer.
652+
if (existing.length === 0) {
653+
const provenance = mountedRegistry.exportProvenance()
654+
const bundle: PrivateSecretProvenanceBundleV1 = {
655+
version: 1,
656+
complete: provenance.complete,
657+
selections: provenance.complete
658+
? [{ key: MOUNTED_WORKSPACE_FILES_PROVENANCE_KEY, provenance }]
659+
: [],
660+
}
661+
enrichedParams[PRIVATE_SECRET_PROVENANCE_FIELD] = bundle
652662
}
653-
enrichedParams[PRIVATE_SECRET_PROVENANCE_FIELD] = bundle
654663
}
655664
}
656665
}

apps/sim/lib/copilot/tools/handlers/vfs.test.ts

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -292,6 +292,50 @@ describe('vfs handlers oversize policy', () => {
292292
)
293293
})
294294

295+
it('windows against the real line count when a read under-reports totalLines', async () => {
296+
const vfs = makeVfs()
297+
vfs.readFileContentWithProvenance.mockResolvedValue({
298+
// `/extract` synthesizes a whole extracted document but reports totalLines: 1.
299+
value: { content: 'page one\npage two\npage three', totalLines: 1 },
300+
file: { fileId: 'file-1', key: 'workspace/key-1', context: 'workspace' },
301+
})
302+
getOrMaterializeVFS.mockResolvedValue(vfs)
303+
304+
const result = await executeVfsRead(
305+
{ path: 'files/report.pdf/extract', offset: 1, limit: 2 },
306+
GREP_CTX
307+
)
308+
309+
expect(result.success).toBe(true)
310+
expect(result.output).toEqual({ content: 'page two\npage three', totalLines: 1 })
311+
})
312+
313+
it('leaves an attachment read unwindowed so its label is never blanked', async () => {
314+
const vfs = makeVfs()
315+
const imageResult = {
316+
content: 'Image: photo.jpeg (157.0KB, image/jpeg, resized for vision)',
317+
totalLines: 1,
318+
attachment: {
319+
type: 'image',
320+
name: 'photo.jpeg',
321+
source: { type: 'base64', media_type: 'image/jpeg', data: 'AAAA' },
322+
},
323+
}
324+
vfs.readFileContentWithProvenance.mockResolvedValue({
325+
value: imageResult,
326+
file: { fileId: 'file-1', key: 'workspace/key-1', context: 'workspace' },
327+
})
328+
getOrMaterializeVFS.mockResolvedValue(vfs)
329+
330+
const result = await executeVfsRead(
331+
{ path: 'files/photo.jpeg/content', offset: 1, limit: 100 },
332+
GREP_CTX
333+
)
334+
335+
expect(result.success).toBe(true)
336+
expect(result.output).toEqual(imageResult)
337+
})
338+
295339
it('rejects only the file read when durable provenance cannot be verified', async () => {
296340
const vfs = makeVfs()
297341
vfs.readFileContentWithProvenance.mockResolvedValue({

apps/sim/lib/copilot/tools/handlers/vfs.ts

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -285,12 +285,21 @@ export async function executeVfsRead(
285285
}
286286
const offset = parseOptionalNumber(params.offset)
287287
const limit = parseOptionalNumber(params.limit)
288+
/**
289+
* Applies the caller's line window, clamped against the content's ACTUAL line count rather than
290+
* the self-reported `totalLines`. Synthesized results report `totalLines: 1` for content that is
291+
* not one line (e.g. `files/x.pdf/extract` returns a whole extracted document), and clamping to
292+
* that collapses the read to its first line — or to nothing for any nonzero offset. An
293+
* attachment result is skipped outright: its `content` is a one-line label beside the bytes, so
294+
* a window can only blank the label while the model still receives the attachment.
295+
*/
288296
const applyWindow = <T extends { content: string; totalLines: number }>(result: T): T => {
289297
if (offset === undefined && limit === undefined) return result
298+
if (hasModelAttachment(result)) return result
290299
const lines = result.content.split('\n')
291-
const start = Math.max(0, Math.min(result.totalLines, offset ?? 0))
292-
const endRaw = limit !== undefined ? start + Math.max(0, limit) : result.totalLines
293-
const end = Math.max(start, Math.min(result.totalLines, endRaw))
300+
const start = Math.max(0, Math.min(lines.length, offset ?? 0))
301+
const endRaw = limit !== undefined ? start + Math.max(0, limit) : lines.length
302+
const end = Math.max(start, Math.min(lines.length, endRaw))
294303
return {
295304
...result,
296305
content: lines.slice(start, end).join('\n'),

apps/sim/lib/execution/mounted-file-secret-provenance.test.ts

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,38 @@ describe('mounted file output provenance scanner', () => {
5252
})
5353
})
5454

55+
it('reports whether the mount carried any secret material', async () => {
56+
const withSecrets = await createMountedFileSecretProvenanceScanner({
57+
version: 1,
58+
complete: true,
59+
entries: [{ encryptedValue: 'encrypted-a' }],
60+
scope: { userId: 'user-1', workspaceId: 'workspace-1' },
61+
})
62+
expect(withSecrets?.hasSecrets).toBe(true)
63+
64+
const withoutSecrets = await createMountedFileSecretProvenanceScanner({
65+
version: 1,
66+
complete: true,
67+
entries: [],
68+
scope: { userId: 'user-1', workspaceId: 'workspace-1' },
69+
})
70+
expect(withoutSecrets?.hasSecrets).toBe(false)
71+
expect(withoutSecrets?.scan(Buffer.from('anything'))).toEqual({ status: 'exact', entries: [] })
72+
})
73+
74+
it('keeps hasSecrets true when attested entries yield no scannable plaintext', async () => {
75+
encryptionMockFns.mockDecryptSecret.mockImplementation(async () => ({ decrypted: '' }))
76+
77+
const scanner = await createMountedFileSecretProvenanceScanner({
78+
version: 1,
79+
complete: true,
80+
entries: [{ encryptedValue: 'encrypted-a' }],
81+
scope: { userId: 'user-1', workspaceId: 'workspace-1' },
82+
})
83+
84+
expect(scanner?.hasSecrets).toBe(true)
85+
})
86+
5587
it('fails closed when encrypted provenance is incomplete or cannot be decrypted', async () => {
5688
await expect(
5789
createMountedFileSecretProvenanceScanner({ version: 1, complete: false, entries: [] })

0 commit comments

Comments
 (0)