Skip to content

Commit 43d0be7

Browse files
committed
fix(secrets): preserve raw outputs with durable provenance
1 parent aae9ce6 commit 43d0be7

43 files changed

Lines changed: 3186 additions & 409 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

apps/sim/executor/handlers/function/function-handler.test.ts

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -200,6 +200,49 @@ describe('FunctionBlockHandler', () => {
200200
}
201201
})
202202

203+
it('forwards an explicit selected secret scope without changing legacy unset blocks', async () => {
204+
await handler.execute(mockContext, mockBlock, {
205+
code: 'return {{API_KEY}}',
206+
secretScope: 'selected',
207+
mountedSecrets: [' API_KEY ', 42, 'SECOND_KEY', '', 'API_KEY'],
208+
})
209+
210+
expect(mockExecuteTool).toHaveBeenCalledWith(
211+
'function_execute',
212+
expect.objectContaining({
213+
secretScope: 'selected',
214+
mountedSecrets: ['API_KEY', 'SECOND_KEY'],
215+
}),
216+
{ executionContext: mockContext }
217+
)
218+
219+
vi.clearAllMocks()
220+
mockExecuteTool.mockResolvedValue({ success: true, output: { result: 'Success' } })
221+
222+
await handler.execute(mockContext, mockBlock, { code: 'return {{API_KEY}}' })
223+
224+
const legacyParams = mockExecuteTool.mock.calls[0][1]
225+
expect(legacyParams).not.toHaveProperty('secretScope')
226+
expect(legacyParams).not.toHaveProperty('mountedSecrets')
227+
})
228+
229+
it('fails closed for an invalid explicit secret scope', async () => {
230+
await handler.execute(mockContext, mockBlock, {
231+
code: 'return {{API_KEY}}',
232+
secretScope: 'invalid',
233+
mountedSecrets: ['API_KEY'],
234+
})
235+
236+
expect(mockExecuteTool).toHaveBeenCalledWith(
237+
'function_execute',
238+
expect.objectContaining({
239+
secretScope: 'selected',
240+
mountedSecrets: [],
241+
}),
242+
{ executionContext: mockContext }
243+
)
244+
})
245+
203246
it('should handle execution errors from the tool', async () => {
204247
const inputs = { code: 'throw new Error("Code failed");' }
205248
const errorResult = { success: false, error: 'Function execution failed: Code failed' }

apps/sim/executor/handlers/function/function-handler.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { normalizeSecretMountPolicy } from '@/lib/copilot/secret-mount-policy'
12
import { getRemainingExecutionMs } from '@/lib/core/execution-limits'
23
import {
34
normalizeRecord,
@@ -68,13 +69,21 @@ export class FunctionBlockHandler implements BlockHandler {
6869
? remainingExecutionMs
6970
: Math.min(requestedTimeout, remainingExecutionMs)
7071
)
72+
const secretMountPolicy =
73+
inputs.secretScope === undefined
74+
? undefined
75+
: normalizeSecretMountPolicy({
76+
secretScope: inputs.secretScope,
77+
mountedSecrets: inputs.mountedSecrets,
78+
})
7179

7280
const toolParams = {
7381
code: codeContent,
7482
...(sourceCode ? { sourceCode } : {}),
7583
language: inputs.language || DEFAULT_CODE_LANGUAGE,
7684
timeout,
7785
...(inputs.sandboxId ? { sandboxId: inputs.sandboxId } : {}),
86+
...(secretMountPolicy ?? {}),
7887
envVars: normalizeStringRecord(ctx.environmentVariables),
7988
workflowVariables: normalizeWorkflowVariables(ctx.workflowVariables),
8089
blockData: {},

apps/sim/executor/utils/resolved-secret-matcher.test.ts

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,11 +8,34 @@ import {
88
OPAQUE_RESOLVED_SECRET_REPLACEMENT,
99
sanitizeResolvedSecretPrimitive,
1010
sanitizeResolvedSecretString,
11+
scanResolvedSecretString,
1112
} from '@/executor/utils/resolved-secret-matcher'
1213

1314
const PRESERVE_NAMED_PROVENANCE = { preserveNamedProvenanceLabels: true } as const
1415

1516
describe('resolved secret matcher', () => {
17+
it('reports each matched literal once across large repeated content', () => {
18+
const matcher = createResolvedSecretMatcher([
19+
{ plaintext: 'x', replacement: '{{SHORT}}' },
20+
{ plaintext: 'xx', replacement: '{{OVERLAP}}' },
21+
{ plaintext: 'abc', replacement: '{{PREFIX}}' },
22+
{ plaintext: 'bc', replacement: '{{SUFFIX}}' },
23+
])
24+
const matches: string[] = []
25+
26+
expect(matcher).toBeDefined()
27+
if (!matcher) return
28+
expect(
29+
scanResolvedSecretString(
30+
`${'x'.repeat(1_000_001)}abcabc`,
31+
matcher,
32+
(match) => matches.push(match),
33+
4
34+
)
35+
).toBe(4)
36+
expect(matches).toEqual(['x', 'xx', 'abc', 'bc'])
37+
})
38+
1639
it('uses exact matching for typed primitive renderings', () => {
1740
const matcher = createResolvedSecretMatcher([{ plaintext: '23', replacement: '{{TOKEN}}' }])
1841

apps/sim/executor/utils/resolved-secret-matcher.ts

Lines changed: 27 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -231,7 +231,7 @@ export function containsResolvedSecretLiteral(
231231
return false
232232
}
233233

234-
/** Visits exact secret literals with the same bounded automaton used by content projection. */
234+
/** Visits each distinct exact secret literal once with the content-projection automaton. */
235235
export function scanResolvedSecretString(
236236
value: string,
237237
matcher: ResolvedSecretMatcher,
@@ -240,16 +240,40 @@ export function scanResolvedSecretString(
240240
): number {
241241
let node = matcher.root
242242
let matchEvents = 0
243+
const matchedPlaintexts = new Set<string>()
244+
const nextUnmatchedOutput = new WeakMap<SecretTrieNode, SecretTrieNode | null>()
245+
246+
const findNextUnmatchedOutput = (
247+
candidate: SecretTrieNode | undefined
248+
): SecretTrieNode | undefined => {
249+
let current = candidate
250+
const exhaustedPath: SecretTrieNode[] = []
251+
while (current?.replacement && matchedPlaintexts.has(current.replacement.plaintext)) {
252+
const cached = nextUnmatchedOutput.get(current)
253+
if (cached !== undefined) {
254+
current = cached ?? undefined
255+
continue
256+
}
257+
exhaustedPath.push(current)
258+
current = current.outputLink
259+
}
260+
for (const exhausted of exhaustedPath) {
261+
nextUnmatchedOutput.set(exhausted, current ?? null)
262+
}
263+
return current
264+
}
265+
243266
for (let index = 0; index < value.length; index += 1) {
244267
node = advanceMatcher(matcher, node, value[index])
245-
let outputNode: SecretTrieNode | undefined = node.replacement ? node : node.outputLink
268+
let outputNode = findNextUnmatchedOutput(node.replacement ? node : node.outputLink)
246269
while (outputNode?.replacement) {
247270
matchEvents += 1
248271
if (matchEvents > maxMatchEvents) {
249272
throw new ResolvedSecretMatcherError('Secret matcher event limit exceeded')
250273
}
274+
matchedPlaintexts.add(outputNode.replacement.plaintext)
251275
onMatch(outputNode.replacement.plaintext)
252-
outputNode = outputNode.outputLink
276+
outputNode = findNextUnmatchedOutput(outputNode)
253277
}
254278
}
255279
return matchEvents

apps/sim/executor/utils/resolved-secret-trace-registry.test.ts

Lines changed: 132 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -248,6 +248,42 @@ describe('ResolvedSecretTraceRegistry', () => {
248248
}
249249
})
250250

251+
it('keeps a named anonymous secret distinct from anonymous provenance', async () => {
252+
mockDecryptSecret.mockResolvedValueOnce({ decrypted: 'same-secret' })
253+
const registry = new ResolvedSecretTraceRegistry(
254+
[{ name: 'anonymous', plaintext: 'same-secret', encryptedValue: 'shared-ciphertext' }],
255+
{ userId: 'user-1', workspaceId: 'workspace-1' }
256+
)
257+
registry.recordResolved('anonymous', 'same-secret')
258+
await registry.importProvenance(
259+
{
260+
version: 1,
261+
complete: true,
262+
entries: [{ encryptedValue: 'shared-ciphertext' }],
263+
scope: { userId: 'user-1', workspaceId: 'workspace-1' },
264+
},
265+
{ trusted: true, anonymous: true }
266+
)
267+
268+
expect(registry.exportCommittedProvenanceForValue('same-secret')).toEqual({
269+
version: 1,
270+
complete: true,
271+
entries: [
272+
{ encryptedValue: 'shared-ciphertext' },
273+
{ name: 'anonymous', encryptedValue: 'shared-ciphertext' },
274+
],
275+
scope: { userId: 'user-1', workspaceId: 'workspace-1' },
276+
})
277+
const snapshot = registry.getModelEgressSnapshot()
278+
expect(snapshot.complete).toBe(true)
279+
if (snapshot.complete) {
280+
expect(snapshot.matches).toContainEqual({
281+
plaintext: 'same-secret',
282+
replacement: ANONYMOUS_SECRET_TRACE_REPLACEMENT,
283+
})
284+
}
285+
})
286+
251287
it('projects committed provenance while temporary activations are pending', () => {
252288
const registry = new ResolvedSecretTraceRegistry([
253289
{ name: 'API_KEY', plaintext: 'secret-value', encryptedValue: 'encrypted-value' },
@@ -701,6 +737,102 @@ describe('ResolvedSecretTraceRegistry', () => {
701737
})
702738
})
703739

740+
it('exports active provenance for a registered legacy runtime alias', () => {
741+
const registry = new ResolvedSecretTraceRegistry([
742+
{ name: 'API-KEY', plaintext: 'secret-value', encryptedValue: 'present-ciphertext' },
743+
])
744+
registry.recordResolved('API-KEY', 'secret-value')
745+
746+
expect(registry.exportCommittedProvenanceForValue('prefix __var_API_KEY suffix')).toEqual({
747+
version: 1,
748+
complete: true,
749+
entries: [{ name: 'API-KEY', encryptedValue: 'present-ciphertext' }],
750+
})
751+
})
752+
753+
it('ignores repeated unrelated runtime aliases without exhausting the scan budget', () => {
754+
const registry = new ResolvedSecretTraceRegistry([
755+
{ name: 'API_KEY', plaintext: 'secret-value', encryptedValue: 'present-ciphertext' },
756+
])
757+
registry.recordResolved('API_KEY', 'secret-value')
758+
759+
expect(
760+
registry.exportCommittedProvenanceForValue(`${'__var_Z '.repeat(1_000_001)}__var_API_KEY`)
761+
).toEqual({
762+
version: 1,
763+
complete: true,
764+
entries: [{ name: 'API_KEY', encryptedValue: 'present-ciphertext' }],
765+
})
766+
})
767+
768+
it('matches legacy runtime aliases as complete tokens instead of prefixes', () => {
769+
const registry = new ResolvedSecretTraceRegistry([
770+
{ name: 'A', plaintext: 'secret-a', encryptedValue: 'ciphertext-a' },
771+
{ name: 'API_KEY', plaintext: 'secret-api', encryptedValue: 'ciphertext-api' },
772+
])
773+
registry.recordResolved('A', 'secret-a')
774+
registry.recordResolved('API_KEY', 'secret-api')
775+
776+
expect(registry.exportCommittedProvenanceForValue('__var_API_KEY')).toEqual({
777+
version: 1,
778+
complete: true,
779+
entries: [{ name: 'API_KEY', encryptedValue: 'ciphertext-api' }],
780+
})
781+
})
782+
783+
it('conservatively retains every secret mapped to a colliding runtime alias', () => {
784+
const registry = new ResolvedSecretTraceRegistry([
785+
{ name: 'API-KEY', plaintext: 'first-secret', encryptedValue: 'first-ciphertext' },
786+
{ name: 'API_KEY', plaintext: 'second-secret', encryptedValue: 'second-ciphertext' },
787+
])
788+
registry.recordResolved('API-KEY', 'first-secret')
789+
registry.recordResolved('API_KEY', 'second-secret')
790+
791+
expect(registry.exportCommittedProvenanceForValue('__var_API_KEY')).toEqual({
792+
version: 1,
793+
complete: true,
794+
entries: [
795+
{ name: 'API-KEY', encryptedValue: 'first-ciphertext' },
796+
{ name: 'API_KEY', encryptedValue: 'second-ciphertext' },
797+
],
798+
})
799+
})
800+
801+
it('retains an alias-specific entry when multiple names share one plaintext', () => {
802+
const registry = new ResolvedSecretTraceRegistry([
803+
{ name: 'FIRST', plaintext: 'shared-secret', encryptedValue: 'first-ciphertext' },
804+
{ name: 'SECOND', plaintext: 'shared-secret', encryptedValue: 'second-ciphertext' },
805+
])
806+
registry.recordResolved('FIRST', 'shared-secret')
807+
registry.recordResolved('SECOND', 'shared-secret')
808+
809+
expect(registry.exportCommittedProvenanceForValue('__var_SECOND')).toEqual({
810+
version: 1,
811+
complete: true,
812+
entries: [{ name: 'SECOND', encryptedValue: 'second-ciphertext' }],
813+
})
814+
})
815+
816+
it('conservatively retains every active secret that shares a raw plaintext literal', () => {
817+
const registry = new ResolvedSecretTraceRegistry([
818+
{ name: 'FIRST', plaintext: 'true', encryptedValue: 'first-ciphertext' },
819+
{ name: 'SECOND', plaintext: 'true', encryptedValue: 'second-ciphertext' },
820+
])
821+
registry.recordResolved('FIRST', 'true')
822+
registry.recordResolved('SECOND', 'true')
823+
824+
const expected = {
825+
version: 1 as const,
826+
complete: true,
827+
entries: [
828+
{ name: 'FIRST', encryptedValue: 'first-ciphertext' },
829+
{ name: 'SECOND', encryptedValue: 'second-ciphertext' },
830+
],
831+
}
832+
expect(registry.exportCommittedProvenanceForValue('true')).toEqual(expected)
833+
expect(registry.exportCommittedProvenanceForValue(true)).toEqual(expected)
834+
})
835+
704836
it('exports active numeric, boolean, and null literals crossing a value boundary', () => {
705837
const registry = new ResolvedSecretTraceRegistry([
706838
{ name: 'NUMBER', plaintext: '1234', encryptedValue: 'number-ciphertext' },

0 commit comments

Comments
 (0)