Skip to content

Commit 5ad820d

Browse files
fix(chat): stop classifying secret-free binary sandbox exports as unknown (#6349)
* fix(execution): stop classifying secret-free binary sandbox exports as unknown * fix(execution): fail closed when files are mounted without a provenance envelope The binary classifier read an absent mounted-file scanner as "no mounted secrets". That is absence of evidence, not evidence of absence: the request contract permits _sandboxFiles without the provenance envelope, so a caller that mounts secret-bearing bytes and omits the envelope would have a derived binary persisted as provably secret-free. Not reachable today — the route is internal-JWT-only and its one file-mounting caller always emits the envelope — but the classification rested on an invariant nothing enforced. - the copilot handler emits the envelope on the same condition that produces the mount, so tables ship one too and the two cannot drift apart - a mount with no verified scanner now counts as secret material in scope, so the classification is never stronger than what the caller attested to Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(execution): treat partial and unscannable mount attestations as unknown Two ways the envelope could read as stronger evidence than it was. The copilot handler preserved `_sandboxFiles` that arrived on the params and then exported provenance from `mountedRegistry`, which knows only about the files it resolved itself. The route would have read that partial envelope as a complete attestation over every mounted byte. The envelope now covers the whole mounted set or is not emitted at all, and a mount with no envelope already fails closed. `hasSecrets` was derived from whether entries produced scannable literals, so an envelope listing entries that all failed to decrypt reported false and let a derived binary be marked exact-empty. It now reflects what the envelope attested to: entries that yield no plaintext make the mount less classifiable, not more. Neither was reachable — `_sandboxFiles` is absent from the copilot tool schema, so nothing can populate the preserved-mount branch — but both had the classification resting on a property nothing enforced. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * chore(tools): regenerate stale tool metadata `bun run tool-metadata:check` fails on origin/staging as well as here, so this is not from this branch — #6317 landed the artifact generated from a factory that still built a per-provider apiKey description, and the source was later genericized without regenerating. Regenerating changes exactly the five embeddings entries' apiKey description to the text `tools/embeddings/factory.ts:74` actually produces. The per-provider strings appear nowhere in source. Included here only because the gate is red on every branch cut from staging until someone lands it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(execution): count the runtime payload as secret material in scope An execution with no mounted files and no env secret still carries `params` and `contextVariables` into the sandbox — the runtime payload is serialized into a private-input file, so resolved block outputs and workflow variables land as plaintext regardless of `_sandboxFiles`. The scope predicate only looked at mounts and env secrets, so a binary derived from them was classified exact-empty. The route has no catalog for those values and cannot tell a secret-bearing one from an ordinary one, so they count as in scope. Only an execution with nothing at all in scope earns an exact-empty binary. This narrows where the relaxation applies rather than regressing anything: every binary export was unknown before this branch, so a workflow Function block carrying block references keeps exactly the behavior it has today. The mothership path is unaffected — its tool sets no contextVariables, blockData, or workflowVariables, which is the case this branch exists to fix. Values, not keys, for the params check: `executionParams._context` is set to undefined before the context is built, so a key count reads every execution as carrying params. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * Revert "fix(execution): count the runtime payload as secret material in scope" This reverts commit 754e37c. The classifier's secret catalog is the Secrets feature and nothing else: `outputSecretNamesByScanLiteral` and `outputSecretPlaintextsByName` are built only from `envVars`, and mounted-file entries trace back to the same place. `contextVariables`, `blockData`, and `workflowVariables` are ordinary workflow data — resolved block outputs the user already sees in logs — and the text export path does not scan them either. Treating their mere presence as secret material was a heuristic, not a security property, and it created exactly the asymmetry rejected two rounds earlier: a binary derived from a context variable would be `unknown` while a text export of the same bytes stays exact-empty. Stricter than the text path for the same content is not a boundary. It was also nearly inert. `scopeEnvironmentVariables` returns every workspace secret when scope is `all` (the default), so any workflow Function block with secrets configured already trips the env branch. The only slice it changed was executions with no env vars at all, where the workspace has no secret for a context variable to carry. A Secret resolved into an upstream block's output and arriving here through blockData is a real gap, but it is pre-existing, identical for text exports, and belongs at the executor -> route boundary as a provenance envelope for params — not as a presence check in this classifier. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent 10878fb commit 5ad820d

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)