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>
This commit is contained in:
Vikhyath Mondreti
2026-08-06 16:53:36 -07:00
co-authored by Claude Opus 5
parent ddc1c1ae73
commit 754e37cedb
2 changed files with 57 additions and 0 deletions
@@ -890,6 +890,40 @@ describe('Function Execute API Route', () => {
)
})
it('keeps a binary export unknown when resolved context variables reach the sandbox', 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',
// Resolved upstream block output — the route has no catalog to classify it.
contextVariables: { upstreamValue: 'could-be-anything' },
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 files were mounted without a provenance envelope', async () => {
envFlagsMock.isRemoteSandboxEnabled = true
mockExecuteInSandbox.mockResolvedValueOnce({
@@ -990,6 +990,12 @@ interface FunctionRouteExecutionContext {
outputSecretPlaintextsByName: Map<string, string>
mountedFileSecretProvenanceScanner?: MountedFileSecretProvenanceScanner
hasMountedSandboxFiles: boolean
/**
* Whether the serialized runtime payload carried `params` or `contextVariables` into the sandbox.
* Those are resolved block outputs and workflow variables — the route has no catalog for them, so
* it cannot tell a secret-bearing one from an ordinary value.
*/
hasUnclassifiedRuntimeInputs: boolean
}
type ResolvedSecretNamesMetadataType =
@@ -1219,9 +1225,15 @@ function activateOutputSecretProvenance(
* 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.
*
* The serialized runtime payload counts too. It carries `params` and `contextVariables` — resolved
* block outputs and workflow variables — into the sandbox as a private-input file, and the route
* has no catalog to tell a secret-bearing one from an ordinary value. Only an execution with
* nothing at all in scope earns an exact-empty binary.
*/
function hasSecretMaterialInScope(context: FunctionRouteExecutionContext): boolean {
if (context.outputSecretPlaintextsByName.size > 0) return true
if (context.hasUnclassifiedRuntimeInputs) return true
const scanner = context.mountedFileSecretProvenanceScanner
return scanner ? scanner.hasSecrets : context.hasMountedSandboxFiles
}
@@ -2031,6 +2043,12 @@ export const POST = withRouteHandler(async (req: NextRequest) => {
outputSecretPlaintextsByName: new Map(),
mountedFileSecretProvenanceScanner,
hasMountedSandboxFiles: (_sandboxFiles?.length ?? 0) > 0,
// Values, not keys: `executionParams._context` is explicitly set to undefined just above,
// so a key count would read every execution as carrying params. Raised below once the
// resolved context variables are known.
hasUnclassifiedRuntimeInputs: Object.values(executionParams).some(
(value) => value !== undefined
),
}
for (const [name, plaintext] of Object.entries(envVars)) {
if (!plaintext) continue
@@ -2074,6 +2092,11 @@ export const POST = withRouteHandler(async (req: NextRequest) => {
...codeResolution.contextVariables,
...preResolvedContextVariables,
}
// Resolved block outputs and workflow variables ride into the sandbox inside the runtime
// payload, so an output file can carry them even with nothing mounted and no env secret.
if (Object.keys(contextVariables).length > 0) {
routeContext.hasUnclassifiedRuntimeInputs = true
}
const compilation = await compileCodePlaceholders({
code: codeResolution.resolvedCode,
language: lang,