Skip to content

Commit 59afafa

Browse files
icecrasher321claude
andcommitted
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>
1 parent a9a03d7 commit 59afafa

3 files changed

Lines changed: 47 additions & 6 deletions

File tree

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

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -890,6 +890,39 @@ describe('Function Execute API Route', () => {
890890
)
891891
})
892892

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+
893926
it('keeps a binary export unknown when a mounted input file carried a secret', async () => {
894927
envFlagsMock.isRemoteSandboxEnabled = true
895928
mockExecuteInSandbox.mockResolvedValueOnce({

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

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -989,6 +989,7 @@ interface FunctionRouteExecutionContext {
989989
outputSecretNamesByScanLiteral: Map<string, string[]>
990990
outputSecretPlaintextsByName: Map<string, string>
991991
mountedFileSecretProvenanceScanner?: MountedFileSecretProvenanceScanner
992+
hasMountedSandboxFiles: boolean
992993
}
993994

994995
type ResolvedSecretNamesMetadataType =
@@ -1213,12 +1214,16 @@ function activateOutputSecretProvenance(
12131214
* True when any secret material was in scope for this execution — a mounted environment secret, or
12141215
* a secret carried by a mounted input file. When false, nothing secret ever reached the sandbox, so
12151216
* 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.
12161222
*/
12171223
function hasSecretMaterialInScope(context: FunctionRouteExecutionContext): boolean {
1218-
return (
1219-
context.outputSecretPlaintextsByName.size > 0 ||
1220-
(context.mountedFileSecretProvenanceScanner?.hasSecrets ?? false)
1221-
)
1224+
if (context.outputSecretPlaintextsByName.size > 0) return true
1225+
const scanner = context.mountedFileSecretProvenanceScanner
1226+
return scanner ? scanner.hasSecrets : context.hasMountedSandboxFiles
12221227
}
12231228

12241229
/**
@@ -2025,6 +2030,7 @@ export const POST = withRouteHandler(async (req: NextRequest) => {
20252030
outputSecretNamesByScanLiteral: new Map(),
20262031
outputSecretPlaintextsByName: new Map(),
20272032
mountedFileSecretProvenanceScanner,
2033+
hasMountedSandboxFiles: (_sandboxFiles?.length ?? 0) > 0,
20282034
}
20292035
for (const [name, plaintext] of Object.entries(envVars)) {
20302036
if (!plaintext) continue

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

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -636,12 +636,14 @@ 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) {
645647
const provenance = mountedRegistry.exportProvenance()
646648
const bundle: PrivateSecretProvenanceBundleV1 = {
647649
version: 1,

0 commit comments

Comments
 (0)