Skip to content

Commit 8e94bf8

Browse files
committed
fix(provenance): record why a resolved-secret registry became incomplete
Incompleteness is one-way: once any guard trips, every later model projection in the run fails and the user is left with a single opaque sentence. Every guard could set it and none recorded which, in a file that imported no logger at all, so the cause could not be recovered after the fact. Name each guard with a static reason literal. Originating causes log at error because they permanently fail the run and error is the only level that survives every default the logger falls back to; reasons that merely carry an upstream fault forward log at warn so one fault does not read as several. The decrypt catch no longer discards its cause. No behaviour change. Reasons are static literals and the logged input path is block/field names; no resolved value is recorded.
1 parent cdeb83d commit 8e94bf8

2 files changed

Lines changed: 337 additions & 28 deletions

File tree

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

Lines changed: 185 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,21 @@
11
import { beforeEach, describe, expect, it, vi } from 'vitest'
22

3-
const { mockDecryptSecret } = vi.hoisted(() => ({
3+
const { mockDecryptSecret, mockLogger } = vi.hoisted(() => ({
44
mockDecryptSecret: vi.fn(),
5+
mockLogger: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() },
56
}))
67

78
vi.mock('@/lib/core/security/encryption', () => ({
89
decryptSecret: mockDecryptSecret,
910
}))
1011

12+
vi.mock('@sim/logger', () => ({
13+
createLogger: () => mockLogger,
14+
}))
15+
1116
import {
1217
ANONYMOUS_SECRET_TRACE_REPLACEMENT,
18+
createIncompleteResolvedSecretTraceRegistry,
1319
createResolvedSecretTraceRegistry,
1420
isResolvedSecretTraceProvenanceV1,
1521
RESOLVED_SECRET_TRACE_CHECKPOINT_VERSION,
@@ -1279,3 +1285,181 @@ describe('ResolvedSecretTraceRegistry', () => {
12791285
expect(registry.getModelEgressSnapshot()).toEqual({ complete: false })
12801286
})
12811287
})
1288+
1289+
describe('incompleteness diagnostics', () => {
1290+
const scope = { userId: 'user-1', workspaceId: 'workspace-1' }
1291+
1292+
beforeEach(() => {
1293+
mockLogger.warn.mockClear()
1294+
mockLogger.error.mockClear()
1295+
})
1296+
1297+
it('reports an originating incompleteness at error so the default log level cannot hide it', () => {
1298+
const registry = new ResolvedSecretTraceRegistry([], scope)
1299+
1300+
registry.markIncomplete('projection-mismatch')
1301+
1302+
expect(mockLogger.warn).not.toHaveBeenCalled()
1303+
expect(mockLogger.error).toHaveBeenCalledWith(
1304+
'Resolved secret registry marked incomplete',
1305+
expect.objectContaining({ reason: 'projection-mismatch' })
1306+
)
1307+
})
1308+
1309+
it('reports an inherited incompleteness at warn so one fault does not read as several', () => {
1310+
const registry = new ResolvedSecretTraceRegistry([], scope)
1311+
1312+
registry.markIncomplete('inherited-incomplete-source')
1313+
1314+
expect(mockLogger.error).not.toHaveBeenCalled()
1315+
expect(mockLogger.warn).toHaveBeenCalledWith(
1316+
'Resolved secret registry marked incomplete',
1317+
expect.objectContaining({ reason: 'inherited-incomplete-source' })
1318+
)
1319+
})
1320+
1321+
it('names the guard that tripped rather than reporting unspecified', () => {
1322+
const registry = new ResolvedSecretTraceRegistry([], scope)
1323+
1324+
registry.recordResolved('MISSING', 'value-not-in-catalog')
1325+
1326+
expect(mockLogger.error).toHaveBeenCalledWith(
1327+
'Resolved secret registry marked incomplete',
1328+
expect.objectContaining({ reason: 'unverified-resolved-entry' })
1329+
)
1330+
})
1331+
1332+
it('separates a tool-call scope mismatch from a merged child that was already incomplete', () => {
1333+
const scopeMismatch = new ResolvedSecretTraceRegistry([], scope)
1334+
const foreignChild = new ResolvedSecretTraceRegistry([], {
1335+
userId: 'user-1',
1336+
workspaceId: 'workspace-2',
1337+
})
1338+
1339+
scopeMismatch.mergeToolCallRegistry(foreignChild)
1340+
1341+
expect(mockLogger.error).toHaveBeenCalledWith(
1342+
'Resolved secret registry marked incomplete',
1343+
expect.objectContaining({ reason: 'tool-call-scope-mismatch' })
1344+
)
1345+
1346+
mockLogger.warn.mockClear()
1347+
mockLogger.error.mockClear()
1348+
1349+
const sameScope = new ResolvedSecretTraceRegistry([], scope)
1350+
const incompleteChild = new ResolvedSecretTraceRegistry([], scope)
1351+
incompleteChild.markIncomplete('projection-mismatch')
1352+
mockLogger.error.mockClear()
1353+
1354+
sameScope.mergeToolCallRegistry(incompleteChild)
1355+
1356+
expect(mockLogger.error).not.toHaveBeenCalled()
1357+
expect(mockLogger.warn).toHaveBeenCalledWith(
1358+
'Resolved secret registry marked incomplete',
1359+
expect.objectContaining({ reason: 'inherited-incomplete-source' })
1360+
)
1361+
})
1362+
1363+
it('attributes an already-incomplete bundle to its source rather than to the value filter', async () => {
1364+
const registry = new ResolvedSecretTraceRegistry([], scope)
1365+
1366+
await registry.importProvenanceForValue(
1367+
{ version: 1, complete: false, entries: [], scope },
1368+
'x',
1369+
{
1370+
trusted: true,
1371+
inputPath: ['prompt'],
1372+
}
1373+
)
1374+
1375+
const reasons = mockLogger.error.mock.calls
1376+
.concat(mockLogger.warn.mock.calls)
1377+
.map(([, details]) => (details as { reason?: string })?.reason)
1378+
expect(reasons).toContain('source-provenance-incomplete')
1379+
expect(reasons).not.toContain('value-provenance-filter-incomplete')
1380+
})
1381+
1382+
it('reports an incoming incomplete bundle at warn, since no catalog was ever on offer', () => {
1383+
const registry = new ResolvedSecretTraceRegistry([], scope)
1384+
1385+
registry.markIncomplete('source-provenance-incomplete')
1386+
1387+
expect(mockLogger.error).not.toHaveBeenCalled()
1388+
expect(mockLogger.warn).toHaveBeenCalledWith(
1389+
'Resolved secret registry marked incomplete',
1390+
expect.objectContaining({ reason: 'source-provenance-incomplete' })
1391+
)
1392+
})
1393+
1394+
it('stays silent for a registry built incomplete by design, which sits on hot paths', () => {
1395+
createIncompleteResolvedSecretTraceRegistry(scope)
1396+
1397+
expect(mockLogger.error).not.toHaveBeenCalled()
1398+
expect(mockLogger.warn).not.toHaveBeenCalled()
1399+
})
1400+
1401+
it('keeps an unaudited caller taking the default reason out of the error stream', () => {
1402+
const registry = new ResolvedSecretTraceRegistry([], scope)
1403+
1404+
registry.markIncomplete()
1405+
1406+
expect(mockLogger.error).not.toHaveBeenCalled()
1407+
expect(mockLogger.warn).toHaveBeenCalledWith(
1408+
'Resolved secret registry marked incomplete',
1409+
expect.objectContaining({ reason: 'unspecified' })
1410+
)
1411+
})
1412+
1413+
it('reports an incoming incomplete bundle exactly once, from the registry that knows the path', async () => {
1414+
const registry = new ResolvedSecretTraceRegistry([], scope)
1415+
1416+
await registry.importProvenanceForValue(
1417+
{ version: 1, complete: false, entries: [], scope },
1418+
'x',
1419+
{ trusted: true, inputPath: ['prompt'] }
1420+
)
1421+
1422+
const records = mockLogger.error.mock.calls.concat(mockLogger.warn.mock.calls)
1423+
expect(records).toHaveLength(1)
1424+
expect(records[0]).toEqual([
1425+
'Resolved secret input path marked incomplete',
1426+
expect.objectContaining({ reason: 'source-provenance-incomplete', inputPath: 'prompt' }),
1427+
])
1428+
})
1429+
1430+
it('summarises decrypt failures once per import instead of once per entry', async () => {
1431+
mockDecryptSecret.mockRejectedValue(new Error('key rotated'))
1432+
const registry = new ResolvedSecretTraceRegistry([], scope)
1433+
1434+
await registry.importProvenance(
1435+
{
1436+
version: 1,
1437+
complete: true,
1438+
entries: Array.from({ length: 25 }, (_, i) => ({
1439+
name: `SECRET_${i}`,
1440+
encryptedValue: `encrypted-${i}`,
1441+
})),
1442+
scope,
1443+
},
1444+
{ trusted: true }
1445+
)
1446+
1447+
const decryptRecords = mockLogger.error.mock.calls.filter(
1448+
([message]) => message === 'Provenance entries could not be decrypted'
1449+
)
1450+
expect(decryptRecords).toHaveLength(1)
1451+
expect(decryptRecords[0][1]).toEqual(
1452+
expect.objectContaining({ failedEntryCount: 25, totalEntryCount: 25, error: 'key rotated' })
1453+
)
1454+
})
1455+
1456+
it('records no secret material alongside the reason', () => {
1457+
const registry = new ResolvedSecretTraceRegistry([], scope)
1458+
1459+
registry.recordResolved('MISSING', 'super-secret-value')
1460+
1461+
const logged = JSON.stringify(mockLogger.error.mock.calls)
1462+
expect(logged).not.toContain('super-secret-value')
1463+
expect(logged).not.toContain('MISSING')
1464+
})
1465+
})

0 commit comments

Comments
 (0)