Skip to content

Commit 8a65a76

Browse files
author
Vikhyath Mondreti
committed
fix(provenance): preserve mixed files and repeated durable bindings
1 parent d5fe948 commit 8a65a76

14 files changed

Lines changed: 1185 additions & 110 deletions

‎apps/sim/content/blog/secret-provenance/index.mdx‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ faq:
2121
- q: "Does Sim use regular expressions to detect secrets in logs?"
2222
a: "No. Sim resolved the secret itself, so it can compare content against the exact values activated in that run. Pattern matching is useful when a scanner does not know which secrets exist; the execution runtime has stronger context."
2323
- q: "What happens if Sim cannot determine whether content contains a secret?"
24-
a: "The boundary fails closed. The payload is withheld, while non-sensitive structure such as timing, block identity, and status remains available for debugging."
24+
a: "Incomplete provenance at a run's own egress boundaries withholds the payload while preserving safe diagnostic structure. Durable reads follow the surface's enforcement policy: legacy content remains readable, and unknown stored provenance can be observed before enforcement is enabled."
2525
- q: "Are secrets tracked across files and tables, or only within one execution?"
2626
a: "Across both. Durable content such as workspace files, table cells, knowledge documents, and agent memory carries encrypted provenance so a later run does not mistake secret-bearing data for clean data."
2727
- q: "Is the model treated as an internal component or an egress boundary?"

‎apps/sim/lib/execution/durable-secret-provenance.test.ts‎

Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,13 @@ import {
1919
filterDurableSecretProvenanceBySourceValues,
2020
hashDurableSecretProvenanceValue,
2121
importDurableSecretProvenance,
22+
mergeDurableSecretProvenance,
23+
normalizeDurableSecretProvenanceEntries,
2224
} from '@/lib/execution/durable-secret-provenance'
25+
import {
26+
PROVENANCE_MAX_ENTRIES,
27+
PROVENANCE_MAX_SERIALIZED_BYTES,
28+
} from '@/lib/execution/provenance-limits'
2329
import { ResolvedSecretTraceRegistry } from '@/executor/utils/resolved-secret-trace-registry'
2430

2531
function privateBundle(scope?: { userId: string; workspaceId?: string }) {
@@ -41,6 +47,17 @@ function privateBundle(scope?: { userId: string; workspaceId?: string }) {
4147
}
4248

4349
describe('durable secret provenance hashing', () => {
50+
it('hashes many small values within the byte budget without a separate node cutoff', () => {
51+
const messages = Array.from({ length: 17_000 }, (_, index) => ({
52+
role: 'user',
53+
content: `message ${index}`,
54+
}))
55+
const hash = hashDurableSecretProvenanceValue(messages)
56+
expect(hash).toMatch(/^[a-f0-9]{64}$/)
57+
expect(hashDurableSecretProvenanceValue(structuredClone(messages))).toBe(hash)
58+
expect(hashDurableSecretProvenanceValue('x'.repeat(16 * 1024 * 1024))).toBeUndefined()
59+
})
60+
4461
it('hashes equivalent plain JSON deterministically without key-order sensitivity', () => {
4562
expect(hashDurableSecretProvenanceValue({ b: [true, null], a: 'value' })).toBe(
4663
hashDurableSecretProvenanceValue({ a: 'value', b: [true, null] })
@@ -83,6 +100,101 @@ describe('durable secret provenance hashing', () => {
83100
})
84101
})
85102

103+
describe('durable provenance binding capacity', () => {
104+
it('folds duplicates while preserving more than 10,000 bindings of one secret', () => {
105+
const entries = Array.from({ length: PROVENANCE_MAX_ENTRIES + 1 }, (_, index) => ({
106+
encryptedValue: 'ciphertext',
107+
sourceValueHash: `hash-${index}`,
108+
}))
109+
const normalized = normalizeDurableSecretProvenanceEntries(entries)
110+
expect(normalized).toHaveLength(entries.length)
111+
expect(
112+
mergeDurableSecretProvenance({ status: 'exact', entries }, { status: 'exact', entries })
113+
).toEqual({ status: 'exact', entries: normalized })
114+
expect(normalizeDurableSecretProvenanceEntries(Array(entries.length).fill(entries[0]))).toEqual(
115+
[entries[0]]
116+
)
117+
})
118+
119+
it('still refuses more than 10,000 distinct secrets', () => {
120+
const entries = Array.from({ length: PROVENANCE_MAX_ENTRIES + 1 }, (_, index) => ({
121+
encryptedValue: `ciphertext-${index}`,
122+
}))
123+
expect(normalizeDurableSecretProvenanceEntries(entries.slice(0, -1))).toHaveLength(
124+
PROVENANCE_MAX_ENTRIES
125+
)
126+
expect(normalizeDurableSecretProvenanceEntries(entries)).toBeUndefined()
127+
})
128+
129+
it('measures escaped UTF-8 JSON bytes including array separators', () => {
130+
const entry = { encryptedValue: 'ciphertext', name: 'é\n' }
131+
const overhead = Buffer.byteLength(JSON.stringify([entry]), 'utf8')
132+
const exact = {
133+
...entry,
134+
encryptedValue: entry.encryptedValue + 'x'.repeat(PROVENANCE_MAX_SERIALIZED_BYTES - overhead),
135+
}
136+
expect(normalizeDurableSecretProvenanceEntries([exact])).toEqual([exact])
137+
expect(
138+
normalizeDurableSecretProvenanceEntries([
139+
{ ...exact, encryptedValue: `${exact.encryptedValue}x` },
140+
])
141+
).toBeUndefined()
142+
expect(normalizeDurableSecretProvenanceEntries([exact, entry])).toBeUndefined()
143+
})
144+
145+
it('preserves distinct bindings whose fields contain delimiter characters', () => {
146+
const entries = [
147+
{ encryptedValue: 'ciphertext', name: 'name', sourceValueHash: 'hash\u0000part' },
148+
{ encryptedValue: 'ciphertext', name: 'part\u0000name', sourceValueHash: 'hash' },
149+
]
150+
expect(normalizeDurableSecretProvenanceEntries(entries)).toHaveLength(2)
151+
})
152+
153+
it('folds message hashes only after selection while retaining source scope and names on import', async () => {
154+
const registry = new ResolvedSecretTraceRegistry()
155+
const imported = vi.spyOn(registry, 'importProvenance').mockResolvedValue(true)
156+
const entries = Array.from({ length: PROVENANCE_MAX_ENTRIES + 1 }, (_, index) => ({
157+
encryptedValue: 'ciphertext',
158+
name: 'TOKEN',
159+
sourceUserId: 'source-user',
160+
sourceWorkspaceId: 'source-workspace',
161+
sourceValueHash: `hash-${index}`,
162+
}))
163+
await expect(
164+
importDurableSecretProvenance(registry, {
165+
status: 'exact',
166+
entries: [
167+
...entries,
168+
{ ...entries[0], name: 'ALIAS' },
169+
{ ...entries[0], sourceUserId: 'other-user' },
170+
],
171+
})
172+
).resolves.toBe(true)
173+
expect(imported).toHaveBeenCalledTimes(2)
174+
expect(imported).toHaveBeenCalledWith(
175+
{
176+
version: 1,
177+
complete: true,
178+
scope: { userId: 'source-user', workspaceId: 'source-workspace' },
179+
entries: [
180+
{ encryptedValue: 'ciphertext', name: 'ALIAS' },
181+
{ encryptedValue: 'ciphertext', name: 'TOKEN' },
182+
],
183+
},
184+
{ trusted: true, origin: 'durableProvenance.envelope' }
185+
)
186+
expect(imported).toHaveBeenCalledWith(
187+
{
188+
version: 1,
189+
complete: true,
190+
scope: { userId: 'other-user', workspaceId: 'source-workspace' },
191+
entries: [{ encryptedValue: 'ciphertext', name: 'TOKEN' }],
192+
},
193+
{ trusted: true, origin: 'durableProvenance.envelope' }
194+
)
195+
})
196+
})
197+
86198
describe('private durable provenance scope admission', () => {
87199
it('accepts a different source user in the authorized destination workspace', () => {
88200
expect(

‎apps/sim/lib/execution/durable-secret-provenance.ts‎

Lines changed: 50 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -9,17 +9,14 @@ import {
99
isPrivateSecretProvenanceBundleV1,
1010
type PrivateSecretProvenanceBundleV1,
1111
} from '@/lib/execution/model-input-provenance'
12-
import {
13-
PROVENANCE_MAX_ENTRIES,
14-
PROVENANCE_MAX_SERIALIZED_BYTES,
15-
} from '@/lib/execution/provenance-limits'
12+
import { SecretProvenanceBudget } from '@/lib/execution/provenance-budget'
13+
import { PROVENANCE_MAX_SERIALIZED_BYTES } from '@/lib/execution/provenance-limits'
1614
import {
1715
type ResolvedSecretTraceProvenanceV1,
1816
ResolvedSecretTraceRegistry,
1917
type ResolvedSecretTraceScopeV1,
2018
} from '@/executor/utils/resolved-secret-trace-registry'
2119

22-
const MAX_DURABLE_HASH_NODES = 50_000
2320
const MAX_DURABLE_HASH_DEPTH = 100
2421
const MAX_DURABLE_HASH_BYTES = 16 * 1024 * 1024
2522

@@ -40,13 +37,15 @@ function compareStrings(left: string, right: string): number {
4037
export function normalizeDurableSecretProvenanceEntries(
4138
value: unknown
4239
): DurableSecretProvenanceEntry[] | undefined {
43-
if (!Array.isArray(value) || value.length > PROVENANCE_MAX_ENTRIES) {
44-
return undefined
45-
}
40+
return Array.isArray(value) ? normalizeDurableSecretProvenanceBindings(value) : undefined
41+
}
4642

43+
function normalizeDurableSecretProvenanceBindings(
44+
candidates: Iterable<unknown>
45+
): DurableSecretProvenanceEntry[] | undefined {
4746
const entries = new Map<string, DurableSecretProvenanceEntry>()
48-
let bytes = 0
49-
for (const candidate of value) {
47+
const budget = new SecretProvenanceBudget()
48+
for (const candidate of candidates) {
5049
if (!candidate || typeof candidate !== 'object' || Array.isArray(candidate)) return undefined
5150
const record = candidate as Record<string, unknown>
5251
if (
@@ -73,25 +72,25 @@ export function normalizeDurableSecretProvenanceEntries(
7372
? { sourceValueHash: record.sourceValueHash }
7473
: {}),
7574
}
76-
const key = `${entry.sourceUserId ?? ''}\u0000${entry.sourceWorkspaceId ?? ''}\u0000${entry.sourceValueHash ?? ''}\u0000${entry.name ?? ''}\u0000${entry.encryptedValue}`
75+
let minimumBytes = 0
76+
for (const field of Object.values(entry)) {
77+
minimumBytes += Buffer.byteLength(field, 'utf8')
78+
if (minimumBytes > PROVENANCE_MAX_SERIALIZED_BYTES) return undefined
79+
}
80+
const key = JSON.stringify(entry)
7781
if (entries.has(key)) continue
78-
bytes += Buffer.byteLength(key, 'utf8')
79-
if (bytes > PROVENANCE_MAX_SERIALIZED_BYTES) return undefined
82+
if (!budget.add(entry.encryptedValue, Buffer.byteLength(key, 'utf8'))) return undefined
8083
entries.set(key, entry)
8184
}
8285

83-
const normalized = [...entries.values()].sort(
86+
return [...entries.values()].sort(
8487
(left, right) =>
8588
compareStrings(left.sourceUserId ?? '', right.sourceUserId ?? '') ||
8689
compareStrings(left.sourceWorkspaceId ?? '', right.sourceWorkspaceId ?? '') ||
8790
compareStrings(left.sourceValueHash ?? '', right.sourceValueHash ?? '') ||
8891
compareStrings(left.name ?? '', right.name ?? '') ||
8992
compareStrings(left.encryptedValue, right.encryptedValue)
9093
)
91-
if (Buffer.byteLength(JSON.stringify(normalized), 'utf8') > PROVENANCE_MAX_SERIALIZED_BYTES) {
92-
return undefined
93-
}
94-
return normalized
9594
}
9695

9796
/** Converts a complete transport envelope into its scope-preserving durable representation. */
@@ -165,8 +164,12 @@ export function mergeDurableSecretProvenance(
165164
...values: readonly DurableSecretProvenance[]
166165
): DurableSecretProvenance {
167166
if (values.some((value) => value.status === 'unknown')) return { status: 'unknown' }
168-
const normalized = normalizeDurableSecretProvenanceEntries(
169-
values.flatMap((value) => (value.status === 'exact' ? value.entries : []))
167+
const normalized = normalizeDurableSecretProvenanceBindings(
168+
(function* () {
169+
for (const value of values) {
170+
if (value.status === 'exact') yield* value.entries
171+
}
172+
})()
170173
)
171174
return normalized ? { status: 'exact', entries: normalized } : { status: 'unknown' }
172175
}
@@ -244,32 +247,40 @@ export async function importDurableSecretProvenance(
244247
return false
245248
}
246249

247-
const grouped = new Map<string, DurableSecretProvenanceEntry[]>()
250+
const grouped = new Map<
251+
string,
252+
{
253+
scope?: ResolvedSecretTraceScopeV1
254+
entries: Map<string, ResolvedSecretTraceProvenanceV1['entries'][number]>
255+
}
256+
>()
248257
for (const entry of entries) {
249-
const key = `${entry.sourceUserId ?? ''}\u0000${entry.sourceWorkspaceId ?? ''}`
250-
const group = grouped.get(key) ?? []
251-
group.push(entry)
252-
grouped.set(key, group)
258+
const scope = entry.sourceUserId
259+
? {
260+
userId: entry.sourceUserId,
261+
...(entry.sourceWorkspaceId ? { workspaceId: entry.sourceWorkspaceId } : {}),
262+
}
263+
: undefined
264+
const key = JSON.stringify(scope ?? null)
265+
let group = grouped.get(key)
266+
if (!group) {
267+
group = { scope, entries: new Map() }
268+
grouped.set(key, group)
269+
}
270+
const transportEntry = {
271+
encryptedValue: entry.encryptedValue,
272+
...(entry.name ? { name: entry.name } : {}),
273+
}
274+
group.entries.set(JSON.stringify(transportEntry), transportEntry)
253275
}
254276

255277
let complete = true
256278
for (const group of grouped.values()) {
257-
const first = group[0]
258279
const envelope: ResolvedSecretTraceProvenanceV1 = {
259280
version: 1,
260281
complete: true,
261-
entries: group.map((entry) => ({
262-
encryptedValue: entry.encryptedValue,
263-
...(entry.name ? { name: entry.name } : {}),
264-
})),
265-
...(first.sourceUserId
266-
? {
267-
scope: {
268-
userId: first.sourceUserId,
269-
...(first.sourceWorkspaceId ? { workspaceId: first.sourceWorkspaceId } : {}),
270-
},
271-
}
272-
: {}),
282+
entries: [...group.entries.values()],
283+
...(group.scope ? { scope: group.scope } : {}),
273284
}
274285
const imported =
275286
value === undefined
@@ -309,7 +320,6 @@ export async function createDurableSecretProvenanceRegistry(
309320
export function hashDurableSecretProvenanceValue(value: unknown): string | undefined {
310321
const hash = createHash('sha256')
311322
const ancestors = new WeakSet<object>()
312-
let nodes = 0
313323
let bytes = 0
314324

315325
const append = (chunk: string): boolean => {
@@ -320,8 +330,7 @@ export function hashDurableSecretProvenanceValue(value: unknown): string | undef
320330
}
321331

322332
const visit = (candidate: unknown, depth: number): boolean => {
323-
nodes++
324-
if (nodes > MAX_DURABLE_HASH_NODES || depth > MAX_DURABLE_HASH_DEPTH) return false
333+
if (depth > MAX_DURABLE_HASH_DEPTH) return false
325334
if (candidate === null) return append('null')
326335
if (typeof candidate === 'string') return append(JSON.stringify(candidate))
327336
if (typeof candidate === 'boolean') return append(candidate ? 'true' : 'false')
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
/**
2+
* @vitest-environment node
3+
*/
4+
import { describe, expect, it } from 'vitest'
5+
import { SecretProvenanceBudget } from '@/lib/execution/provenance-budget'
6+
import {
7+
PROVENANCE_MAX_ENTRIES,
8+
PROVENANCE_MAX_SERIALIZED_BYTES,
9+
} from '@/lib/execution/provenance-limits'
10+
11+
describe('incremental provenance budget', () => {
12+
it('charges repeated bindings against bytes but only distinct ciphertexts against secrets', () => {
13+
const budget = new SecretProvenanceBudget()
14+
for (let index = 0; index < PROVENANCE_MAX_ENTRIES + 1; index++) {
15+
expect(budget.add('same-ciphertext', 100)).toBe(true)
16+
}
17+
})
18+
19+
it('refuses an additional distinct secret without refusing another binding of a known secret', () => {
20+
const budget = new SecretProvenanceBudget()
21+
for (let index = 0; index < PROVENANCE_MAX_ENTRIES; index++) {
22+
expect(budget.add(`ciphertext-${index}`, 100)).toBe(true)
23+
}
24+
expect(budget.add('additional-secret', 100)).toBe(false)
25+
expect(budget.add('ciphertext-0', 100)).toBe(true)
26+
})
27+
28+
it('includes brackets and commas and does not consume capacity on a rejected binding', () => {
29+
const budget = new SecretProvenanceBudget()
30+
expect(budget.add('first', PROVENANCE_MAX_SERIALIZED_BYTES - 5)).toBe(true)
31+
expect(budget.add('too-large', 3)).toBe(false)
32+
expect(budget.add('fits', 2)).toBe(true)
33+
expect(budget.add('fits', 2)).toBe(false)
34+
})
35+
})
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
import {
2+
PROVENANCE_MAX_ENTRIES,
3+
PROVENANCE_MAX_SERIALIZED_BYTES,
4+
} from '@/lib/execution/provenance-limits'
5+
6+
/** Tracks distinct secrets and serialized bytes while callers fold durable bindings incrementally. */
7+
export class SecretProvenanceBudget {
8+
private readonly encryptedValues = new Set<string>()
9+
private serializedBytes = 2
10+
private bindingCount = 0
11+
12+
/** Admits one deduplicated binding, including its JSON array separator in the byte budget. */
13+
add(encryptedValue: string, serializedEntryBytes: number): boolean {
14+
const nextBytes = this.serializedBytes + serializedEntryBytes + (this.bindingCount > 0 ? 1 : 0)
15+
if (
16+
nextBytes > PROVENANCE_MAX_SERIALIZED_BYTES ||
17+
(!this.encryptedValues.has(encryptedValue) &&
18+
this.encryptedValues.size >= PROVENANCE_MAX_ENTRIES)
19+
) {
20+
return false
21+
}
22+
this.encryptedValues.add(encryptedValue)
23+
this.serializedBytes = nextBytes
24+
this.bindingCount++
25+
return true
26+
}
27+
}

0 commit comments

Comments
 (0)