Skip to content

Commit 63648ba

Browse files
committed
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.
1 parent 34e7459 commit 63648ba

3 files changed

Lines changed: 58 additions & 10 deletions

File tree

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

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -99,6 +99,22 @@ export type ResolvedSecretIncompletenessReason =
9999
* inheriting a parent that reported moments earlier, or an unaudited caller taking the default
100100
* reason from flooding the error stream. A reason added later without thought stays quiet.
101101
*/
102+
/**
103+
* True when a reason means a guard tripped on a path that should have succeeded, as opposed to
104+
* provenance never being on offer. The same set decides the report level above; exposing the
105+
* predicate keeps callers that must separate fault from absence — a file write deciding between
106+
* taint and `unrecorded` — on the one centrally maintained classification instead of a copy.
107+
*
108+
* A fault matters to such callers because it can coexist with plaintext that never activated: a
109+
* verification or decrypt failure happens *while* secret material is in flight, so an empty active
110+
* set does not prove the context never held any.
111+
*/
112+
export function isResolvedSecretIncompletenessFault(
113+
reason: ResolvedSecretIncompletenessReason
114+
): boolean {
115+
return ORIGINATING_FAULT_REASONS.has(reason)
116+
}
117+
102118
const ORIGINATING_FAULT_REASONS = new Set<ResolvedSecretIncompletenessReason>([
103119
'untrusted-provenance',
104120
'entry-decrypt-failed',

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

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1578,6 +1578,31 @@ describe('createWorkspaceFileSecretProvenanceFromRegistry write decision', () =>
15781578
).resolves.toEqual({ safe: true, provenance: { status: 'unrecorded' } })
15791579
})
15801580

1581+
/**
1582+
* Zero active entries does not prove the context never held plaintext: a verification or
1583+
* decrypt fault trips while secret material is in flight, before anything activates. Only a
1584+
* latch whose recorded reasons are all non-fault absences may relax to unrecorded.
1585+
*/
1586+
it('keeps a latch caused by an originating fault as a taint even with no active entries', async () => {
1587+
const registry = {
1588+
exportCommittedProvenanceForValue: vi.fn(() => ({
1589+
version: 1,
1590+
complete: false,
1591+
entries: [],
1592+
})),
1593+
getIncompletenessDiagnostics: vi.fn(() => ({
1594+
reasons: ['projection-mismatch'],
1595+
origins: [],
1596+
incompleteInputPathCount: 0,
1597+
activeEntryCount: 0,
1598+
})),
1599+
} as unknown as ResolvedSecretTraceRegistry
1600+
1601+
await expect(
1602+
createWorkspaceFileSecretProvenanceFromRegistry(registry, 'generated content', SCOPE)
1603+
).resolves.toEqual({ safe: false })
1604+
})
1605+
15811606
it('keeps a latched registry holding plaintext it cannot map as a taint', async () => {
15821607
const registry = {
15831608
exportCommittedProvenanceForValue: vi.fn(() => ({

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

Lines changed: 17 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -20,9 +20,10 @@ import {
2020
PROVENANCE_MAX_ENTRIES,
2121
PROVENANCE_MAX_SERIALIZED_BYTES,
2222
} from '@/lib/execution/provenance-limits'
23-
import type {
24-
ResolvedSecretTraceProvenanceV1,
25-
ResolvedSecretTraceRegistry,
23+
import {
24+
isResolvedSecretIncompletenessFault,
25+
type ResolvedSecretTraceProvenanceV1,
26+
type ResolvedSecretTraceRegistry,
2627
} from '@/executor/utils/resolved-secret-trace-registry'
2728

2829
/** Ids per statement. Bounds the query, never how many files a caller may classify. */
@@ -255,14 +256,20 @@ export async function createWorkspaceFileSecretProvenanceFromRegistry(
255256
: registry.exportCommittedProvenanceForValue(persistedValue)
256257
if (!sourceProvenance.complete || !persistedProvenance.complete) {
257258
/**
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.
259+
* A latched registry is the same absence only when both hold: nothing activated, and no
260+
* recorded reason is an originating fault. Zero active entries alone does not prove the
261+
* context never held plaintext — a verification or decrypt fault trips while secret material
262+
* is in flight — so any fault reason keeps the taint. What remains is a registry that latched
263+
* because provenance was never on offer (a failed workflow run crossing with no envelope is
264+
* the recurring producer), which is exactly what `unrecorded` states. Stamping taint for that
265+
* state made one failed run turn every file its chat later wrote into a hard refusal until
266+
* the next clean write.
264267
*/
265-
if (registry.getIncompletenessDiagnostics()?.activeEntryCount === 0) {
268+
const diagnostics = registry.getIncompletenessDiagnostics()
269+
if (
270+
diagnostics?.activeEntryCount === 0 &&
271+
!diagnostics.reasons.some(isResolvedSecretIncompletenessFault)
272+
) {
266273
return { safe: true, provenance: { status: 'unrecorded' } }
267274
}
268275
return { safe: false }

0 commit comments

Comments
 (0)