Skip to content

Commit 14ca769

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

4 files changed

Lines changed: 53 additions & 14 deletions

File tree

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: 15 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -644,15 +644,22 @@ export async function executeFunctionExecute(
644644
const existing = (enrichedParams._sandboxFiles as SandboxFile[]) || []
645645
enrichedParams._sandboxFiles = [...existing, ...resolved]
646646

647-
const provenance = mountedRegistry.exportProvenance()
648-
const bundle: PrivateSecretProvenanceBundleV1 = {
649-
version: 1,
650-
complete: provenance.complete,
651-
selections: provenance.complete
652-
? [{ key: MOUNTED_WORKSPACE_FILES_PROVENANCE_KEY, provenance }]
653-
: [],
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
654662
}
655-
enrichedParams[PRIVATE_SECRET_PROVENANCE_FIELD] = bundle
656663
}
657664
}
658665
}

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

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,19 @@ describe('mounted file output provenance scanner', () => {
7171
expect(withoutSecrets?.scan(Buffer.from('anything'))).toEqual({ status: 'exact', entries: [] })
7272
})
7373

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+
7487
it('fails closed when encrypted provenance is incomplete or cannot be decrypted', async () => {
7588
await expect(
7689
createMountedFileSecretProvenanceScanner({ version: 1, complete: false, entries: [] })

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

Lines changed: 9 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -12,9 +12,11 @@ const ANONYMOUS_MOUNTED_FILE_SECRET_NAME = 'MOUNTED_FILE_SECRET'
1212

1313
export interface MountedFileSecretProvenanceScanner {
1414
/**
15-
* True when the mount actually carried secret material. False means the mounted files were
16-
* classified and contributed nothing, so no mounted secret can reach an output file — which lets
17-
* callers classify content this scanner cannot soundly scan (binary bytes) instead of failing closed.
15+
* True when the envelope attested to any secret material, whether or not it could be turned into
16+
* a scannable literal. False therefore means the mount carried nothing to leak — which lets
17+
* callers classify content this scanner cannot soundly scan (binary bytes) instead of failing
18+
* closed. Entries that fail to yield plaintext keep this true: losing the ability to scan them
19+
* makes the mount less classifiable, not more.
1820
*/
1921
hasSecrets: boolean
2022
scan(buffer: Buffer): WorkspaceFileSecretProvenance
@@ -34,6 +36,7 @@ export async function createMountedFileSecretProvenanceScanner(
3436
): Promise<MountedFileSecretProvenanceScanner | undefined> {
3537
if (!provenance.complete || !provenance.scope?.userId) return undefined
3638

39+
const hasSecrets = provenance.entries.length > 0
3740
const entriesByScanLiteral = new Map<string, Map<string, WorkspaceFileSecretProvenanceEntry>>()
3841
try {
3942
for (const entry of provenance.entries) {
@@ -63,7 +66,7 @@ export async function createMountedFileSecretProvenanceScanner(
6366
}
6467

6568
if (entriesByScanLiteral.size === 0) {
66-
return { hasSecrets: false, scan: () => ({ status: 'exact', entries: [] }) }
69+
return { hasSecrets, scan: () => ({ status: 'exact', entries: [] }) }
6770
}
6871

6972
let matcher
@@ -75,11 +78,11 @@ export async function createMountedFileSecretProvenanceScanner(
7578
return undefined
7679
}
7780
if (!matcher) {
78-
return { hasSecrets: false, scan: () => ({ status: 'exact', entries: [] }) }
81+
return { hasSecrets, scan: () => ({ status: 'exact', entries: [] }) }
7982
}
8083

8184
return {
82-
hasSecrets: true,
85+
hasSecrets,
8386
scan(buffer) {
8487
const matched = new Map<string, WorkspaceFileSecretProvenanceEntry>()
8588
try {

0 commit comments

Comments
 (0)