Skip to content

Commit b879960

Browse files
fix(provenance): make one length floor the whole substitution rule (#6551)
* fix(provenance): make one length floor the whole substitution rule A literal shorter than eight characters is no longer substituted anywhere. It was already the floor for matches inside a larger token; below it a second tier still substituted whenever the hit sat on a word boundary — standing alone, delimited, or as the whole value — on the theory that those positions made the hit unambiguous. Position is not the variable that matters. A hit on `7` is uninformative wherever it sits, because the value space is ten. That tier rewrote `_raw_idx = 7` into `_raw_idx = {{WEEKLY_OWNWORK_TTL}}` for the one shard whose index collided with a TTL variable, and turned 2,000 boolean `had_error` cells into `[REDACTED_SECRET]` because a `*_ENABLED` variable held `false`. Each was patched with a per-value exception list; the floor subsumes both, so the lists are deleted. With no literal below the floor reaching a matcher, the tier's machinery is unreachable and goes with it: the match-policy type, its classifier, the word boundary test, and the detect/render mode that existed only to select between them. One constant now governs the question. The cost is explicit and accepted: a secret shorter than eight characters is no longer redacted from logs or model-visible content. Substitution cannot hide a value that short — an observer who can read the surrounding text can enumerate it. Two tests that pinned the old tier are rewritten to pin this, rather than deleted, so the trade stays visible. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * test(provenance): restore two tests that the length floor had hollowed out Greptile caught the byte-limit test: lengthening the catalog fixture without the `recordResolved` value beside it left them mismatched, so the registry latched and `projectResolvedSecretModelJsonContent` returned `{ safe: false }` at its completeness guard — before any alias projection. The test passed while covering nothing it is named for. It now records the same literal it catalogs, sizes the limit between the raw bytes and the projected bytes, and asserts both directions so a limit applied to the wrong side fails it. Auditing every changed test for the same shape — a `recordResolved` value that does not match its own catalog entry — found one more, pre-dating this branch: the legacy-memory test drew its teeth from substituting the one-character secret `x` inside `Box`, which the floor no longer substitutes, so it too had become vacuous. Its fixture is now a full-length secret that appears in the message, which is what makes "not projected" meaningful. The three remaining mismatches are deliberate: those tests are about a resolution that fails to verify. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * test(provenance): give the label-protection test a live matcher again Cursor caught the third instance of the same shape: the fixture `TOK` is three characters, so the floor drops it and the matcher is empty. The assertion was `'Bearer {{TOKEN}}'` in and out, which an empty matcher satisfies exactly as well as working label protection — the test could no longer tell them apart. Use a fixture over the floor whose label still contains its own plaintext, and project the bare plaintext first. That control fails if the matcher is inert, so the atomic-label assertion beside it can only pass for the right reason. Auditing every test on this branch for the shape — all fixtures below the floor — returned twenty-four, but the rest are sound: most assert that nothing is substituted, which the floor makes more certain rather than less, and the remainder run through the resolver's causal path, which never consults a matcher. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * test(provenance): make the byte-limit test reject at the gate it names Three limits can reject this value and my previous fix still tripped the wrong one. `projectResolvedSecretModelJsonContent` checks the raw encoding, then walks the content against a running budget, then re-encodes the projected object — and only the last is what the test is for. At 16 the walk charged the key `a` and then measured the 17-byte alias against the remaining 15, so it failed before the re-encoding ran; at 25 everything passed. Neither assertion touched the check. Twenty is the band that isolates it: the walk admits the alias against its remaining 19, so a rejection there can only come from re-encoding the 25-byte result. Asserting the content projection succeeds at the same limit pins that, and deleting the re-encoding check now fails the test rather than leaving it green. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent 596bf4f commit b879960

20 files changed

Lines changed: 320 additions & 643 deletions

apps/sim/executor/execution/block-executor.test.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -530,15 +530,15 @@ describe('BlockExecutor', () => {
530530
const resolver = new VariableResolver(workflow, {}, state)
531531
const onBlockComplete = vi.fn(async () => {})
532532
const registry = new ResolvedSecretTraceRegistry([
533-
{ name: 'SHORT_SECRET', plaintext: 'Test', encryptedValue: 'encrypted-test' },
533+
{ name: 'SHORT_SECRET', plaintext: 'TestValue', encryptedValue: 'encrypted-test' },
534534
])
535535
const handler: BlockHandler = {
536536
canHandle: () => true,
537537
execute: async (blockContext, block) => {
538538
if (block.id === secretBlock.id) {
539-
blockContext.resolvedSecretTraceRegistry?.recordResolved('SHORT_SECRET', 'Test')
539+
blockContext.resolvedSecretTraceRegistry?.recordResolved('SHORT_SECRET', 'TestValue')
540540
}
541-
return { result: 'Test' }
541+
return { result: 'TestValue' }
542542
},
543543
}
544544
const executor = new BlockExecutor([handler], resolver, { onBlockComplete }, state)

apps/sim/executor/handlers/agent/memory.test.ts

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -347,14 +347,14 @@ describe('Memory', () => {
347347
expect(result.content).toBe('foreign-secret')
348348
})
349349

350-
it.each(['123'])(
350+
it.each(['12345678'])(
351351
'projects short secret %s only in model text and arguments',
352352
async (secret) => {
353353
const registry = new ResolvedSecretTraceRegistry([
354354
{ name: 'TOKEN', plaintext: secret, encryptedValue: 'ciphertext' },
355355
])
356356
registry.recordResolved('TOKEN', secret)
357-
const converted = secret === '123' ? 123 : true
357+
const converted = secret === '12345678' ? 12345678 : true
358358
const message: Message = {
359359
role: 'assistant',
360360
content: `Result: ${secret}`,
@@ -465,11 +465,11 @@ describe('Memory', () => {
465465

466466
it('does not project unrelated active secrets into legacy memory', async () => {
467467
const registry = new ResolvedSecretTraceRegistry([
468-
{ name: 'TOKEN', plaintext: 'x', encryptedValue: 'ciphertext' },
468+
{ name: 'TOKEN', plaintext: 'unrelated-secret', encryptedValue: 'ciphertext' },
469469
])
470-
registry.recordResolved('TOKEN', 'x')
470+
expect(registry.recordResolved('TOKEN', 'unrelated-secret')).toBe(true)
471471
vi.spyOn(memoryService as any, 'fetchMemory').mockResolvedValueOnce({
472-
messages: [{ role: 'assistant', content: 'Box' }],
472+
messages: [{ role: 'assistant', content: 'Box unrelated-secret' }],
473473
provenance: { status: 'exact', entries: [] },
474474
})
475475

@@ -478,7 +478,7 @@ describe('Memory', () => {
478478
inputs
479479
)
480480

481-
expect(messages).toEqual([{ role: 'assistant', content: 'Box' }])
481+
expect(messages).toEqual([{ role: 'assistant', content: 'Box unrelated-secret' }])
482482
})
483483

484484
it('does not activate provenance from a message dropped by the selected window', async () => {

apps/sim/executor/handlers/pi/search/tool.test.ts

Lines changed: 9 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,7 @@ function executionContext(
3636
const ctx = executionContext()
3737

3838
function buildTool(provider: 'exa' | 'serper' | 'parallel' | 'firecrawl' = 'exa', context = ctx) {
39-
return buildPiSearchToolSpec(context, { provider, apiKey: 'key-123' }, 'local')
39+
return buildPiSearchToolSpec(context, { provider, apiKey: 'key-1234567' }, 'local')
4040
}
4141

4242
async function run(
@@ -69,7 +69,7 @@ describe('buildPiSearchToolSpec', () => {
6969

7070
const [toolId, params, options] = mockExecuteTool.mock.calls[0]
7171
expect(toolId).toBe('exa_search')
72-
expect(params.apiKey).toBe('key-123')
72+
expect(params.apiKey).toBe('key-1234567')
7373
expect(params.timeout).toBe(10_000)
7474
expect(options.executionContext).toBe(ctx)
7575
expect(options.resolvedSecretTraceRegistry).toBeInstanceOf(ResolvedSecretTraceRegistry)
@@ -83,7 +83,7 @@ describe('buildPiSearchToolSpec', () => {
8383

8484
const [toolId, params] = mockExecuteTool.mock.calls[0]
8585
expect(toolId).toBe('serper_search')
86-
expect(params).toEqual({ query: 'pi', num: 2, apiKey: 'key-123', timeout: 10_000 })
86+
expect(params).toEqual({ query: 'pi', num: 2, apiKey: 'key-1234567', timeout: 10_000 })
8787
})
8888

8989
it('normalizes a successful provider response into the envelope', async () => {
@@ -193,17 +193,17 @@ describe('buildPiSearchToolSpec', () => {
193193

194194
it('projects only the exact resolver-recorded search key and leaves the raw result unchanged', async () => {
195195
const registry = new ResolvedSecretTraceRegistry([
196-
{ name: 'SEARCH_KEY', plaintext: 'key-123', encryptedValue: 'search-ciphertext' },
196+
{ name: 'SEARCH_KEY', plaintext: 'key-1234567', encryptedValue: 'search-ciphertext' },
197197
{ name: 'UNRELATED', plaintext: 'Test', encryptedValue: 'unrelated-ciphertext' },
198198
])
199-
registry.recordResolvedAtInputPath('SEARCH_KEY', 'key-123', ['searchApiKey'])
200-
registry.recordResolvedInputProjection(['searchApiKey'], 'key-123', '{{SEARCH_KEY}}')
199+
registry.recordResolvedAtInputPath('SEARCH_KEY', 'key-1234567', ['searchApiKey'])
200+
registry.recordResolvedInputProjection(['searchApiKey'], 'key-1234567', '{{SEARCH_KEY}}')
201201
registry.recordResolvedAtInputPath('UNRELATED', 'Test', ['task'])
202202
registry.recordResolvedInputProjection(['task'], 'Test', '{{UNRELATED}}')
203203
const output = {
204204
results: [
205205
{
206-
title: 'key-123',
206+
title: 'key-1234567',
207207
url: 'https://example.com/docs',
208208
text: 'Test',
209209
},
@@ -213,7 +213,7 @@ describe('buildPiSearchToolSpec', () => {
213213

214214
const result = await buildPiSearchToolSpec(
215215
executionContext(registry),
216-
{ provider: 'exa', apiKey: 'key-123' },
216+
{ provider: 'exa', apiKey: 'key-1234567' },
217217
'local',
218218
'{{SEARCH_KEY}}'
219219
).execute({ query: 'pi' })
@@ -231,7 +231,7 @@ describe('buildPiSearchToolSpec', () => {
231231
expect(output).toEqual({
232232
results: [
233233
{
234-
title: 'key-123',
234+
title: 'key-1234567',
235235
url: 'https://example.com/docs',
236236
text: 'Test',
237237
},

apps/sim/executor/utils/resolved-secret-content-projection.test.ts

Lines changed: 68 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -161,37 +161,37 @@ describe('projectResolvedSecretModelContent', () => {
161161

162162
it('keeps longest-match semantics when a known opaque placeholder is nested in a secret', () => {
163163
const registry = new ResolvedSecretTraceRegistry([
164-
{ name: 'Test', plaintext: 'Test', encryptedValue: 'test-ciphertext' },
164+
{ name: 'TestName', plaintext: 'TestName', encryptedValue: 'test-ciphertext' },
165165
{
166166
name: 'COMPOSITE',
167-
plaintext: 'x{{Test}}y',
167+
plaintext: 'x{{TestName}}y',
168168
encryptedValue: 'composite-ciphertext',
169169
},
170170
])
171-
registry.recordResolved('Test', 'Test')
172-
registry.recordResolved('COMPOSITE', 'x{{Test}}y')
171+
registry.recordResolved('TestName', 'TestName')
172+
registry.recordResolved('COMPOSITE', 'x{{TestName}}y')
173173

174-
expect(projectResolvedSecretModelContent('x{{Test}}y', registry)).toEqual({
174+
expect(projectResolvedSecretModelContent('x{{TestName}}y', registry)).toEqual({
175175
safe: true,
176176
value: '{{COMPOSITE}}',
177177
})
178178
})
179179

180180
it('projects exact typed numeric secrets, leaving booleans and null identifying nothing', () => {
181181
const registry = new ResolvedSecretTraceRegistry([
182-
{ name: 'NUMBER', plaintext: '123', encryptedValue: 'number-ciphertext' },
182+
{ name: 'NUMBER', plaintext: '12345678', encryptedValue: 'number-ciphertext' },
183183
{ name: 'BOOLEAN', plaintext: 'true', encryptedValue: 'boolean-ciphertext' },
184184
{ name: 'NULL', plaintext: 'null', encryptedValue: 'null-ciphertext' },
185185
])
186-
registry.recordResolved('NUMBER', '123')
186+
registry.recordResolved('NUMBER', '12345678')
187187
registry.recordResolved('BOOLEAN', 'true')
188188
registry.recordResolved('NULL', 'null')
189189

190190
expect(
191191
projectResolvedSecretModelContent(
192192
{
193-
strings: ['123', 'true', 'null'],
194-
number: 123,
193+
strings: ['12345678', 'true', 'null'],
194+
number: 12345678,
195195
boolean: true,
196196
nothing: null,
197197
unrelatedNumber: 1234,
@@ -212,12 +212,12 @@ describe('projectResolvedSecretModelContent', () => {
212212
})
213213
})
214214

215-
it.each(['123'])('keeps projected JSON argument strings valid (%s)', (secret) => {
215+
it.each(['12345678'])('keeps projected JSON argument strings valid (%s)', (secret) => {
216216
const registry = new ResolvedSecretTraceRegistry([
217217
{ name: 'TOKEN', plaintext: secret, encryptedValue: 'ciphertext' },
218218
])
219219
registry.recordResolved('TOKEN', secret)
220-
const typedValue = secret === '123' ? 123 : true
220+
const typedValue = secret === '12345678' ? 12345678 : true
221221

222222
const projection = projectResolvedSecretModelJsonStrings(
223223
[JSON.stringify({ secret, converted: typedValue, nested: [typedValue] })],
@@ -255,50 +255,59 @@ describe('projectResolvedSecretModelContent', () => {
255255

256256
it('is stable when a secret literal overlaps its own provenance alias', () => {
257257
const registry = new ResolvedSecretTraceRegistry([
258-
{ name: 'TOKEN', plaintext: 'TOKEN', encryptedValue: 'ciphertext' },
258+
{ name: 'TOKEN', plaintext: 'TOKENTOKEN', encryptedValue: 'ciphertext' },
259259
])
260-
registry.recordResolved('TOKEN', 'TOKEN')
260+
registry.recordResolved('TOKEN', 'TOKENTOKEN')
261261

262-
const first = projectResolvedSecretModelContent('Bearer TOKEN', registry)
262+
const first = projectResolvedSecretModelContent('Bearer TOKENTOKEN', registry)
263263
expect(first).toEqual({ safe: true, value: 'Bearer {{TOKEN}}' })
264264
if (!first.safe) return
265265
expect(projectResolvedSecretModelContent(first.value, registry)).toEqual(first)
266266
})
267267

268268
it('preserves the canonical provenance label when its name equals the secret plaintext', () => {
269269
const registry = new ResolvedSecretTraceRegistry([
270-
{ name: 'Test', plaintext: 'Test', encryptedValue: 'ciphertext' },
270+
{ name: 'TestName', plaintext: 'TestName', encryptedValue: 'ciphertext' },
271271
])
272-
registry.recordResolved('Test', 'Test')
272+
registry.recordResolved('TestName', 'TestName')
273273

274274
expect(
275275
projectResolvedSecretModelContent(
276276
{
277-
result: 'Test',
278-
source: 'return {{Test}}',
279-
error: "NameError: name 'Test' is not defined",
277+
result: 'TestName',
278+
source: 'return {{TestName}}',
279+
error: "NameError: name 'TestName' is not defined",
280280
},
281281
registry
282282
)
283283
).toEqual({
284284
safe: true,
285285
value: {
286-
result: '{{Test}}',
287-
source: 'return {{Test}}',
288-
error: "NameError: name '{{Test}}' is not defined",
286+
result: '{{TestName}}',
287+
source: 'return {{TestName}}',
288+
error: "NameError: name '{{TestName}}' is not defined",
289289
},
290290
})
291291
})
292292

293293
it('atomically projects the selected provenance label when its name contains the value', () => {
294294
const registry = new ResolvedSecretTraceRegistry([
295-
{ name: 'TOKEN', plaintext: 'TOK', encryptedValue: 'ciphertext' },
295+
{ name: 'TOKENVALUE_NAME', plaintext: 'TOKENVALUE', encryptedValue: 'ciphertext' },
296296
])
297-
registry.recordResolved('TOKEN', 'TOK')
298-
299-
expect(projectResolvedSecretModelContent('Bearer {{TOKEN}}', registry)).toEqual({
297+
expect(registry.recordResolved('TOKENVALUE_NAME', 'TOKENVALUE')).toBe(true)
298+
299+
/**
300+
* The label `{{TOKENVALUE_NAME}}` contains the plaintext that produced it, so it is only left
301+
* alone because the label is treated atomically. Projecting the bare plaintext first proves the
302+
* matcher is live — without it an empty matcher would satisfy the second assertion too.
303+
*/
304+
expect(projectResolvedSecretModelContent('Bearer TOKENVALUE', registry)).toEqual({
305+
safe: true,
306+
value: 'Bearer {{TOKENVALUE_NAME}}',
307+
})
308+
expect(projectResolvedSecretModelContent('Bearer {{TOKENVALUE_NAME}}', registry)).toEqual({
300309
safe: true,
301-
value: 'Bearer {{TOKEN}}',
310+
value: 'Bearer {{TOKENVALUE_NAME}}',
302311
})
303312
})
304313

@@ -308,7 +317,7 @@ describe('projectResolvedSecretModelContent', () => {
308317
complete: true,
309318
matches: [
310319
{
311-
plaintext: 'x'.repeat(64 * 1024),
320+
plaintext: 'xxxxxxxx'.repeat(64 * 1024),
312321
replacement: '[REDACTED_SECRET]',
313322
},
314323
],
@@ -321,16 +330,18 @@ describe('projectResolvedSecretModelContent', () => {
321330

322331
it('keeps provenance-shaped content deterministic without trusting it as a protocol handle', () => {
323332
const registry = new ResolvedSecretTraceRegistry([
324-
{ name: 'Test', plaintext: 'Test', encryptedValue: 'ciphertext' },
333+
{ name: 'TestName', plaintext: 'TestName', encryptedValue: 'ciphertext' },
325334
])
326-
registry.recordResolved('Test', 'Test')
335+
registry.recordResolved('TestName', 'TestName')
327336

328-
expect(projectResolvedSecretModelContent('{{Test}}', registry)).toEqual({
337+
expect(projectResolvedSecretModelContent('{{TestName}}', registry)).toEqual({
329338
safe: true,
330-
value: '{{Test}}',
339+
value: '{{TestName}}',
331340
})
332-
expect(isResolvedSecretModelContentUnchanged('{{Test}}', registry)).toBe(false)
333-
expect(isResolvedSecretModelContentUnchanged(['resource', '{{Test}}'], registry)).toBe(false)
341+
expect(isResolvedSecretModelContentUnchanged('{{TestName}}', registry)).toBe(false)
342+
expect(isResolvedSecretModelContentUnchanged(['resource', '{{TestName}}'], registry)).toBe(
343+
false
344+
)
334345
expect(isResolvedSecretModelContentUnchanged(['resource', 'safe'], registry)).toBe(true)
335346
})
336347
})
@@ -409,11 +420,29 @@ describe('projectResolvedSecretModelJsonContent', () => {
409420

410421
it('enforces the byte limit after secret aliases are projected', () => {
411422
const registry = new ResolvedSecretTraceRegistry([
412-
{ name: 'X', plaintext: 'x', encryptedValue: 'ciphertext' },
423+
{ name: 'X_LONGER_NAME', plaintext: 'xxxxxxxx', encryptedValue: 'ciphertext' },
413424
])
414-
registry.recordResolved('X', 'x')
415-
416-
expect(projectResolvedSecretModelJsonContent({ a: 'x' }, registry, 9)).toEqual({ safe: false })
425+
expect(registry.recordResolved('X_LONGER_NAME', 'xxxxxxxx')).toBe(true)
426+
427+
/**
428+
* Three separate limits can reject this value, and only the last one is what this test is for:
429+
* the raw encoding (16 bytes), the content walk's running budget, and the JSON re-encoding of
430+
* the projected object (25 bytes). A limit of 20 is the only band that isolates the third —
431+
* the walk charges the key `a` and then admits the 17-byte alias against the remaining 19, so
432+
* anything that rejects at 20 can only be the wire check. Pinning both halves keeps it that
433+
* way: drop the re-encoding check and the second assertion starts passing.
434+
*/
435+
expect(projectResolvedSecretModelContent({ a: 'xxxxxxxx' }, registry, 20)).toEqual({
436+
safe: true,
437+
value: { a: '{{X_LONGER_NAME}}' },
438+
})
439+
expect(projectResolvedSecretModelJsonContent({ a: 'xxxxxxxx' }, registry, 20)).toEqual({
440+
safe: false,
441+
})
442+
expect(projectResolvedSecretModelJsonContent({ a: 'xxxxxxxx' }, registry, 25)).toEqual({
443+
safe: true,
444+
value: { a: '{{X_LONGER_NAME}}' },
445+
})
417446
})
418447
})
419448

@@ -445,7 +474,7 @@ describe('projectResolvedSecretDiagnosticError', () => {
445474

446475
it('sanitizes an inactive compiler alias without activating or scanning its secret', () => {
447476
const registry = new ResolvedSecretTraceRegistry([
448-
{ name: 'X', plaintext: 'x', encryptedValue: 'ciphertext' },
477+
{ name: 'X', plaintext: 'xxxxxxxx', encryptedValue: 'ciphertext' },
449478
])
450479
const error = new Error('Box __var_X')
451480

apps/sim/executor/utils/resolved-secret-content-projection.ts

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,6 @@ function createResolvedSecretModelMatcher(
3838
): ResolvedSecretMatcher | undefined {
3939
const matcher = createResolvedSecretMatcher(matches, {
4040
preserveNamedProvenanceLabels: true,
41-
mode: 'render',
4241
})
4342
if (!matcher) return undefined
4443

@@ -75,7 +74,7 @@ function createResolvedSecretModelMatcher(
7574
})),
7675
...opaquePlaceholderMatches,
7776
],
78-
{ preserveNamedProvenanceLabels: true, mode: 'render' }
77+
{ preserveNamedProvenanceLabels: true }
7978
)
8079
}
8180

0 commit comments

Comments
 (0)