From 9adb4051a145aea466cec34935c3c5d5a2f4c556 Mon Sep 17 00:00:00 2001 From: Vikhyath Mondreti Date: Tue, 25 Aug 2026 16:33:16 -0700 Subject: [PATCH] fix(provenance): classify a latched-but-empty registry's file writes as unrecorded (#7084) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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. * fix(provenance): relax a latch to unrecorded only when no fault is on record Review finding, accepted with a broader gate than proposed: zero active entries alone does not prove the context never held plaintext — a verification or decrypt fault trips while secret material is in flight, before anything activates — so keying the absence carve-out on the entry count alone let a fault-latched registry stamp unrecorded. Gate it on the one centrally maintained classification of exactly this distinction: the originating-fault reason set that already decides the report level. A latch relaxes to unrecorded only when nothing activated and every recorded reason says provenance was never on offer; any fault keeps the taint. An allow-list of the single observed reason would have re-tainted the other genuine absences — a registry born without a catalog, a durable row nobody recorded, a client tool that never reported. * fix(provenance): relax to unrecorded only for the dedicated absence reasons Second review finding, accepted: the originating-fault set assigns report ownership, not absence semantics, and the two are not the same split. value-provenance-filter-incomplete is warn-level because its underlying fault already error-logs inside the staged source registry — yet it latches precisely when real entries were decrypted in-process and could not be narrowed to the value, plaintext in flight that never activated. Gating the file-write relaxation on "not a fault" let that latch stamp unrecorded. The registry now owns a dedicated absence set with the stronger contract membership requires: provenance was never on offer AND no secret material transited the latching context. An absent or declared-incomplete envelope carries no entries by schema, a registry born without a catalog or a persisted log never handled material, a durable-unknown latch fires before importing anything, and the inherited markers never occur alone. The write decision relaxes only when nothing activated and every recorded reason is in that set; an empty reason list keeps the taint. The fault predicate from the previous commit is superseded and removed. --- .../resolved-secret-trace-registry.test.ts | 32 +++++ .../utils/resolved-secret-trace-registry.ts | 34 +++++ .../lib/copilot/request/tools/files.test.ts | 7 + apps/sim/lib/logs/execution/trace-store.ts | 1 + .../workspace-file-secret-provenance.test.ts | 122 ++++++++++++++++++ .../workspace-file-secret-provenance.ts | 30 ++++- 6 files changed, 222 insertions(+), 4 deletions(-) diff --git a/apps/sim/executor/utils/resolved-secret-trace-registry.test.ts b/apps/sim/executor/utils/resolved-secret-trace-registry.test.ts index 5284f2f3fc..785b209fdc 100644 --- a/apps/sim/executor/utils/resolved-secret-trace-registry.test.ts +++ b/apps/sim/executor/utils/resolved-secret-trace-registry.test.ts @@ -17,6 +17,7 @@ import { ANONYMOUS_SECRET_TRACE_REPLACEMENT, createIncompleteResolvedSecretTraceRegistry, createResolvedSecretTraceRegistry, + isResolvedSecretProvenanceAbsence, isResolvedSecretTraceProvenanceV1, RESOLVED_SECRET_TRACE_CHECKPOINT_VERSION, ResolvedSecretTraceProvenanceAccumulator, @@ -24,6 +25,37 @@ import { ResolvedSecretTraceRegistry, } from '@/executor/utils/resolved-secret-trace-registry' +describe('provenance absence classification', () => { + it.each([ + 'value-provenance-absent', + 'source-provenance-incomplete', + 'constructed-incomplete', + 'log-creation-skipped', + 'durable-provenance-unknown', + 'inherited-incomplete-source', + 'inherited-incomplete-input-path', + ] as const)('classifies %s as an absence, since no material transited the latch', (reason) => { + expect(isResolvedSecretProvenanceAbsence(reason)).toBe(true) + }) + + /** + * Warn-level is not absence: `value-provenance-filter-incomplete` latches after a staged + * source registry decrypted real entries it could not narrow to the value, so plaintext was in + * flight without ever activating. The absence set must stay narrower than the report split. + */ + it.each([ + 'value-provenance-filter-incomplete', + 'value-provenance-import-failed', + 'entry-decrypt-failed', + 'projection-mismatch', + 'untrusted-provenance', + 'restored-provenance-untrusted', + 'client-tool-seal-failed', + ] as const)('keeps %s out of the absence set', (reason) => { + expect(isResolvedSecretProvenanceAbsence(reason)).toBe(false) + }) +}) + describe('ResolvedSecretTraceProvenanceAccumulator', () => { const scope = { userId: 'user-1', workspaceId: 'workspace-1' } diff --git a/apps/sim/executor/utils/resolved-secret-trace-registry.ts b/apps/sim/executor/utils/resolved-secret-trace-registry.ts index b7b376e25a..05e8d51cc7 100644 --- a/apps/sim/executor/utils/resolved-secret-trace-registry.ts +++ b/apps/sim/executor/utils/resolved-secret-trace-registry.ts @@ -99,6 +99,40 @@ export type ResolvedSecretIncompletenessReason = * inheriting a parent that reported moments earlier, or an unaudited caller taking the default * reason from flooding the error stream. A reason added later without thought stays quiet. */ +/** + * Reasons meaning provenance was never on offer AND no secret material transited the latching + * context. This is a deliberately separate, narrower set than the warn side of the report-level + * split below: that split assigns report ownership, and a warn-level reason can still involve + * plaintext in flight — `value-provenance-filter-incomplete` latches after a staged source + * registry decrypted real entries it then could not narrow to the value, so the plaintext existed + * in-process without ever activating. Membership here requires the stronger claim. + * + * The claim holds for each member: an absent or declared-incomplete envelope carries no entries + * (the envelope schema rejects incomplete-with-entries), so nothing was decrypted; a registry + * built without a catalog or without a persisted log never handled material; a durable read that + * latched did so before importing anything; and the inherited markers never occur alone — the + * source's own reasons are copied first, so they are judged by the originals they accompany. + * + * Consumers use this to separate `unrecorded` (absence — readable under the fail-open policy) + * from taint at a write decision. A reason outside this set keeps the taint. + */ +const PROVENANCE_ABSENCE_REASONS = new Set([ + 'value-provenance-absent', + 'source-provenance-incomplete', + 'constructed-incomplete', + 'log-creation-skipped', + 'durable-provenance-unknown', + 'inherited-incomplete-source', + 'inherited-incomplete-input-path', +]) + +/** True when {@link PROVENANCE_ABSENCE_REASONS} holds the reason; see its contract. */ +export function isResolvedSecretProvenanceAbsence( + reason: ResolvedSecretIncompletenessReason +): boolean { + return PROVENANCE_ABSENCE_REASONS.has(reason) +} + const ORIGINATING_FAULT_REASONS = new Set([ 'untrusted-provenance', 'entry-decrypt-failed', diff --git a/apps/sim/lib/copilot/request/tools/files.test.ts b/apps/sim/lib/copilot/request/tools/files.test.ts index ba153c84bc..0f4bafe90d 100644 --- a/apps/sim/lib/copilot/request/tools/files.test.ts +++ b/apps/sim/lib/copilot/request/tools/files.test.ts @@ -563,6 +563,13 @@ describe('maybeWriteOutputToFile', () => { .mockReturnValueOnce({ version: 1, complete: false, entries: [] }) const registry = { exportCommittedProvenanceForValue, + /** Plaintext is in scope, so the incomplete export below is a taint, not an absence. */ + getIncompletenessDiagnostics: vi.fn(() => ({ + reasons: ['source-provenance-incomplete'], + origins: [], + incompleteInputPathCount: 0, + activeEntryCount: 1, + })), } as unknown as ResolvedSecretTraceRegistry const result = await maybeWriteOutputToFile( diff --git a/apps/sim/lib/logs/execution/trace-store.ts b/apps/sim/lib/logs/execution/trace-store.ts index afaed357d3..ff229f8734 100644 --- a/apps/sim/lib/logs/execution/trace-store.ts +++ b/apps/sim/lib/logs/execution/trace-store.ts @@ -439,6 +439,7 @@ function reportStoredDisplayProvenanceFaults( if (parts.length === 0) continue logger[report.level](report.message, { ...details, + fault: kind, parts: parts.slice(0, MAX_REPORTED_PROVENANCE_FAULT_PARTS), partCount: parts.length, }) diff --git a/apps/sim/lib/uploads/contexts/workspace/workspace-file-secret-provenance.test.ts b/apps/sim/lib/uploads/contexts/workspace/workspace-file-secret-provenance.test.ts index a372277641..3b0622fca6 100644 --- a/apps/sim/lib/uploads/contexts/workspace/workspace-file-secret-provenance.test.ts +++ b/apps/sim/lib/uploads/contexts/workspace/workspace-file-secret-provenance.test.ts @@ -20,6 +20,7 @@ import type { DbTransaction } from '@/lib/db/types' import { areModelSafeWorkspaceFileKeys, copyWorkspaceFileSecretProvenanceInTx, + createWorkspaceFileSecretProvenanceFromRegistry, filterModelSafeWorkspaceFileAttachments, importWorkspaceFileSecretProvenanceForModelView, importWorkspaceFileSecretProvenanceForRuntime, @@ -1548,3 +1549,124 @@ describe('workspace file secret provenance', () => { ).toEqual({ status: 'unknown' }) }) }) + +describe('createWorkspaceFileSecretProvenanceFromRegistry write decision', () => { + const SCOPE = { userId: 'user-1', workspaceId: 'workspace-1' } + + /** + * A registry latched with nothing resolved is an absence, not a taint: no plaintext exists in + * the context to be in the bytes, so the file must stay readable under the unrecorded policy. + * Stamping taint here made one failed workflow run hard-refuse every file its chat later wrote. + */ + it('classifies a latched registry holding no active entries as unrecorded', async () => { + const registry = { + exportCommittedProvenanceForValue: vi.fn(() => ({ + version: 1, + complete: false, + entries: [], + })), + getIncompletenessDiagnostics: vi.fn(() => ({ + reasons: ['value-provenance-absent'], + origins: [], + incompleteInputPathCount: 0, + activeEntryCount: 0, + })), + } as unknown as ResolvedSecretTraceRegistry + + await expect( + createWorkspaceFileSecretProvenanceFromRegistry(registry, 'generated content', SCOPE) + ).resolves.toEqual({ safe: true, provenance: { status: 'unrecorded' } }) + }) + + /** + * Zero active entries does not prove the context never held plaintext: a verification or + * decrypt fault trips while secret material is in flight, before anything activates. Only a + * latch whose recorded reasons all belong to the registry's absence set may relax. + */ + it.each([ + ['an originating fault', 'projection-mismatch'], + /** + * Warn-level, yet plaintext-bearing: it latches after a staged source registry decrypted + * real entries it could not narrow to the value — the report-level split must not be the + * absence split. + */ + ['an unnarrowable crossing', 'value-provenance-filter-incomplete'], + ] as const)( + 'keeps a latch caused by %s as a taint even with no active entries', + async (_, reason) => { + const registry = { + exportCommittedProvenanceForValue: vi.fn(() => ({ + version: 1, + complete: false, + entries: [], + })), + getIncompletenessDiagnostics: vi.fn(() => ({ + reasons: [reason], + origins: [], + incompleteInputPathCount: 0, + activeEntryCount: 0, + })), + } as unknown as ResolvedSecretTraceRegistry + + await expect( + createWorkspaceFileSecretProvenanceFromRegistry(registry, 'generated content', SCOPE) + ).resolves.toEqual({ safe: false }) + } + ) + + /** A latched registry that recorded no reason offers nothing to vouch with; keep the taint. */ + it('keeps a latch with an empty reason list as a taint', async () => { + const registry = { + exportCommittedProvenanceForValue: vi.fn(() => ({ + version: 1, + complete: false, + entries: [], + })), + getIncompletenessDiagnostics: vi.fn(() => ({ + reasons: [], + origins: [], + incompleteInputPathCount: 0, + activeEntryCount: 0, + })), + } as unknown as ResolvedSecretTraceRegistry + + await expect( + createWorkspaceFileSecretProvenanceFromRegistry(registry, 'generated content', SCOPE) + ).resolves.toEqual({ safe: false }) + }) + + it('keeps a latched registry holding plaintext it cannot map as a taint', async () => { + const registry = { + exportCommittedProvenanceForValue: vi.fn(() => ({ + version: 1, + complete: false, + entries: [], + })), + getIncompletenessDiagnostics: vi.fn(() => ({ + reasons: ['source-provenance-incomplete'], + origins: [], + incompleteInputPathCount: 0, + activeEntryCount: 1, + })), + } as unknown as ResolvedSecretTraceRegistry + + await expect( + createWorkspaceFileSecretProvenanceFromRegistry(registry, 'generated content', SCOPE) + ).resolves.toEqual({ safe: false }) + }) + + it('stays a taint when the incomplete export carries no diagnostics to vouch with', async () => { + const registry = { + exportCommittedProvenanceForValue: vi.fn(() => ({ + version: 1, + complete: false, + entries: [], + })), + getIncompletenessDiagnostics: vi.fn(() => undefined), + } as unknown as ResolvedSecretTraceRegistry + + await expect( + createWorkspaceFileSecretProvenanceFromRegistry(registry, 'generated content', SCOPE) + ).resolves.toEqual({ safe: false }) + }) +}) diff --git a/apps/sim/lib/uploads/contexts/workspace/workspace-file-secret-provenance.ts b/apps/sim/lib/uploads/contexts/workspace/workspace-file-secret-provenance.ts index 17b632bba1..62a1a9cada 100644 --- a/apps/sim/lib/uploads/contexts/workspace/workspace-file-secret-provenance.ts +++ b/apps/sim/lib/uploads/contexts/workspace/workspace-file-secret-provenance.ts @@ -20,9 +20,10 @@ import { PROVENANCE_MAX_ENTRIES, PROVENANCE_MAX_SERIALIZED_BYTES, } from '@/lib/execution/provenance-limits' -import type { - ResolvedSecretTraceProvenanceV1, - ResolvedSecretTraceRegistry, +import { + isResolvedSecretProvenanceAbsence, + type ResolvedSecretTraceProvenanceV1, + type ResolvedSecretTraceRegistry, } from '@/executor/utils/resolved-secret-trace-registry' /** Ids per statement. Bounds the query, never how many files a caller may classify. */ @@ -253,7 +254,28 @@ export async function createWorkspaceFileSecretProvenanceFromRegistry( const persistedProvenance = Object.is(sourceValue, persistedValue) ? sourceProvenance : registry.exportCommittedProvenanceForValue(persistedValue) - if (!sourceProvenance.complete || !persistedProvenance.complete) return { safe: false } + if (!sourceProvenance.complete || !persistedProvenance.complete) { + /** + * A latched registry is the same absence only when both hold: nothing activated, and every + * recorded reason is in the registry's absence set — reasons meaning provenance was never on + * offer and no secret material transited the latching context. Zero active entries alone does + * not prove that: a decrypt, verification, or filtering failure trips while plaintext is in + * flight, before anything activates, so any such reason keeps the taint. What remains is a + * registry that latched with nothing to lose (a failed workflow run crossing with no envelope + * is the recurring producer), which is exactly what `unrecorded` states. Stamping taint for + * that state made one failed run turn every file its chat later wrote into a hard refusal + * until the next clean write. + */ + const diagnostics = registry.getIncompletenessDiagnostics() + if ( + diagnostics?.activeEntryCount === 0 && + diagnostics.reasons.length > 0 && + diagnostics.reasons.every(isResolvedSecretProvenanceAbsence) + ) { + return { safe: true, provenance: { status: 'unrecorded' } } + } + return { safe: false } + } if ( (sourceProvenance.entries.length > 0 && !isPrivateSecretProvenanceScopeCompatible(sourceProvenance.scope, destinationScope)) ||