mirror of
https://github.com/simstudioai/sim.git
synced 2026-09-21 13:00:04 +08:00
fix(provenance): feature flagged, inexact sidecars (#6491)
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -604,7 +604,8 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
|
||||
!(await importDurableSecretProvenance(
|
||||
resultSecretRegistry,
|
||||
metadata.provenance,
|
||||
renderedMetadata
|
||||
renderedMetadata,
|
||||
'knowledge'
|
||||
))
|
||||
) {
|
||||
resultSecretRegistry.markIncomplete()
|
||||
|
||||
@@ -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' },
|
||||
|
||||
@@ -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: [] }),
|
||||
}),
|
||||
}),
|
||||
],
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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([
|
||||
|
||||
Reference in New Issue
Block a user