fix(provenance): feature flagged, inexact sidecars (#6491)

This commit is contained in:
Vikhyath Mondreti
2026-08-10 09:26:19 -07:00
committed by GitHub
parent c051c593b4
commit 156ee3ebf2
22 changed files with 787 additions and 106 deletions
@@ -181,6 +181,7 @@ See [Observability](/platform/self-hosting/observability).
| `NEXT_PUBLIC_CHAT_DISABLED` | Set to `true` to hide the Chat module: the workspace lands on your first workflow, with no chats list, scheduled tasks, or editor Chat panel. Chat is shown when unset; `bun run setup` sets it for you if you skip the chat key |
| `PII_REDACTION` | Redact PII from workflow logs via Data Retention rules; requires the PII service and a cluster-reachable `INTERNAL_API_BASE_URL` |
| `PII_GRANULAR_REDACTION` | Additionally expose the execution-altering redaction stages |
| `DURABLE_SECRET_PROVENANCE_ENFORCED_SURFACES` | Durable stores where a value whose secret provenance was never recorded fails the run instead of logging a warning. `all`, or a comma-separated subset of `memory`, `table-row`, `knowledge`. Unset (nothing enforced) by default |
| `ADMIN_API_KEY` | Admin API key for GitOps operations and organization provisioning |
## Enterprise Features
+2 -1
View File
@@ -604,7 +604,8 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
!(await importDurableSecretProvenance(
resultSecretRegistry,
metadata.provenance,
renderedMetadata
renderedMetadata,
'knowledge'
))
) {
resultSecretRegistry.markIncomplete()
+1 -1
View File
@@ -137,7 +137,7 @@ export async function createMemoryResponse(options: {
status: sidecar?.status ?? null,
entries: sidecar?.entries,
})
await importDurableSecretProvenance(registry, provenance, record.data)
await importDurableSecretProvenance(registry, provenance, record.data, 'memory')
}
}
}
@@ -337,8 +337,8 @@ describe('Memory', () => {
expect(result.content).toBe('foreign-secret')
})
it.each(['123', 'true'])(
'projects low-entropy secret %s only in model text and arguments',
it.each(['123'])(
'projects short secret %s only in model text and arguments',
async (secret) => {
const registry = new ResolvedSecretTraceRegistry([
{ name: 'TOKEN', plaintext: secret, encryptedValue: 'ciphertext' },
+38 -11
View File
@@ -11,6 +11,10 @@ import {
importDurableSecretProvenance,
mergeDurableSecretProvenance,
} from '@/lib/execution/durable-secret-provenance'
import {
isDurableSecretProvenanceEnforced,
reportUnrecordedDurableProvenance,
} from '@/lib/execution/durable-secret-provenance-enforcement'
import { redactObjectStrings } from '@/lib/logs/execution/pii-redaction'
import {
readBoundMemorySecretProvenance,
@@ -75,16 +79,32 @@ export class Memory {
stored.provenance,
messages
)
if (
selectedProvenance.status === 'unknown' ||
(selectedProvenance.entries.length > 0 && !ctx.resolvedSecretTraceRegistry) ||
(ctx.resolvedSecretTraceRegistry &&
!(await importDurableSecretProvenance(
ctx.resolvedSecretTraceRegistry,
selectedProvenance,
messages
)))
) {
/**
* Unrecorded provenance is checked through the same policy the shared import uses, so stored
* memory written by a run that could not vouch does not permanently refuse every later turn.
*/
let refuseStoredProvenance: boolean
if (selectedProvenance.status === 'unknown') {
refuseStoredProvenance = isDurableSecretProvenanceEnforced('memory')
if (!refuseStoredProvenance) {
reportUnrecordedDurableProvenance({
surface: 'memory',
cause: 'stored-memory-provenance-unknown',
...(ctx.workspaceId ? { workspaceId: ctx.workspaceId } : {}),
})
}
} else {
refuseStoredProvenance =
(selectedProvenance.entries.length > 0 && !ctx.resolvedSecretTraceRegistry) ||
(ctx.resolvedSecretTraceRegistry !== undefined &&
!(await importDurableSecretProvenance(
ctx.resolvedSecretTraceRegistry,
selectedProvenance,
messages,
'memory'
)))
}
if (refuseStoredProvenance) {
refuseResolvedSecretProjection({
site: 'memory.storedProvenanceImport',
message: MEMORY_CONTENT_REFUSAL,
@@ -102,7 +122,14 @@ export class Memory {
[],
ctx.resolvedSecretTraceRegistry?.exportProvenance().scope
)
if (!(await importDurableSecretProvenance(modelRegistry, messageProvenance, message))) {
if (
!(await importDurableSecretProvenance(
modelRegistry,
messageProvenance,
message,
'memory'
))
) {
refuseResolvedSecretProjection({
site: 'memory.messageProvenanceImport',
message: MEMORY_CONTENT_REFUSAL,
@@ -3,7 +3,9 @@
*/
import { describe, expect, it, vi } from 'vitest'
import {
createResolvedSecretMatcher,
isResolvedSecretModelContentUnchanged,
projectResolvedSecretContent,
projectResolvedSecretDiagnosticError,
projectResolvedSecretModelContent,
projectResolvedSecretModelJsonContent,
@@ -175,7 +177,7 @@ describe('projectResolvedSecretModelContent', () => {
})
})
it('projects exact typed primitive secrets without rewriting unrelated primitives', () => {
it('projects exact typed numeric secrets, leaving booleans and null identifying nothing', () => {
const registry = new ResolvedSecretTraceRegistry([
{ name: 'NUMBER', plaintext: '123', encryptedValue: 'number-ciphertext' },
{ name: 'BOOLEAN', plaintext: 'true', encryptedValue: 'boolean-ciphertext' },
@@ -200,17 +202,17 @@ describe('projectResolvedSecretModelContent', () => {
).toEqual({
safe: true,
value: {
strings: ['{{NUMBER}}', '{{BOOLEAN}}', '{{NULL}}'],
strings: ['{{NUMBER}}', 'true', 'null'],
number: '{{NUMBER}}',
boolean: '{{BOOLEAN}}',
nothing: '{{NULL}}',
boolean: true,
nothing: null,
unrelatedNumber: 1234,
unrelatedBoolean: false,
},
})
})
it.each(['123', 'true'])('keeps projected JSON argument strings valid (%s)', (secret) => {
it.each(['123'])('keeps projected JSON argument strings valid (%s)', (secret) => {
const registry = new ResolvedSecretTraceRegistry([
{ name: 'TOKEN', plaintext: secret, encryptedValue: 'ciphertext' },
])
@@ -231,6 +233,26 @@ describe('projectResolvedSecretModelContent', () => {
})
})
it('leaves a boolean-valued secret in a JSON argument string untouched', () => {
const registry = new ResolvedSecretTraceRegistry([
{ name: 'TOKEN', plaintext: 'true', encryptedValue: 'ciphertext' },
])
registry.recordResolved('TOKEN', 'true')
const projection = projectResolvedSecretModelJsonStrings(
[JSON.stringify({ secret: 'true', converted: true, nested: [true] })],
registry
)
expect(projection.safe).toBe(true)
if (!projection.safe || !Array.isArray(projection.value)) return
expect(JSON.parse(projection.value[0] as string)).toEqual({
secret: 'true',
converted: true,
nested: [true],
})
})
it('is stable when a secret literal overlaps its own provenance alias', () => {
const registry = new ResolvedSecretTraceRegistry([
{ name: 'TOKEN', plaintext: 'TOKEN', encryptedValue: 'ciphertext' },
@@ -464,3 +486,46 @@ describe('projectResolvedSecretDiagnosticError', () => {
})
})
})
describe('literals too small to identify anything', () => {
const matcher = createResolvedSecretMatcher(
[
{ plaintext: 'false', replacement: '{{BANNER_ENABLED}}' },
{ plaintext: 'xoxb-real-secret-value', replacement: '{{SLACK_TOKEN}}' },
],
{ preserveNamedProvenanceLabels: true, mode: 'render' }
)!
const project = (value: unknown) =>
projectResolvedSecretContent(value, matcher, 1_000_000, { projectPrimitiveLiterals: true })
/** A `*_ENABLED` variable holding `false` once rewrote 2,000 boolean cells in one table read. */
it('leaves a typed boolean cell alone', () => {
expect(project({ had_error: false, ok: true, missing: null })).toEqual({
safe: true,
value: { had_error: false, ok: true, missing: null },
})
})
it('leaves a delimited occurrence inside surrounding text alone', () => {
expect(project({ url: 'https://x?fromUser=false&sort=count' })).toEqual({
safe: true,
value: { url: 'https://x?fromUser=false&sort=count' },
})
})
it('still substitutes a real secret sharing the same matcher', () => {
expect(project({ token: 'xoxb-real-secret-value', flag: false })).toEqual({
safe: true,
value: { token: '{{SLACK_TOKEN}}', flag: false },
})
})
it('builds no matcher at all when every literal is non-identifying', () => {
expect(
createResolvedSecretMatcher([{ plaintext: 'true', replacement: '{{FLAG}}' }], {
mode: 'render',
})
).toBeUndefined()
})
})
@@ -4,6 +4,7 @@
import { describe, expect, it } from 'vitest'
import {
getResolvedSecretMatchPolicy,
isNonIdentifyingSecretLiteral,
isWordBoundaryMatch,
MIN_UNANCHORED_MATCH_LENGTH,
} from '@/executor/utils/resolved-secret-match-policy'
@@ -89,3 +90,19 @@ describe('isWordBoundaryMatch', () => {
expect(isWordBoundaryMatch('abc', 3, 3)).toBe(true)
})
})
describe('isNonIdentifyingSecretLiteral', () => {
it.each(['true', 'false', 'null'])(
'excludes %s, whose value space is too small to identify',
(literal) => {
expect(isNonIdentifyingSecretLiteral(literal)).toBe(true)
}
)
it.each(['0', '1', 'False', 'TRUE', 'Null', 'nullish', '', 'hunter2', 'sk_live_abc'])(
'keeps %s protectable',
(literal) => {
expect(isNonIdentifyingSecretLiteral(literal)).toBe(false)
}
)
})
@@ -35,6 +35,37 @@ export type ResolvedSecretMatchPolicy = 'anywhere' | 'boundary'
*/
export const MIN_UNANCHORED_MATCH_LENGTH = 8
/**
* Literals that may not match at all, at any offset, because they identify nothing.
*
* This is cardinality, not entropy — the distinction the floor above turns on. An all-`f` HMAC key
* is low-entropy but drawn from an enormous space, so a hit on it is evidence. `false` is drawn
* from a space of two: a hit on it is evidence of nothing, and substituting it protects nothing an
* attacker could not guess by flipping a coin. Meanwhile it rewrites every boolean any workflow
* ever wrote — one deployment turned 2,000 `had_error` cells into `[REDACTED_SECRET]` because a
* `*_BANNER_ENABLED` variable happened to hold `false`.
*
* Exactly the three JSON renderings of a non-string primitive, and nothing else. `0` and `1` are
* deliberately absent: a short numeric secret is entirely plausible where a boolean one is not.
* Matching is case-sensitive because the set is defined by what `String(value)` produces for a
* typed primitive, not by what looks boolean — an environment variable literally holding `False`
* keeps its protection.
*
* The residual is one bit: a variable whose whole value is the string `false` is no longer hidden.
*/
const NON_IDENTIFYING_SECRET_LITERALS: ReadonlySet<string> = new Set(['true', 'false', 'null'])
/**
* True when a literal carries too little information to be worth protecting anywhere.
*
* Applied where literals are turned into matchers, so it governs detection and substitution alike:
* such a value is never rewritten out of content, and never recorded into durable provenance as
* something a later read must redact.
*/
export function isNonIdentifyingSecretLiteral(plaintext: string): boolean {
return NON_IDENTIFYING_SECRET_LITERALS.has(plaintext)
}
/**
* Combining marks count so a substitution cannot split a grapheme cluster. `_` deliberately does
* NOT: `sk_live_...` and `user_483920_profile` are the dominant way a secret gets joined into an
@@ -1,6 +1,7 @@
import { MAX_INLINE_MATERIALIZATION_BYTES } from '@/lib/execution/payloads/limits'
import {
getResolvedSecretMatchPolicy,
isNonIdentifyingSecretLiteral,
type ResolvedSecretMatchPolicy,
satisfiesResolvedSecretMatchPolicy,
} from '@/executor/utils/resolved-secret-match-policy'
@@ -457,7 +458,12 @@ export function createResolvedSecretMatcher(
const replacementByPlaintext = new Map<string, string>()
for (const match of matches) {
if (!match.plaintext) continue
/**
* Dropped before any construction-time check runs, so no later stage can be talked into
* treating one of these as protectable — including the wide-match-set checks below, which
* deliberately ignore the narrow policy.
*/
if (!match.plaintext || isNonIdentifyingSecretLiteral(match.plaintext)) continue
const current = replacementByPlaintext.get(match.plaintext)
if (current === undefined || compareStrings(match.replacement, current) < 0) {
replacementByPlaintext.set(match.plaintext, match.replacement)
@@ -1010,11 +1010,11 @@ describe('ResolvedSecretTraceRegistry', () => {
it('conservatively retains every active secret that shares a raw plaintext literal', () => {
const registry = new ResolvedSecretTraceRegistry([
{ name: 'FIRST', plaintext: 'true', encryptedValue: 'first-ciphertext' },
{ name: 'SECOND', plaintext: 'true', encryptedValue: 'second-ciphertext' },
{ name: 'FIRST', plaintext: '4815162342', encryptedValue: 'first-ciphertext' },
{ name: 'SECOND', plaintext: '4815162342', encryptedValue: 'second-ciphertext' },
])
registry.recordResolved('FIRST', 'true')
registry.recordResolved('SECOND', 'true')
registry.recordResolved('FIRST', '4815162342')
registry.recordResolved('SECOND', '4815162342')
const expected = {
version: 1 as const,
@@ -1024,11 +1024,11 @@ describe('ResolvedSecretTraceRegistry', () => {
{ name: 'SECOND', encryptedValue: 'second-ciphertext' },
],
}
expect(registry.exportCommittedProvenanceForValue('true')).toEqual(expected)
expect(registry.exportCommittedProvenanceForValue(true)).toEqual(expected)
expect(registry.exportCommittedProvenanceForValue('4815162342')).toEqual(expected)
expect(registry.exportCommittedProvenanceForValue(4815162342)).toEqual(expected)
})
it('exports active numeric, boolean, and null literals crossing a value boundary', () => {
it('exports active numeric literals crossing a value boundary, but not boolean or null', () => {
const registry = new ResolvedSecretTraceRegistry([
{ name: 'NUMBER', plaintext: '1234', encryptedValue: 'number-ciphertext' },
{ name: 'BOOLEAN', plaintext: 'false', encryptedValue: 'boolean-ciphertext' },
@@ -1048,19 +1048,17 @@ describe('ResolvedSecretTraceRegistry', () => {
).toEqual({
version: 1,
complete: true,
entries: [
{ encryptedValue: 'boolean-ciphertext' },
{ encryptedValue: 'null-ciphertext' },
{ encryptedValue: 'number-ciphertext' },
],
entries: [{ encryptedValue: 'number-ciphertext' }],
})
})
it('marks a bounded cross-boundary scan incomplete when an enumerable accessor is opaque', () => {
it('keeps every candidate when a bounded cross-boundary scan hits an opaque accessor', () => {
const registry = new ResolvedSecretTraceRegistry([
{ name: 'TOKEN', plaintext: 'secret', encryptedValue: 'ciphertext' },
{ name: 'ABSENT', plaintext: 'never-present', encryptedValue: 'absent-ciphertext' },
])
registry.recordResolved('TOKEN', 'secret')
registry.recordResolved('ABSENT', 'never-present')
const value = {}
Object.defineProperty(value, 'opaque', {
enumerable: true,
@@ -1069,16 +1067,18 @@ describe('ResolvedSecretTraceRegistry', () => {
expect(registry.exportProvenanceForValue(value, { anonymous: true })).toEqual({
version: 1,
complete: false,
entries: [],
complete: true,
entries: [{ encryptedValue: 'absent-ciphertext' }, { encryptedValue: 'ciphertext' }],
})
})
it('does not claim a complete cross-boundary scan for opaque large-value refs', () => {
it('keeps every candidate rather than voiding provenance for an opaque large-value ref', () => {
const registry = new ResolvedSecretTraceRegistry([
{ name: 'TOKEN', plaintext: 'secret', encryptedValue: 'ciphertext' },
{ name: 'ABSENT', plaintext: 'never-present', encryptedValue: 'absent-ciphertext' },
])
registry.recordResolved('TOKEN', 'secret')
registry.recordResolved('ABSENT', 'never-present')
expect(
registry.exportProvenanceForValue(
@@ -1091,6 +1091,70 @@ describe('ResolvedSecretTraceRegistry', () => {
},
{ anonymous: true }
)
).toEqual({
version: 1,
complete: true,
entries: [{ encryptedValue: 'absent-ciphertext' }, { encryptedValue: 'ciphertext' }],
})
})
it('lets a model input path survive an upstream output the scan could not read', async () => {
const scope = { userId: 'user-1', workspaceId: 'workspace-1' }
const catalog = [
{ name: 'TOKEN', plaintext: 'decrypted:ciphertext', encryptedValue: 'ciphertext' },
]
const producer = new ResolvedSecretTraceRegistry(catalog, scope)
producer.recordResolved('TOKEN', 'decrypted:ciphertext')
/** A block output past the traversal bound, exactly as compaction leaves a large table read. */
const upstreamOutput = {
rows: Array.from({ length: 5_000 }, (_, index) => ({
id: `row_${index}`,
a: 'a',
b: 'b',
c: 'c',
d: 'd',
e: 'e',
f: 'f',
g: 'g',
h: 'h',
i: 'i',
j: 'j',
})),
}
const upstreamProvenance = producer.exportCommittedProvenanceForValue(upstreamOutput)
expect(upstreamProvenance.complete).toBe(true)
const consumer = new ResolvedSecretTraceRegistry(catalog, scope)
await consumer.importProvenanceForValueAtInputPath(
upstreamProvenance,
upstreamOutput,
['userPrompt'],
{ trusted: true }
)
const modelFork = consumer.forkForInputPaths([['userPrompt'], ['systemPrompt']])
expect(modelFork.projectResolvedInputSelection({ userPrompt: 'classify these rows' })).toEqual({
complete: true,
value: { userPrompt: 'classify these rows' },
})
})
it('still voids provenance for an unscannable value when the registry cannot vouch', () => {
const registry = new ResolvedSecretTraceRegistry([
{ name: 'TOKEN', plaintext: 'secret', encryptedValue: 'ciphertext' },
])
registry.recordResolved('TOKEN', 'secret')
registry.markIncomplete('unverified-resolved-entry')
expect(
registry.exportCommittedProvenanceForValue({
__simLargeValueRef: true,
version: 1,
id: 'lv_ABCDEFGHIJKL',
kind: 'object',
size: 1024,
})
).toEqual({ version: 1, complete: false, entries: [] })
})
@@ -1110,8 +1174,8 @@ describe('ResolvedSecretTraceRegistry', () => {
expect(provenance).toEqual({
version: 1,
complete: false,
entries: [],
complete: true,
entries: [{ encryptedValue: 'ciphertext' }],
})
expect(descriptorSnapshotCalls).toBe(0)
})
@@ -1508,3 +1572,53 @@ describe('incompleteness diagnostics', () => {
expect(logged).not.toContain('MISSING')
})
})
describe('non-identifying literals in durable provenance', () => {
/**
* The amplifier behind the boolean redaction: once recorded on a row, every later read of that
* table reactivated the value and rewrote every boolean in it.
*/
it('never records a value too small to identify anything', () => {
const registry = new ResolvedSecretTraceRegistry([
{ name: 'BANNER_ENABLED', plaintext: 'false', encryptedValue: 'flag-ciphertext' },
{ name: 'TOKEN', plaintext: 'xoxb-real-secret-value', encryptedValue: 'token-ciphertext' },
])
registry.recordResolved('BANNER_ENABLED', 'false')
registry.recordResolved('TOKEN', 'xoxb-real-secret-value')
expect(registry.exportProvenanceForValue({ had_error: false, note: 'fromUser=false' })).toEqual(
{ version: 1, complete: true, entries: [] }
)
expect(registry.exportProvenanceForValue({ token: 'xoxb-real-secret-value' })).toEqual({
version: 1,
complete: true,
entries: [{ name: 'TOKEN', encryptedValue: 'token-ciphertext' }],
})
})
it('still recognizes the internal alias, which names the variable its value cannot', () => {
const registry = new ResolvedSecretTraceRegistry([
{ name: 'BANNER_ENABLED', plaintext: 'false', encryptedValue: 'flag-ciphertext' },
])
registry.recordResolved('BANNER_ENABLED', 'false')
expect(registry.exportProvenanceForValue({ code: '__var_BANNER_ENABLED' })).toEqual({
version: 1,
complete: true,
entries: [{ name: 'BANNER_ENABLED', encryptedValue: 'flag-ciphertext' }],
})
})
it('keeps it out of the model matcher so nothing downstream can substitute it', () => {
const registry = new ResolvedSecretTraceRegistry([
{ name: 'BANNER_ENABLED', plaintext: 'false', encryptedValue: 'flag-ciphertext' },
])
registry.recordResolved('BANNER_ENABLED', 'false')
const snapshot = registry.getModelEgressSnapshot()
expect(snapshot.complete).toBe(true)
if (snapshot.complete) {
expect(snapshot.matches.map((match) => match.plaintext)).not.toContain('false')
}
})
})
@@ -4,6 +4,7 @@ import { decryptSecret } from '@/lib/core/security/encryption'
import { isLargeArrayManifest } from '@/lib/execution/payloads/large-array-manifest-metadata'
import { isLargeValueRef } from '@/lib/execution/payloads/large-value-ref'
import { MAX_INLINE_MATERIALIZATION_BYTES } from '@/lib/execution/payloads/limits'
import { isNonIdentifyingSecretLiteral } from '@/executor/utils/resolved-secret-match-policy'
import {
createResolvedSecretMatcher,
OPAQUE_RESOLVED_SECRET_REPLACEMENT,
@@ -38,6 +39,8 @@ export type ResolvedSecretIncompletenessReason =
| 'value-provenance-untrusted'
| 'value-provenance-import-failed'
| 'value-provenance-filter-incomplete'
| 'durable-provenance-unknown'
| 'durable-provenance-malformed'
| 'unspecified'
/**
@@ -64,6 +67,7 @@ const ORIGINATING_FAULT_REASONS = new Set<ResolvedSecretIncompletenessReason>([
'tool-call-scope-mismatch',
'value-provenance-untrusted',
'value-provenance-import-failed',
'durable-provenance-malformed',
])
/**
@@ -161,13 +165,17 @@ interface ResolvedInputPathState {
interface PreparedProvenanceFilter {
candidatesByScanLiteral: ReadonlyMap<string, readonly ActiveSecretEntry[]>
candidatesByAlias: ReadonlyMap<string, readonly ActiveSecretEntry[]>
candidateEntryKeys: ReadonlySet<string>
candidateEntries: ReadonlyMap<string, ActiveSecretEntry>
matcher?: ResolvedSecretMatcher
}
/**
* Carries the candidate entries on both arms, because they are the answer whenever narrowing is
* unavailable — including when the matcher itself could not be built.
*/
type PreparedProvenanceFilterResult =
| { complete: true; filter: PreparedProvenanceFilter }
| { complete: false }
| { complete: false; candidateEntries: ReadonlyMap<string, ActiveSecretEntry> }
export interface ImportResolvedSecretTraceProvenanceOptions {
trusted: boolean
@@ -1296,7 +1304,12 @@ export class ResolvedSecretTraceRegistry {
private buildMatches(entries: Iterable<ActiveSecretEntry>): readonly ResolvedSecretTraceMatch[] {
const candidatesByPlaintext = new Map<string, ActiveSecretEntry[]>()
for (const entry of entries) {
if (entry.plaintext.length === 0) continue
/**
* Dropped here too, not only inside the matcher, so a literal that will never be substituted
* also never counts toward the matcher capacity bound or appears to a snapshot reader as
* something this registry protects.
*/
if (entry.plaintext.length === 0 || isNonIdentifyingSecretLiteral(entry.plaintext)) continue
const candidates = candidatesByPlaintext.get(entry.plaintext) ?? []
candidates.push(entry)
candidatesByPlaintext.set(entry.plaintext, candidates)
@@ -1495,16 +1508,21 @@ export class ResolvedSecretTraceRegistry {
}
private prepareProvenanceFilter(
candidateEntries: Iterable<ActiveSecretEntry>
sourceEntries: Iterable<ActiveSecretEntry>
): PreparedProvenanceFilterResult {
const candidatesByPlaintext = new Map<string, ActiveSecretEntry[]>()
const sortedCandidateEntries = [...candidateEntries].sort(
const sortedCandidateEntries = [...sourceEntries].sort(
(left, right) =>
compareStrings(left.name, right.name) ||
compareStrings(left.encryptedValue, right.encryptedValue)
)
for (const entry of sortedCandidateEntries) {
if (entry.plaintext.length === 0) continue
/**
* Excluded from scan literals as well as from the matcher, so such a value is never recorded
* into durable provenance as something a later read must redact. A named entry still joins
* the alias loop below — `__var_NAME` identifies the variable even when its value does not.
*/
if (entry.plaintext.length === 0 || isNonIdentifyingSecretLiteral(entry.plaintext)) continue
const candidates = candidatesByPlaintext.get(entry.plaintext) ?? []
const entryKey = activeEntryKey(entry)
if (!candidates.some((candidate) => activeEntryKey(candidate) === entryKey)) {
@@ -1514,7 +1532,7 @@ export class ResolvedSecretTraceRegistry {
}
const candidatesByScanLiteral = new Map<string, ActiveSecretEntry[]>()
const candidateEntryKeys = new Set<string>()
const candidateEntries = new Map<string, ActiveSecretEntry>()
const addScanLiteral = (literal: string, entry: ActiveSecretEntry): void => {
if (literal.length === 0) return
const candidates = candidatesByScanLiteral.get(literal) ?? []
@@ -1523,7 +1541,7 @@ export class ResolvedSecretTraceRegistry {
candidates.push(entry)
candidatesByScanLiteral.set(literal, candidates)
}
candidateEntryKeys.add(entryKey)
candidateEntries.set(entryKey, entry)
}
for (const candidates of candidatesByPlaintext.values()) {
for (const entry of candidates) {
@@ -1542,7 +1560,7 @@ export class ResolvedSecretTraceRegistry {
candidates.push(entry)
candidatesByAlias.set(alias, candidates)
}
candidateEntryKeys.add(entryKey)
candidateEntries.set(entryKey, entry)
}
let matcher: ResolvedSecretMatcher | undefined
@@ -1555,7 +1573,7 @@ export class ResolvedSecretTraceRegistry {
error: getErrorMessage(error, 'Unknown error'),
candidateCount: candidatesByScanLiteral.size,
})
return { complete: false }
return { complete: false, candidateEntries }
}
return {
@@ -1563,12 +1581,49 @@ export class ResolvedSecretTraceRegistry {
filter: {
candidatesByScanLiteral,
candidatesByAlias,
candidateEntryKeys,
candidateEntries,
...(matcher ? { matcher } : {}),
},
}
}
/** Builds the envelope for one selected entry set; only this registry's own state can void it. */
private provenanceForSelectedEntries(
entries: ReadonlyMap<string, ActiveSecretEntry>,
options: ExportResolvedSecretTraceProvenanceForValueOptions
): ResolvedSecretTraceProvenanceV1 {
const complete = !this.isPermanentlyIncomplete()
return {
version: 1,
complete,
entries: complete
? this.buildProvenanceEntries([...entries.values()], options.anonymous)
: [],
...(this.scope ? { scope: cloneProvenanceScope(this.scope) } : {}),
}
}
/**
* Answers a value the bounded scan could not read in full by keeping every candidate entry.
*
* Narrowing exists to stop content that provably carries no secret from being over-redacted; it
* is not what makes an envelope trustworthy. The candidates are already the trusted answer to
* "which secrets could this value carry", so an unreadable value — an offloaded large-value ref
* the scan cannot see through, a payload past the traversal bound, a hostile accessor — degrades
* to no narrowing rather than to unknown provenance.
*
* Reporting unknown here is what let a size threshold behave like a permanent fault: the flag
* travels onto the producing block's state, and every model boundary that later consumes that
* output refuses, with nothing telling the author the cause was payload volume rather than a
* secret. Over-approximating costs extra redaction; it can never under-redact.
*/
private unnarrowedProvenance(
candidateEntries: ReadonlyMap<string, ActiveSecretEntry>,
options: ExportResolvedSecretTraceProvenanceForValueOptions
): ResolvedSecretTraceProvenanceV1 {
return this.provenanceForSelectedEntries(candidateEntries, options)
}
private exportProvenanceForValueWithPreparedFilter(
value: unknown,
prepared: PreparedProvenanceFilterResult,
@@ -1583,37 +1638,22 @@ export class ResolvedSecretTraceRegistry {
options: ExportResolvedSecretTraceProvenanceForValueOptions
): ResolvedSecretTraceProvenanceV1 {
if (!prepared.complete) {
return {
version: 1,
complete: false,
entries: [],
...(this.scope ? { scope: cloneProvenanceScope(this.scope) } : {}),
}
return this.unnarrowedProvenance(prepared.candidateEntries, options)
}
const { candidatesByScanLiteral, candidatesByAlias, candidateEntryKeys, matcher } =
const { candidatesByScanLiteral, candidatesByAlias, candidateEntries, matcher } =
prepared.filter
const matchedEntries = new Map<string, ActiveSecretEntry>()
const pendingValues: unknown[] = []
try {
for (const value of values) {
if (pendingValues.length >= MAX_PROVENANCE_FILTER_NODES) {
return {
version: 1,
complete: false,
entries: [],
...(this.scope ? { scope: cloneProvenanceScope(this.scope) } : {}),
}
return this.unnarrowedProvenance(candidateEntries, options)
}
pendingValues.push(value)
}
} catch {
return {
version: 1,
complete: false,
entries: [],
...(this.scope ? { scope: cloneProvenanceScope(this.scope) } : {}),
}
return this.unnarrowedProvenance(candidateEntries, options)
}
const visited = new WeakSet<object>()
let scannedNodes = 0
@@ -1661,7 +1701,7 @@ export class ResolvedSecretTraceRegistry {
if (scannedNodes + pendingValues.length >= MAX_PROVENANCE_FILTER_NODES) return false
scannedNodes++
if (!scanString(key)) return false
if (matchedEntries.size >= candidateEntryKeys.size) return true
if (matchedEntries.size >= candidateEntries.size) return true
if ('value' in descriptor) {
if (scannedNodes + pendingValues.length >= MAX_PROVENANCE_FILTER_NODES) return false
@@ -1672,7 +1712,7 @@ export class ResolvedSecretTraceRegistry {
return true
}
while (pendingValues.length > 0 && matchedEntries.size < candidateEntryKeys.size) {
while (pendingValues.length > 0 && matchedEntries.size < candidateEntries.size) {
const current = pendingValues.pop()
scannedNodes++
if (scannedNodes > MAX_PROVENANCE_FILTER_NODES) {
@@ -1708,9 +1748,9 @@ export class ResolvedSecretTraceRegistry {
scanComplete = false
break
}
if (matchedEntries.size >= candidateEntryKeys.size) break
if (matchedEntries.size >= candidateEntries.size) break
}
if (!scanComplete || matchedEntries.size >= candidateEntryKeys.size) break
if (!scanComplete || matchedEntries.size >= candidateEntries.size) break
for (const key in current as Record<string, unknown>) {
enumeratedProperties++
@@ -1725,7 +1765,7 @@ export class ResolvedSecretTraceRegistry {
scanComplete = false
break
}
if (matchedEntries.size >= candidateEntryKeys.size) break
if (matchedEntries.size >= candidateEntries.size) break
}
if (!scanComplete) break
} catch {
@@ -1734,16 +1774,9 @@ export class ResolvedSecretTraceRegistry {
}
}
const complete = !this.isPermanentlyIncomplete() && scanComplete
const entries = complete
? this.buildProvenanceEntries([...matchedEntries.values()], options.anonymous)
: []
return {
version: 1,
complete,
entries,
...(this.scope ? { scope: cloneProvenanceScope(this.scope) } : {}),
}
return scanComplete
? this.provenanceForSelectedEntries(matchedEntries, options)
: this.unnarrowedProvenance(candidateEntries, options)
}
private collectInputPathEntryKeys(paths: readonly ResolvedSecretInputPath[]): Set<string> {
@@ -227,7 +227,7 @@ describe('projectToolResultForCopilot', () => {
).toEqual({ success: true, output: { result: encoded } })
})
it('projects exact typed primitive secrets and the same values as strings', () => {
it('projects exact typed numeric secrets, leaving booleans and null identifying nothing', () => {
const registry = new ResolvedSecretTraceRegistry([
{ name: 'NUMBER', plaintext: '123', encryptedValue: 'number-ciphertext' },
{ name: 'BOOLEAN', plaintext: 'true', encryptedValue: 'boolean-ciphertext' },
@@ -256,11 +256,11 @@ describe('projectToolResultForCopilot', () => {
success: true,
output: {
number: '{{NUMBER}}',
boolean: '{{BOOLEAN}}',
nothing: '{{NULL}}',
boolean: true,
nothing: null,
numberText: '{{NUMBER}}',
booleanText: '{{BOOLEAN}}',
nilText: '{{NULL}}',
booleanText: 'true',
nilText: 'null',
},
})
})
@@ -569,7 +569,7 @@ describe('maybeWriteReadCsvToTable', () => {
col_status: {
version: 1,
complete: true,
entries: [{ name: 'BOOLEAN', encryptedValue: 'encrypted-boolean' }],
entries: [],
scope: { userId: 'user-1', workspaceId: 'workspace-1' },
},
},
@@ -634,7 +634,7 @@ describe('maybeWriteReadCsvToTable', () => {
)
})
it('preserves numeric and boolean cells while recording their provenance', async () => {
it('preserves numeric and boolean cells, recording provenance only for the identifying one', async () => {
const registry = new ResolvedSecretTraceRegistry(
[
{ name: 'NUMBER', plaintext: '123', encryptedValue: 'encrypted-number' },
@@ -666,9 +666,7 @@ describe('maybeWriteReadCsvToTable', () => {
col_age: expect.objectContaining({
entries: [{ name: 'NUMBER', encryptedValue: 'encrypted-number' }],
}),
col_active: expect.objectContaining({
entries: [{ name: 'BOOLEAN', encryptedValue: 'encrypted-boolean' }],
}),
col_active: expect.objectContaining({ entries: [] }),
}),
}),
],
+1
View File
@@ -109,6 +109,7 @@ export const env = createEnv({
PII_REDACTION: z.boolean().optional(), // Redact PII from workflow logs via configurable Data Retention rules (Presidio at the logger persist choke point) and expose the Data Retention config UI
PII_GRANULAR_REDACTION: z.boolean().optional(), // Expose the execution-altering PII redaction stages (redact workflow input + block outputs in-flight) in the Data Retention config; layered on top of PII_REDACTION
TRIGGER_EU_REGION: z.boolean().optional(), // Route Trigger.dev runs to eu-central-1 instead of the default us-east-1 (fallback for the trigger-eu-region flag when AppConfig is not the source of truth)
DURABLE_SECRET_PROVENANCE_ENFORCED_SURFACES: z.string().optional(), // Durable surfaces where unrecorded secret provenance fails the run instead of logging a warning: "all", or a comma-separated subset of memory,table-row,knowledge,workspace-file (default: none enforced)
// Table feature limits (per plan). Apply when billing is disabled (free tier defaults) or for billed plans.
FREE_TABLES_LIMIT: z.number().optional(), // Max user tables per workspace on free tier (default: 5)
@@ -0,0 +1,92 @@
/**
* @vitest-environment node
*/
import { beforeEach, describe, expect, it, vi } from 'vitest'
const { mockEnv, mockLogger } = vi.hoisted(() => ({
mockEnv: {} as Record<string, string | undefined>,
mockLogger: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() },
}))
vi.mock('@/lib/core/config/env', () => ({ env: mockEnv }))
vi.mock('@sim/logger', () => ({ createLogger: () => mockLogger }))
import {
DURABLE_SECRET_PROVENANCE_SURFACES,
isDurableSecretProvenanceEnforced,
reportUnrecordedDurableProvenance,
resetDurableSecretProvenanceEnforcementCache,
} from '@/lib/execution/durable-secret-provenance-enforcement'
function configure(value: string | undefined): void {
mockEnv.DURABLE_SECRET_PROVENANCE_ENFORCED_SURFACES = value
resetDurableSecretProvenanceEnforcementCache()
}
describe('durable secret provenance enforcement', () => {
beforeEach(() => {
vi.clearAllMocks()
configure(undefined)
})
it('enforces nothing by default, so unrecorded provenance warns instead of latching', () => {
for (const surface of DURABLE_SECRET_PROVENANCE_SURFACES) {
expect(isDurableSecretProvenanceEnforced(surface)).toBe(false)
}
})
it('closes one surface at a time without touching the others', () => {
configure('table-row')
expect(isDurableSecretProvenanceEnforced('table-row')).toBe(true)
expect(isDurableSecretProvenanceEnforced('memory')).toBe(false)
expect(isDurableSecretProvenanceEnforced('knowledge')).toBe(false)
})
it('accepts a comma-separated subset, ignoring case and padding', () => {
configure(' Memory , TABLE-ROW ')
expect(isDurableSecretProvenanceEnforced('memory')).toBe(true)
expect(isDurableSecretProvenanceEnforced('table-row')).toBe(true)
expect(isDurableSecretProvenanceEnforced('knowledge')).toBe(false)
})
it('closes every surface on "all"', () => {
configure('all')
for (const surface of DURABLE_SECRET_PROVENANCE_SURFACES) {
expect(isDurableSecretProvenanceEnforced(surface)).toBe(true)
}
})
it('reports an unrecognized surface rather than silently enforcing nothing', () => {
configure('memory,workspace-file')
expect(isDurableSecretProvenanceEnforced('memory')).toBe(true)
expect(mockLogger.error).toHaveBeenCalledWith(
'Ignoring unrecognized durable secret provenance surfaces',
expect.objectContaining({ unrecognized: ['workspace-file'] })
)
})
it('reports at error with the surface, cause, and affected count so it survives every LOG_LEVEL default', () => {
reportUnrecordedDurableProvenance({
surface: 'table-row',
cause: 'row-sidecar-not-exact',
affectedCount: 8,
workspaceId: 'workspace-1',
})
expect(mockLogger.warn).not.toHaveBeenCalled()
expect(mockLogger.error).toHaveBeenCalledWith(
'Proceeding on unrecorded durable secret provenance',
{
surface: 'table-row',
cause: 'row-sidecar-not-exact',
enforced: false,
affectedCount: 8,
workspaceId: 'workspace-1',
}
)
})
})
@@ -0,0 +1,106 @@
import { createLogger } from '@sim/logger'
import { env } from '@/lib/core/config/env'
const logger = createLogger('DurableSecretProvenanceEnforcement')
/**
* Durable stores that can hand a run a value whose secret provenance was never recorded.
*
* Named by the call site rather than derived, so the surface survives refactors and stays
* greppable — the same convention the projection-refusal `site` strings use.
*/
export const DURABLE_SECRET_PROVENANCE_SURFACES = ['memory', 'table-row', 'knowledge'] as const
export type DurableSecretProvenanceSurface = (typeof DURABLE_SECRET_PROVENANCE_SURFACES)[number]
/** Reads the configured surfaces once; an unrecognized name is reported rather than assumed. */
function resolveEnforcedSurfaces(): ReadonlySet<DurableSecretProvenanceSurface> {
const configured = env.DURABLE_SECRET_PROVENANCE_ENFORCED_SURFACES?.trim()
if (!configured) return new Set()
const requested = configured
.split(',')
.map((entry) => entry.trim().toLowerCase())
.filter((entry) => entry.length > 0)
if (requested.includes('all')) return new Set(DURABLE_SECRET_PROVENANCE_SURFACES)
const enforced = new Set<DurableSecretProvenanceSurface>()
const unrecognized: string[] = []
for (const entry of requested) {
const surface = DURABLE_SECRET_PROVENANCE_SURFACES.find((candidate) => candidate === entry)
if (surface) enforced.add(surface)
else unrecognized.push(entry)
}
if (unrecognized.length > 0) {
logger.error('Ignoring unrecognized durable secret provenance surfaces', {
unrecognized,
supported: [...DURABLE_SECRET_PROVENANCE_SURFACES],
})
}
return enforced
}
let enforcedSurfaces: ReadonlySet<DurableSecretProvenanceSurface> | undefined
/**
* True when unrecorded provenance from this surface must fail the run rather than warn.
*
* Nothing is enforced by default. `unknown` provenance means "nobody recorded what secrets this
* value carries", which is the same thing a pre-tracking legacy row says — and legacy rows are read
* as carrying none. Enforcing one and not the other made an *aware* writer that momentarily could
* not vouch strictly worse than an unaware one: the row it wrote latched every run that later read
* it, and each latched run wrote more such rows, so a workspace could not recover without a data
* repair.
*
* Warning instead keeps that state visible and measurable while the writers that produce it are
* fixed. The sidecar still records `unknown` faithfully, so a surface can be closed back up once
* its writers stop losing provenance — and the rows that will start failing are countable from the
* sidecar table before the switch is thrown. This is a deliberate posture: an unenforced surface
* can under-redact a value whose provenance was lost.
*
* Set `DURABLE_SECRET_PROVENANCE_ENFORCED_SURFACES` to `all`, or to a comma-separated subset of
* {@link DURABLE_SECRET_PROVENANCE_SURFACES}, to close a surface.
*
* Workspace files are deliberately not a surface here: their unknown check sits in the callers
* rather than in the shared import, and one unknown file locks vfs reads, chat attachments, sandbox
* mounts, and the file tool routes at once. They stay fail-closed until that is its own decision.
*/
export function isDurableSecretProvenanceEnforced(
surface: DurableSecretProvenanceSurface
): boolean {
enforcedSurfaces ??= resolveEnforcedSurfaces()
return enforcedSurfaces.has(surface)
}
export interface UnrecordedDurableProvenanceReport {
surface: DurableSecretProvenanceSurface
/** What the surface could not vouch for, e.g. `sidecar-status-unknown`. Always a static literal. */
cause: string
/** How many records in this one read were unrecorded, when the caller reads a page at a time. */
affectedCount?: number
workspaceId?: string
}
/**
* Records that a read proceeded on provenance nobody wrote down.
*
* Error, not warn, for the same reason the originating-fault reasons use it: error is the only
* level that survives every default the logger falls back to — production, test, and a self-hosted
* chart that sets no `LOG_LEVEL`. A surface stays open on the strength of this line being visible
* and trending to zero, so a level that a deployment can silently filter would leave the posture
* unmeasured. It is deliberately noisy on an affected workspace; that is the signal.
*/
export function reportUnrecordedDurableProvenance(report: UnrecordedDurableProvenanceReport): void {
logger.error('Proceeding on unrecorded durable secret provenance', {
surface: report.surface,
cause: report.cause,
enforced: false,
...(report.affectedCount !== undefined ? { affectedCount: report.affectedCount } : {}),
...(report.workspaceId ? { workspaceId: report.workspaceId } : {}),
})
}
/** Test seam: forces the next read to re-resolve the env-configured surfaces. */
export function resetDurableSecretProvenanceEnforcementCache(): void {
enforcedSurfaces = undefined
}
@@ -1,12 +1,26 @@
/**
* @vitest-environment node
*/
import { describe, expect, it } from 'vitest'
import { beforeEach, describe, expect, it, vi } from 'vitest'
const { mockIsEnforced, mockReport } = vi.hoisted(() => ({
mockIsEnforced: vi.fn(() => false),
mockReport: vi.fn(),
}))
vi.mock('@/lib/execution/durable-secret-provenance-enforcement', () => ({
DURABLE_SECRET_PROVENANCE_SURFACES: ['memory', 'table-row', 'knowledge'],
isDurableSecretProvenanceEnforced: mockIsEnforced,
reportUnrecordedDurableProvenance: mockReport,
}))
import {
durableSecretProvenanceFromPrivateBundle,
filterDurableSecretProvenanceBySourceValues,
hashDurableSecretProvenanceValue,
importDurableSecretProvenance,
} from '@/lib/execution/durable-secret-provenance'
import { ResolvedSecretTraceRegistry } from '@/executor/utils/resolved-secret-trace-registry'
function privateBundle(scope?: { userId: string; workspaceId?: string }) {
return {
@@ -133,3 +147,54 @@ describe('private durable provenance scope admission', () => {
).toBeUndefined()
})
})
describe('importing unrecorded durable provenance', () => {
const UNKNOWN = { status: 'unknown' } as const
beforeEach(() => {
vi.clearAllMocks()
mockIsEnforced.mockReturnValue(false)
})
it('warns and leaves the registry able to vouch when the surface is not enforced', async () => {
const registry = new ResolvedSecretTraceRegistry()
await expect(
importDurableSecretProvenance(registry, UNKNOWN, undefined, 'memory')
).resolves.toBe(true)
expect(registry.isPermanentlyIncomplete()).toBe(false)
expect(mockReport).toHaveBeenCalledWith({
surface: 'memory',
cause: 'durable-provenance-unknown',
})
})
it('latches the registry once that surface is closed', async () => {
mockIsEnforced.mockReturnValue(true)
const registry = new ResolvedSecretTraceRegistry()
await expect(
importDurableSecretProvenance(registry, UNKNOWN, undefined, 'memory')
).resolves.toBe(false)
expect(registry.isPermanentlyIncomplete()).toBe(true)
expect(mockReport).not.toHaveBeenCalled()
})
it('latches for a caller that has not declared a surface', async () => {
const registry = new ResolvedSecretTraceRegistry()
await expect(importDurableSecretProvenance(registry, UNKNOWN)).resolves.toBe(false)
expect(registry.isPermanentlyIncomplete()).toBe(true)
})
it('never relaxes a malformed sidecar, which is a fault rather than missing data', async () => {
const registry = new ResolvedSecretTraceRegistry()
const malformed = { status: 'exact', entries: [{ encryptedValue: '' }] } as never
await expect(
importDurableSecretProvenance(registry, malformed, undefined, 'memory')
).resolves.toBe(false)
expect(registry.isPermanentlyIncomplete()).toBe(true)
expect(mockReport).not.toHaveBeenCalled()
})
})
@@ -1,5 +1,10 @@
import { createHash } from 'node:crypto'
import type { DurableSecretProvenanceEntry } from '@sim/db/schema'
import {
type DurableSecretProvenanceSurface,
isDurableSecretProvenanceEnforced,
reportUnrecordedDurableProvenance,
} from '@/lib/execution/durable-secret-provenance-enforcement'
import {
isPrivateSecretProvenanceBundleV1,
type PrivateSecretProvenanceBundleV1,
@@ -191,19 +196,34 @@ export function filterDurableSecretProvenanceBySourceValues(
return entries ? { status: 'exact', entries } : { status: 'unknown' }
}
/** Imports durable entries into a model-bound registry, preserving source-scope anonymity. */
/**
* Imports durable entries into a model-bound registry, preserving source-scope anonymity.
*
* `surface` selects the enforcement policy for provenance nobody recorded. Omitting it enforces,
* which is the right default for a caller that has not been reviewed against
* {@link isDurableSecretProvenanceEnforced} yet.
*/
export async function importDurableSecretProvenance(
registry: ResolvedSecretTraceRegistry,
provenance: DurableSecretProvenance,
value?: unknown
value?: unknown,
surface?: DurableSecretProvenanceSurface
): Promise<boolean> {
if (provenance.status === 'unknown') {
registry.markIncomplete()
if (surface && !isDurableSecretProvenanceEnforced(surface)) {
reportUnrecordedDurableProvenance({ surface, cause: 'durable-provenance-unknown' })
return true
}
registry.markIncomplete('durable-provenance-unknown')
return false
}
const entries = normalizeDurableSecretProvenanceEntries(provenance.entries)
if (!entries) {
registry.markIncomplete()
/**
* Malformed is not unrecorded: a sidecar that exists but cannot be parsed is a fault, and no
* enforcement policy relaxes it.
*/
registry.markIncomplete('durable-provenance-malformed')
return false
}
+15 -4
View File
@@ -409,7 +409,7 @@ export async function loadKnowledgeDocumentSecretRegistry(
tracked: row.secretProvenanceVersion === 1 || currentSourceFileProvenance !== undefined,
}
const registry = new ResolvedSecretTraceRegistry([], scope)
if (!(await importDurableSecretProvenance(registry, provenance))) {
if (!(await importDurableSecretProvenance(registry, provenance, undefined, 'knowledge'))) {
throw new Error('Knowledge document secret provenance is unavailable')
}
return { registry, provenance, tracked: true }
@@ -503,7 +503,9 @@ export async function importKnowledgePersistedResponseSecretProvenance(options:
readBoundKnowledgeDocumentSecretProvenance({ ...row, source }),
source
)
if (!(await importDurableSecretProvenance(options.registry, provenance, item.value))) {
if (
!(await importDurableSecretProvenance(options.registry, provenance, item.value, 'knowledge'))
) {
return false
}
}
@@ -515,7 +517,9 @@ export async function importKnowledgePersistedResponseSecretProvenance(options:
return false
}
const provenance = readBoundKnowledgeEmbeddingSecretProvenance(row)
if (!(await importDurableSecretProvenance(options.registry, provenance, item.value))) {
if (
!(await importDurableSecretProvenance(options.registry, provenance, item.value, 'knowledge'))
) {
return false
}
}
@@ -567,7 +571,14 @@ export async function importKnowledgeSearchResultSecretProvenance(options: {
return { imported: false, documentMetadata: {} }
}
const provenance = readBoundKnowledgeEmbeddingSecretProvenance(row)
if (!(await importDurableSecretProvenance(options.registry, provenance, result.content))) {
if (
!(await importDurableSecretProvenance(
options.registry,
provenance,
result.content,
'knowledge'
))
) {
return { imported: false, documentMetadata: {} }
}
}
@@ -5,6 +5,18 @@ import { userTableDefinitions, userTableRows } from '@sim/db/schema'
import { dbChainMock, dbChainMockFns, queueTableRows, resetDbChainMock } from '@sim/testing'
import { eq } from 'drizzle-orm'
import { beforeEach, describe, expect, it, vi } from 'vitest'
const { mockIsEnforced, mockReport } = vi.hoisted(() => ({
mockIsEnforced: vi.fn(() => false),
mockReport: vi.fn(),
}))
vi.mock('@/lib/execution/durable-secret-provenance-enforcement', () => ({
DURABLE_SECRET_PROVENANCE_SURFACES: ['memory', 'table-row', 'knowledge'],
isDurableSecretProvenanceEnforced: mockIsEnforced,
reportUnrecordedDurableProvenance: mockReport,
}))
import type { DbTransaction } from '@/lib/table/planner'
import {
classifyTableRowSecretProvenanceForCopy,
@@ -46,6 +58,7 @@ describe('table row secret provenance', () => {
beforeEach(() => {
vi.clearAllMocks()
resetDbChainMock()
mockIsEnforced.mockReturnValue(false)
})
it('checks a version-pinned table with one bounded unsafe-row query', async () => {
@@ -204,7 +217,8 @@ describe('table row secret provenance', () => {
})
})
it('fails closed for stale tracked rows instead of returning partial provenance', async () => {
it('fails closed for stale tracked rows once the table-row surface is enforced', async () => {
mockIsEnforced.mockReturnValue(true)
queueTableRows(userTableRows, [
{
id: 'tracked-row',
@@ -230,6 +244,62 @@ describe('table row secret provenance', () => {
})
})
/**
* The shape that broke production: one unrecorded row in a page voided the whole read, and a
* page is what a `query_rows` block hands downstream, so every later model boundary refused.
*/
it('keeps a page readable when one row is unrecorded, without dropping its siblings', async () => {
queueTableRows(userTableRows, [
{
id: 'unknown-row',
updatedAt: ROW_UPDATED_AT,
secretProvenanceVersion: 1,
sidecarRowId: 'unknown-row',
sidecarStatus: 'unknown',
sidecarEntries: [],
sidecarIsCurrent: true,
},
{
id: 'tracked-row',
updatedAt: ROW_UPDATED_AT,
secretProvenanceVersion: 1,
sidecarRowId: 'tracked-row',
sidecarStatus: 'exact',
sidecarEntries: [
{
columnId: 'secret-column',
encryptedValue: 'encrypted-local',
name: 'LOCAL_SECRET',
sourceUserId: 'user-1',
sourceWorkspaceId: 'workspace-1',
},
],
sidecarIsCurrent: true,
},
])
await expect(
loadTableRowSecretProvenance(
[
{ id: 'unknown-row', updatedAt: ROW_UPDATED_AT },
{ id: 'tracked-row', updatedAt: ROW_UPDATED_AT },
],
{ userId: 'user-1', workspaceId: 'workspace-1' }
)
).resolves.toEqual({
version: 1,
complete: true,
entries: [{ encryptedValue: 'encrypted-local', name: 'LOCAL_SECRET' }],
scope: { userId: 'user-1', workspaceId: 'workspace-1' },
})
expect(mockReport).toHaveBeenCalledWith({
surface: 'table-row',
cause: 'row-sidecar-not-exact',
affectedCount: 1,
workspaceId: 'workspace-1',
})
})
it('rejects contradictory duplicate row crossings before reading provenance', async () => {
await expect(
loadTableRowSecretProvenance(
+24 -1
View File
@@ -6,6 +6,10 @@ import {
userTableRows,
} from '@sim/db/schema'
import { and, asc, eq, gt, inArray, type SQL, sql } from 'drizzle-orm'
import {
isDurableSecretProvenanceEnforced,
reportUnrecordedDurableProvenance,
} from '@/lib/execution/durable-secret-provenance-enforcement'
import type { DbExecutor, DbTransaction } from '@/lib/table/planner'
import type { RowData, TableRowSecretProvenanceWrite } from '@/lib/table/types'
import {
@@ -765,6 +769,7 @@ export async function loadTableRowSecretProvenance(
const currentById = new Map(currentRows.map((row) => [row.id, row]))
const storedEntries: StoredTableRowSecretProvenanceEntry[] = []
let unrecordedRowCount = 0
for (const rowId of rowIds) {
const current = currentById.get(rowId)
const crossing = crossingById.get(rowId)
@@ -777,7 +782,16 @@ export async function loadTableRowSecretProvenance(
current.sidecarStatus !== 'exact' ||
!current.sidecarIsCurrent
) {
return { version: 1, complete: false, entries: [], scope }
/**
* One such row would otherwise void the whole page, and a page is what a `query_rows` block
* hands downstream — so a single row nobody recorded provenance for latched every run that
* read the table. Unenforced, the row contributes nothing, exactly like the legacy row above.
*/
if (isDurableSecretProvenanceEnforced('table-row')) {
return { version: 1, complete: false, entries: [], scope }
}
unrecordedRowCount += 1
continue
}
const parsed = normalizeStoredEntries(current.sidecarEntries)
if (!parsed) return { version: 1, complete: false, entries: [], scope }
@@ -791,6 +805,15 @@ export async function loadTableRowSecretProvenance(
}
}
if (unrecordedRowCount > 0) {
reportUnrecordedDurableProvenance({
surface: 'table-row',
cause: 'row-sidecar-not-exact',
affectedCount: unrecordedRowCount,
...(scope.workspaceId ? { workspaceId: scope.workspaceId } : {}),
})
}
const entries = aggregateStoredEntries(storedEntries, scope)
if (!entries) return { version: 1, complete: false, entries: [], scope }
const provenance: ResolvedSecretTraceProvenanceV1 = {
+1 -1
View File
@@ -393,7 +393,7 @@ describe('provider runtime context', () => {
expect(result.output).toBe('{{TOKEN}}')
})
it.each(['123', 'true'])(
it.each(['123'])(
'leaves non-model resource metadata untouched while projecting content (%s)',
async (secret) => {
const registry = new ResolvedSecretTraceRegistry([