diff --git a/apps/docs/content/docs/en/platform/self-hosting/environment-variables.mdx b/apps/docs/content/docs/en/platform/self-hosting/environment-variables.mdx index 3835925ca00..7e52d9225b7 100644 --- a/apps/docs/content/docs/en/platform/self-hosting/environment-variables.mdx +++ b/apps/docs/content/docs/en/platform/self-hosting/environment-variables.mdx @@ -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 diff --git a/apps/sim/app/api/knowledge/search/route.ts b/apps/sim/app/api/knowledge/search/route.ts index c4c412df787..e0530f6b772 100644 --- a/apps/sim/app/api/knowledge/search/route.ts +++ b/apps/sim/app/api/knowledge/search/route.ts @@ -604,7 +604,8 @@ export const POST = withRouteHandler(async (request: NextRequest) => { !(await importDurableSecretProvenance( resultSecretRegistry, metadata.provenance, - renderedMetadata + renderedMetadata, + 'knowledge' )) ) { resultSecretRegistry.markIncomplete() diff --git a/apps/sim/app/api/memory/secret-provenance.ts b/apps/sim/app/api/memory/secret-provenance.ts index dc6ea8f7368..cd14e84b90d 100644 --- a/apps/sim/app/api/memory/secret-provenance.ts +++ b/apps/sim/app/api/memory/secret-provenance.ts @@ -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') } } } diff --git a/apps/sim/executor/handlers/agent/memory.test.ts b/apps/sim/executor/handlers/agent/memory.test.ts index 1c127b925ce..a345073fc86 100644 --- a/apps/sim/executor/handlers/agent/memory.test.ts +++ b/apps/sim/executor/handlers/agent/memory.test.ts @@ -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' }, diff --git a/apps/sim/executor/handlers/agent/memory.ts b/apps/sim/executor/handlers/agent/memory.ts index 13523c742ce..b33d86b0b94 100644 --- a/apps/sim/executor/handlers/agent/memory.ts +++ b/apps/sim/executor/handlers/agent/memory.ts @@ -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, diff --git a/apps/sim/executor/utils/resolved-secret-content-projection.test.ts b/apps/sim/executor/utils/resolved-secret-content-projection.test.ts index 23f1731b0c9..88a38cad883 100644 --- a/apps/sim/executor/utils/resolved-secret-content-projection.test.ts +++ b/apps/sim/executor/utils/resolved-secret-content-projection.test.ts @@ -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() + }) +}) diff --git a/apps/sim/executor/utils/resolved-secret-match-policy.test.ts b/apps/sim/executor/utils/resolved-secret-match-policy.test.ts index 864d920e451..bf79df5754a 100644 --- a/apps/sim/executor/utils/resolved-secret-match-policy.test.ts +++ b/apps/sim/executor/utils/resolved-secret-match-policy.test.ts @@ -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) + } + ) +}) diff --git a/apps/sim/executor/utils/resolved-secret-match-policy.ts b/apps/sim/executor/utils/resolved-secret-match-policy.ts index 2bd9ffd0b11..4b572662ed5 100644 --- a/apps/sim/executor/utils/resolved-secret-match-policy.ts +++ b/apps/sim/executor/utils/resolved-secret-match-policy.ts @@ -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 = 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 diff --git a/apps/sim/executor/utils/resolved-secret-matcher.ts b/apps/sim/executor/utils/resolved-secret-matcher.ts index b20aad11653..60080f3249e 100644 --- a/apps/sim/executor/utils/resolved-secret-matcher.ts +++ b/apps/sim/executor/utils/resolved-secret-matcher.ts @@ -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() 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) 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 0db5b2e881f..6c2f057e2fb 100644 --- a/apps/sim/executor/utils/resolved-secret-trace-registry.test.ts +++ b/apps/sim/executor/utils/resolved-secret-trace-registry.test.ts @@ -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') + } + }) +}) diff --git a/apps/sim/executor/utils/resolved-secret-trace-registry.ts b/apps/sim/executor/utils/resolved-secret-trace-registry.ts index 853573eae6d..377c89d72da 100644 --- a/apps/sim/executor/utils/resolved-secret-trace-registry.ts +++ b/apps/sim/executor/utils/resolved-secret-trace-registry.ts @@ -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([ 'tool-call-scope-mismatch', 'value-provenance-untrusted', 'value-provenance-import-failed', + 'durable-provenance-malformed', ]) /** @@ -161,13 +165,17 @@ interface ResolvedInputPathState { interface PreparedProvenanceFilter { candidatesByScanLiteral: ReadonlyMap candidatesByAlias: ReadonlyMap - candidateEntryKeys: ReadonlySet + candidateEntries: ReadonlyMap 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 } export interface ImportResolvedSecretTraceProvenanceOptions { trusted: boolean @@ -1296,7 +1304,12 @@ export class ResolvedSecretTraceRegistry { private buildMatches(entries: Iterable): readonly ResolvedSecretTraceMatch[] { const candidatesByPlaintext = new Map() 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 + sourceEntries: Iterable ): PreparedProvenanceFilterResult { const candidatesByPlaintext = new Map() - 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() - const candidateEntryKeys = new Set() + const candidateEntries = new Map() 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, + 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, + 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() 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() 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) { 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 { diff --git a/apps/sim/lib/copilot/request/tools/resolved-secret-result.test.ts b/apps/sim/lib/copilot/request/tools/resolved-secret-result.test.ts index e138881aa12..2cb2066c699 100644 --- a/apps/sim/lib/copilot/request/tools/resolved-secret-result.test.ts +++ b/apps/sim/lib/copilot/request/tools/resolved-secret-result.test.ts @@ -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', }, }) }) diff --git a/apps/sim/lib/copilot/request/tools/tables.test.ts b/apps/sim/lib/copilot/request/tools/tables.test.ts index f75dbdde3f6..cc9faafbff9 100644 --- a/apps/sim/lib/copilot/request/tools/tables.test.ts +++ b/apps/sim/lib/copilot/request/tools/tables.test.ts @@ -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: [] }), }), }), ], diff --git a/apps/sim/lib/core/config/env.ts b/apps/sim/lib/core/config/env.ts index e0fcf321283..c8f365e8eb9 100644 --- a/apps/sim/lib/core/config/env.ts +++ b/apps/sim/lib/core/config/env.ts @@ -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) diff --git a/apps/sim/lib/execution/durable-secret-provenance-enforcement.test.ts b/apps/sim/lib/execution/durable-secret-provenance-enforcement.test.ts new file mode 100644 index 00000000000..46310bb9b29 --- /dev/null +++ b/apps/sim/lib/execution/durable-secret-provenance-enforcement.test.ts @@ -0,0 +1,92 @@ +/** + * @vitest-environment node + */ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { mockEnv, mockLogger } = vi.hoisted(() => ({ + mockEnv: {} as Record, + 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', + } + ) + }) +}) diff --git a/apps/sim/lib/execution/durable-secret-provenance-enforcement.ts b/apps/sim/lib/execution/durable-secret-provenance-enforcement.ts new file mode 100644 index 00000000000..8489ab66424 --- /dev/null +++ b/apps/sim/lib/execution/durable-secret-provenance-enforcement.ts @@ -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 { + 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() + 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 | 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 +} diff --git a/apps/sim/lib/execution/durable-secret-provenance.test.ts b/apps/sim/lib/execution/durable-secret-provenance.test.ts index 75d75d22d38..00a29654067 100644 --- a/apps/sim/lib/execution/durable-secret-provenance.test.ts +++ b/apps/sim/lib/execution/durable-secret-provenance.test.ts @@ -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() + }) +}) diff --git a/apps/sim/lib/execution/durable-secret-provenance.ts b/apps/sim/lib/execution/durable-secret-provenance.ts index 334f4cfa07d..edd4a1b7df8 100644 --- a/apps/sim/lib/execution/durable-secret-provenance.ts +++ b/apps/sim/lib/execution/durable-secret-provenance.ts @@ -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 { 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 } diff --git a/apps/sim/lib/knowledge/secret-provenance.ts b/apps/sim/lib/knowledge/secret-provenance.ts index 03802733807..139fb0adc44 100644 --- a/apps/sim/lib/knowledge/secret-provenance.ts +++ b/apps/sim/lib/knowledge/secret-provenance.ts @@ -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: {} } } } diff --git a/apps/sim/lib/table/rows/secret-provenance.test.ts b/apps/sim/lib/table/rows/secret-provenance.test.ts index 20240b69f2a..c9f0a948a55 100644 --- a/apps/sim/lib/table/rows/secret-provenance.test.ts +++ b/apps/sim/lib/table/rows/secret-provenance.test.ts @@ -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( diff --git a/apps/sim/lib/table/rows/secret-provenance.ts b/apps/sim/lib/table/rows/secret-provenance.ts index 126c3c8ea84..160bffa73c1 100644 --- a/apps/sim/lib/table/rows/secret-provenance.ts +++ b/apps/sim/lib/table/rows/secret-provenance.ts @@ -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 = { diff --git a/apps/sim/providers/runtime-context.test.ts b/apps/sim/providers/runtime-context.test.ts index de6bed10577..c2919d7f4bc 100644 --- a/apps/sim/providers/runtime-context.test.ts +++ b/apps/sim/providers/runtime-context.test.ts @@ -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([