Skip to content

Commit 34e7459

Browse files
committed
fix(provenance): classify a latched-but-empty registry's file writes as unrecorded
A copilot chat whose registry latched — the recurring producer is a workflow run that failed before exporting provenance, crossing as value-provenance-absent — stamped every file it later wrote with the unknown taint, which no policy relaxes. The chat then could not read the file back: generating a document and immediately rendering it to show the user hard-failed with "cannot be shared safely", and healed only when a later clean turn rewrote the sidecar. Traced in production: the refused file's terminal sidecar is exact with zero entries — a file with no secrets in it — and the last refusal landed under a minute before the healthy write. The write decision now distinguishes the two states its own policy names. A latched registry holding no active entries is an absence: no secret plaintext was resolved or imported in that context, so none can be in the bytes, and the only fact is that content of unrecorded history crossed — which is what the unrecorded status states, readable under the fail-open policy with the audit entry naming the surface. Taint stays reserved for a registry that holds plaintext it cannot map to this output, and for every structural refusal below (scope, derived representations, encryption). Also carries the fault kind as a structured field on the trace-store display lines, so dashboards can group without parsing messages.
1 parent 470946d commit 34e7459

4 files changed

Lines changed: 87 additions & 1 deletion

File tree

apps/sim/lib/copilot/request/tools/files.test.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -563,6 +563,13 @@ describe('maybeWriteOutputToFile', () => {
563563
.mockReturnValueOnce({ version: 1, complete: false, entries: [] })
564564
const registry = {
565565
exportCommittedProvenanceForValue,
566+
/** Plaintext is in scope, so the incomplete export below is a taint, not an absence. */
567+
getIncompletenessDiagnostics: vi.fn(() => ({
568+
reasons: ['source-provenance-incomplete'],
569+
origins: [],
570+
incompleteInputPathCount: 0,
571+
activeEntryCount: 1,
572+
})),
566573
} as unknown as ResolvedSecretTraceRegistry
567574

568575
const result = await maybeWriteOutputToFile(

apps/sim/lib/logs/execution/trace-store.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -439,6 +439,7 @@ function reportStoredDisplayProvenanceFaults(
439439
if (parts.length === 0) continue
440440
logger[report.level](report.message, {
441441
...details,
442+
fault: kind,
442443
parts: parts.slice(0, MAX_REPORTED_PROVENANCE_FAULT_PARTS),
443444
partCount: parts.length,
444445
})

apps/sim/lib/uploads/contexts/workspace/workspace-file-secret-provenance.test.ts

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ import type { DbTransaction } from '@/lib/db/types'
2020
import {
2121
areModelSafeWorkspaceFileKeys,
2222
copyWorkspaceFileSecretProvenanceInTx,
23+
createWorkspaceFileSecretProvenanceFromRegistry,
2324
filterModelSafeWorkspaceFileAttachments,
2425
importWorkspaceFileSecretProvenanceForModelView,
2526
importWorkspaceFileSecretProvenanceForRuntime,
@@ -1548,3 +1549,67 @@ describe('workspace file secret provenance', () => {
15481549
).toEqual({ status: 'unknown' })
15491550
})
15501551
})
1552+
1553+
describe('createWorkspaceFileSecretProvenanceFromRegistry write decision', () => {
1554+
const SCOPE = { userId: 'user-1', workspaceId: 'workspace-1' }
1555+
1556+
/**
1557+
* A registry latched with nothing resolved is an absence, not a taint: no plaintext exists in
1558+
* the context to be in the bytes, so the file must stay readable under the unrecorded policy.
1559+
* Stamping taint here made one failed workflow run hard-refuse every file its chat later wrote.
1560+
*/
1561+
it('classifies a latched registry holding no active entries as unrecorded', async () => {
1562+
const registry = {
1563+
exportCommittedProvenanceForValue: vi.fn(() => ({
1564+
version: 1,
1565+
complete: false,
1566+
entries: [],
1567+
})),
1568+
getIncompletenessDiagnostics: vi.fn(() => ({
1569+
reasons: ['value-provenance-absent'],
1570+
origins: [],
1571+
incompleteInputPathCount: 0,
1572+
activeEntryCount: 0,
1573+
})),
1574+
} as unknown as ResolvedSecretTraceRegistry
1575+
1576+
await expect(
1577+
createWorkspaceFileSecretProvenanceFromRegistry(registry, 'generated content', SCOPE)
1578+
).resolves.toEqual({ safe: true, provenance: { status: 'unrecorded' } })
1579+
})
1580+
1581+
it('keeps a latched registry holding plaintext it cannot map as a taint', async () => {
1582+
const registry = {
1583+
exportCommittedProvenanceForValue: vi.fn(() => ({
1584+
version: 1,
1585+
complete: false,
1586+
entries: [],
1587+
})),
1588+
getIncompletenessDiagnostics: vi.fn(() => ({
1589+
reasons: ['source-provenance-incomplete'],
1590+
origins: [],
1591+
incompleteInputPathCount: 0,
1592+
activeEntryCount: 1,
1593+
})),
1594+
} as unknown as ResolvedSecretTraceRegistry
1595+
1596+
await expect(
1597+
createWorkspaceFileSecretProvenanceFromRegistry(registry, 'generated content', SCOPE)
1598+
).resolves.toEqual({ safe: false })
1599+
})
1600+
1601+
it('stays a taint when the incomplete export carries no diagnostics to vouch with', async () => {
1602+
const registry = {
1603+
exportCommittedProvenanceForValue: vi.fn(() => ({
1604+
version: 1,
1605+
complete: false,
1606+
entries: [],
1607+
})),
1608+
getIncompletenessDiagnostics: vi.fn(() => undefined),
1609+
} as unknown as ResolvedSecretTraceRegistry
1610+
1611+
await expect(
1612+
createWorkspaceFileSecretProvenanceFromRegistry(registry, 'generated content', SCOPE)
1613+
).resolves.toEqual({ safe: false })
1614+
})
1615+
})

apps/sim/lib/uploads/contexts/workspace/workspace-file-secret-provenance.ts

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -253,7 +253,20 @@ export async function createWorkspaceFileSecretProvenanceFromRegistry(
253253
const persistedProvenance = Object.is(sourceValue, persistedValue)
254254
? sourceProvenance
255255
: registry.exportCommittedProvenanceForValue(persistedValue)
256-
if (!sourceProvenance.complete || !persistedProvenance.complete) return { safe: false }
256+
if (!sourceProvenance.complete || !persistedProvenance.complete) {
257+
/**
258+
* A latched registry holding no active entries is the same absence: no secret plaintext was
259+
* ever resolved or imported in this context, so none can be in these bytes — the latch says
260+
* only that content of unrecorded history crossed (a failed workflow run is the recurring
261+
* producer), which is exactly what `unrecorded` states. Taint stays reserved for a registry
262+
* that holds plaintext it cannot map to this output: stamping it here made one failed run
263+
* turn every file its chat later wrote into a hard refusal until the next clean write.
264+
*/
265+
if (registry.getIncompletenessDiagnostics()?.activeEntryCount === 0) {
266+
return { safe: true, provenance: { status: 'unrecorded' } }
267+
}
268+
return { safe: false }
269+
}
257270
if (
258271
(sourceProvenance.entries.length > 0 &&
259272
!isPrivateSecretProvenanceScopeCompatible(sourceProvenance.scope, destinationScope)) ||

0 commit comments

Comments
 (0)