Skip to content

Commit 9cecf0b

Browse files
improvement(provenance): aggregate and attribute unrecorded durable reads (#7017)
Fail-open on unrecorded durable provenance rests on one compensating control: the audit entry telling the people who own the secrets that a read proceeded unvouched. An audit of all four surfaces found the control incomplete in exactly the places this closes, and confirmed the policy itself sound — so nothing here changes what any read or write does, only what gets recorded about it. Knowledge was the one surface with no audit trail at all: the per-record import reports without a workspace, and the report skips the audit row when it cannot name one, so fail-open knowledge reads emitted one error log line per record and zero audit entries. Both importers now count unrecorded records while the surface is open and report once per read with the workspace, actor, and count — the shape memory and tables already use. The search read reports once across chunks and rendered metadata, and only when the registry did not latch, since a latched read never reaches a model. Fault returns stay silent; those reads fail closed. Memory had the one silent local degrade: a record whose canonical hash outgrows its bounds, or whose entries fail normalization, was stored unknown with nothing logged anywhere — the table writer logs its equivalent. The binding now logs the cause at error where it is decided. An incoming unknown stays silent; its producer already reported. The memory list contract gains the page ceiling every other list already has (max 1000, matching the table convention); no caller in the repo passes a limit at all, and the route is internal-auth only. Workspace-file audit rows now carry the acting user where the caller already holds one — copilot vfs, the agent and mothership handlers, and the provider attachment filter. Everywhere else, including principals with no user to name, the actor stays null, which the report type has always permitted. Two comments catch up with the code: the file sidecar stores three statuses since the absence/taint split, and the mounted-file scanner's scan-overflow-to-taint is deliberate where the registry scan over-approximates — that scan only narrows an already-sound candidate set, while this one decides whether egress redaction would suffice for bytes the same matcher just failed on.
1 parent 1cb9c86 commit 9cecf0b

15 files changed

Lines changed: 422 additions & 31 deletions

File tree

apps/sim/app/api/knowledge/secret-provenance.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -248,6 +248,8 @@ export async function finalizeKnowledgePersistedResponse(options: {
248248
registry,
249249
documents: options.documents,
250250
chunks: options.chunks,
251+
...(options.workspaceId ? { workspaceId: options.workspaceId } : {}),
252+
actorUserId: options.userId,
251253
})
252254
return finalizeKnowledgeRegistryResponse({
253255
request: options.request,

apps/sim/app/api/memory/secret-provenance.test.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ vi.mock('@/lib/execution/durable-secret-provenance-enforcement', () => ({
1818
reportUnrecordedDurableProvenance: mockReport,
1919
}))
2020

21+
import { memoryListQuerySchema } from '@/lib/api/contracts/memory'
2122
import { AuthType } from '@/lib/auth/hybrid'
2223
import {
2324
PRIVATE_SECRET_PROVENANCE_BUNDLE_V1,
@@ -341,3 +342,11 @@ describe('memory write secret provenance', () => {
341342
expect(mockReport).not.toHaveBeenCalled()
342343
})
343344
})
345+
346+
describe('memory list query contract', () => {
347+
it('rejects a limit past the page ceiling and keeps the default below it', () => {
348+
expect(memoryListQuerySchema.safeParse({ limit: '2000' }).success).toBe(false)
349+
expect(memoryListQuerySchema.parse({})).toMatchObject({ limit: 50 })
350+
expect(memoryListQuerySchema.parse({ limit: '1000' })).toMatchObject({ limit: 1000 })
351+
})
352+
})

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1680,6 +1680,7 @@ export class AgentBlockHandler implements BlockHandler {
16801680
identity,
16811681
registry: ctx.resolvedSecretTraceRegistry,
16821682
view: 'opaque',
1683+
...(ctx.userId ? { actorUserId: ctx.userId } : {}),
16831684
})
16841685
if (!safe) {
16851686
unsafeGeneratedDocumentFiles.add(`${file.key}:${file.id}`)

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -689,7 +689,7 @@ async function buildMothershipFileAttachments(
689689
)
690690
const modelSafe = await areModelSafeWorkspaceFileKeys(
691691
userFiles.map((file) => file.key).filter((key): key is string => Boolean(key)),
692-
{ workspaceId: ctx.workspaceId }
692+
{ workspaceId: ctx.workspaceId, ...(ctx.userId ? { actorUserId: ctx.userId } : {}) }
693693
)
694694
if (!modelSafe) throw new Error(MODEL_UNSAFE_WORKSPACE_FILE_ERROR_MESSAGE)
695695

apps/sim/lib/api/contracts/memory.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,13 @@ export const agentMemoryDataSchemaContract = agentMemoryDataSchema
3434
export const memoryListQuerySchema = z.object({
3535
workspaceId: z.string().optional(),
3636
query: z.string().nullable().optional(),
37-
limit: z.coerce.number().int().min(1).optional().default(50),
37+
limit: z.coerce
38+
.number()
39+
.int()
40+
.min(1)
41+
.max(1000, 'Cannot list more than 1000 memories per request')
42+
.optional()
43+
.default(50),
3844
})
3945

4046
export const memoryMessageSchema = z

apps/sim/lib/copilot/tools/handlers/vfs.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -126,6 +126,7 @@ async function canReturnWorkspaceFileValue(
126126
registry: context.resolvedSecretTraceRegistry,
127127
view: provenanceView,
128128
value,
129+
actorUserId: context.userId,
129130
}))
130131
) {
131132
return false

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

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,14 @@ export async function createMountedFileSecretProvenanceScanner(
8989

9090
return {
9191
hasSecrets,
92+
/**
93+
* A scan that cannot finish yields `unknown` — a taint — where the registry's per-value scan
94+
* over-approximates instead. The asymmetry is deliberate: that scan only narrows a candidate
95+
* set that is already a sound answer, while this one decides whether egress redaction of these
96+
* entries would suffice for these bytes — a claim that cannot be made for content the same
97+
* matcher just failed on. Reaching the event bound takes an eight-plus-character literal
98+
* occurring ~a million times, so only degenerate content pays the refusal.
99+
*/
92100
scan(buffer) {
93101
const matched = new Map<string, WorkspaceFileSecretProvenanceEntry>()
94102
try {

apps/sim/lib/knowledge/application/search.ts

Lines changed: 24 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,10 @@ import { OrchestrationError } from '@/lib/core/orchestration/types'
1515
import { PlatformEvents } from '@/lib/core/telemetry'
1616
import { generateRequestId } from '@/lib/core/utils/request'
1717
import { importDurableSecretProvenance } from '@/lib/execution/durable-secret-provenance'
18+
import {
19+
isDurableSecretProvenanceEnforced,
20+
reportUnrecordedDurableProvenance,
21+
} from '@/lib/execution/durable-secret-provenance-enforcement'
1822
import { defineAuthorizedKnowledgeUseCase } from '@/lib/knowledge/application/authorized-knowledge-use-case'
1923
import {
2024
KnowledgeUsageLimitExceededError,
@@ -488,6 +492,8 @@ export const searchKnowledge = defineAuthorizedKnowledgeUseCase({
488492
}
489493
})
490494
if (registry && provenanceSnapshot) {
495+
const knowledgeEnforced = isDurableSecretProvenanceEnforced('knowledge')
496+
let unrecordedCount = provenanceSnapshot.unrecordedCount
491497
for (const [documentId, document] of Object.entries(provenanceSnapshot.documentMetadata)) {
492498
const renderedMetadata = results
493499
.filter((result) => result.documentId === documentId)
@@ -496,18 +502,34 @@ export const searchKnowledge = defineAuthorizedKnowledgeUseCase({
496502
sourceUrl: result.sourceUrl,
497503
metadata: result.metadata,
498504
}))
505+
if (renderedMetadata.length === 0) continue
506+
if (document.provenance.status === 'unknown' && !knowledgeEnforced) unrecordedCount += 1
499507
if (
500-
renderedMetadata.length > 0 &&
501508
!(await importDurableSecretProvenance(
502509
registry,
503510
document.provenance,
504511
renderedMetadata,
505-
'knowledge'
512+
'knowledge',
513+
{ reportUnrecorded: false }
506514
))
507515
) {
508516
registry.markIncomplete('knowledge-result-provenance-unavailable')
509517
}
510518
}
519+
/**
520+
* One entry for the whole search — chunks and rendered metadata are one read. Skipped when
521+
* the registry latched: a latched read never reaches a model, and this entry exists to say a
522+
* fail-open read went ahead unvouched.
523+
*/
524+
if (unrecordedCount > 0 && !registry.isPermanentlyIncomplete()) {
525+
reportUnrecordedDurableProvenance({
526+
surface: 'knowledge',
527+
cause: 'durable-provenance-unknown',
528+
affectedCount: unrecordedCount,
529+
workspaceId: context.workspaceId,
530+
actorUserId: userId,
531+
})
532+
}
511533
}
512534
const cost = baseCost
513535
? {

apps/sim/lib/knowledge/secret-provenance.test.ts

Lines changed: 118 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,24 +1,34 @@
11
/**
22
* @vitest-environment node
33
*/
4-
import { document } from '@sim/db/schema'
4+
import { document, embedding } from '@sim/db/schema'
55
import { queueTableRows, resetDbChainMock } from '@sim/testing'
66
import { beforeEach, describe, expect, it, vi } from 'vitest'
77
import { hashDurableSecretProvenanceValue } from '@/lib/execution/durable-secret-provenance'
88
import {
99
createKnowledgeDocumentSourceValue,
10+
importKnowledgePersistedResponseSecretProvenance,
11+
importKnowledgeSearchResultSecretProvenance,
1012
loadKnowledgeDocumentSecretRegistry,
1113
readBoundKnowledgeDocumentSecretProvenance,
1214
} from '@/lib/knowledge/secret-provenance'
15+
import { ResolvedSecretTraceRegistry } from '@/executor/utils/resolved-secret-trace-registry'
1316

14-
const { mockDecryptSecret } = vi.hoisted(() => ({
17+
const { mockDecryptSecret, mockIsEnforced, mockReport } = vi.hoisted(() => ({
1518
mockDecryptSecret: vi.fn(),
19+
mockIsEnforced: vi.fn(() => false),
20+
mockReport: vi.fn(),
1621
}))
1722

1823
vi.mock('@/lib/core/security/encryption', () => ({
1924
decryptSecret: mockDecryptSecret,
2025
}))
2126

27+
vi.mock('@/lib/execution/durable-secret-provenance-enforcement', () => ({
28+
isDurableSecretProvenanceEnforced: mockIsEnforced,
29+
reportUnrecordedDurableProvenance: mockReport,
30+
}))
31+
2232
const DOCUMENT_SOURCE = createKnowledgeDocumentSourceValue({
2333
filename: 'source.pdf',
2434
fileUrl: '/api/files/serve/workspace%2Fworkspace-1%2Fsource.pdf?context=workspace',
@@ -39,6 +49,7 @@ describe('knowledge durable secret provenance', () => {
3949
resetDbChainMock()
4050
queueTableRows(document, [DOCUMENT_ROW])
4151
mockDecryptSecret.mockResolvedValue({ decrypted: 'tracked-secret' })
52+
mockIsEnforced.mockReturnValue(false)
4253
})
4354

4455
it('uses the same explicit source shape for joined rows and persisted writes', () => {
@@ -130,3 +141,108 @@ describe('knowledge durable secret provenance', () => {
130141
})
131142
})
132143
})
144+
145+
describe('knowledge unrecorded-read reporting', () => {
146+
const SCOPE = { userId: 'user-1', workspaceId: 'workspace-1' }
147+
const UNRECORDED_DOCUMENT_ROW = {
148+
id: 'doc-1',
149+
...DOCUMENT_SOURCE,
150+
secretProvenanceVersion: 1,
151+
provenanceSourceHash: null,
152+
status: 'unknown',
153+
entries: null,
154+
}
155+
const UNRECORDED_CHUNK_ROW = {
156+
id: 'chunk-1',
157+
documentId: 'doc-1',
158+
content: 'chunk text',
159+
chunkHash: 'stale',
160+
secretProvenanceVersion: 1,
161+
provenanceContentHash: null,
162+
status: 'unknown',
163+
entries: null,
164+
}
165+
166+
beforeEach(() => {
167+
vi.clearAllMocks()
168+
resetDbChainMock()
169+
mockIsEnforced.mockReturnValue(false)
170+
})
171+
172+
it('reports one aggregated entry per read, naming workspace, actor, and count', async () => {
173+
queueTableRows(document, [UNRECORDED_DOCUMENT_ROW])
174+
queueTableRows(embedding, [UNRECORDED_CHUNK_ROW])
175+
const registry = new ResolvedSecretTraceRegistry([], SCOPE)
176+
177+
await expect(
178+
importKnowledgePersistedResponseSecretProvenance({
179+
registry,
180+
documents: [{ id: 'doc-1', source: DOCUMENT_SOURCE, value: {} }],
181+
chunks: [{ id: 'chunk-1', documentId: 'doc-1', content: 'chunk text', value: {} }],
182+
workspaceId: 'workspace-1',
183+
actorUserId: 'user-1',
184+
})
185+
).resolves.toBe(true)
186+
187+
expect(registry.isPermanentlyIncomplete()).toBe(false)
188+
expect(mockReport).toHaveBeenCalledTimes(1)
189+
expect(mockReport).toHaveBeenCalledWith({
190+
surface: 'knowledge',
191+
cause: 'durable-provenance-unknown',
192+
affectedCount: 2,
193+
workspaceId: 'workspace-1',
194+
actorUserId: 'user-1',
195+
})
196+
})
197+
198+
/** A fault return fails the read closed, so no unvouched record reached anything to report. */
199+
it('reports nothing when the read fails closed on a missing row', async () => {
200+
queueTableRows(document, [])
201+
const registry = new ResolvedSecretTraceRegistry([], SCOPE)
202+
203+
await expect(
204+
importKnowledgePersistedResponseSecretProvenance({
205+
registry,
206+
documents: [{ id: 'doc-1', source: DOCUMENT_SOURCE, value: {} }],
207+
workspaceId: 'workspace-1',
208+
actorUserId: 'user-1',
209+
})
210+
).resolves.toBe(false)
211+
212+
expect(mockReport).not.toHaveBeenCalled()
213+
})
214+
215+
it('latches without reporting once the surface is enforced', async () => {
216+
mockIsEnforced.mockReturnValue(true)
217+
queueTableRows(document, [UNRECORDED_DOCUMENT_ROW])
218+
const registry = new ResolvedSecretTraceRegistry([], SCOPE)
219+
220+
await expect(
221+
importKnowledgePersistedResponseSecretProvenance({
222+
registry,
223+
documents: [{ id: 'doc-1', source: DOCUMENT_SOURCE, value: {} }],
224+
workspaceId: 'workspace-1',
225+
actorUserId: 'user-1',
226+
})
227+
).resolves.toBe(false)
228+
229+
expect(registry.isPermanentlyIncomplete()).toBe(true)
230+
expect(mockReport).not.toHaveBeenCalled()
231+
})
232+
233+
/** The search read spans chunks and rendered metadata, so its caller owns the one report. */
234+
it('returns the unrecorded count from a search import instead of reporting it', async () => {
235+
queueTableRows(embedding, [{ ...UNRECORDED_CHUNK_ROW, documentId: DOCUMENT_ROW.id }])
236+
queueTableRows(document, [DOCUMENT_ROW])
237+
const registry = new ResolvedSecretTraceRegistry([], SCOPE)
238+
239+
const snapshot = await importKnowledgeSearchResultSecretProvenance({
240+
registry,
241+
results: [{ id: 'chunk-1', documentId: DOCUMENT_ROW.id, content: 'chunk text' }],
242+
})
243+
244+
expect(snapshot.imported).toBe(true)
245+
expect(snapshot.unrecordedCount).toBe(1)
246+
expect(mockReport).not.toHaveBeenCalled()
247+
})
248+
})

0 commit comments

Comments
 (0)