Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 2 additions & 1 deletion apps/sim/app/api/knowledge/search/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -604,7 +604,8 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
!(await importDurableSecretProvenance(
resultSecretRegistry,
metadata.provenance,
renderedMetadata
renderedMetadata,
'knowledge'
))
) {
resultSecretRegistry.markIncomplete()
Expand Down
2 changes: 1 addition & 1 deletion apps/sim/app/api/memory/secret-provenance.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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')
}
}
}
Expand Down
4 changes: 2 additions & 2 deletions apps/sim/executor/handlers/agent/memory.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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' },
Expand Down
49 changes: 38 additions & 11 deletions apps/sim/executor/handlers/agent/memory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand All @@ -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,
Expand Down
75 changes: 70 additions & 5 deletions apps/sim/executor/utils/resolved-secret-content-projection.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,9 @@
*/
import { describe, expect, it, vi } from 'vitest'
import {
createResolvedSecretMatcher,
isResolvedSecretModelContentUnchanged,
projectResolvedSecretContent,
projectResolvedSecretDiagnosticError,
projectResolvedSecretModelContent,
projectResolvedSecretModelJsonContent,
Expand Down Expand Up @@ -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' },
Expand All @@ -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' },
])
Expand All @@ -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' },
Expand Down Expand Up @@ -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()
})
})
17 changes: 17 additions & 0 deletions apps/sim/executor/utils/resolved-secret-match-policy.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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)
}
)
})
31 changes: 31 additions & 0 deletions apps/sim/executor/utils/resolved-secret-match-policy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
8 changes: 7 additions & 1 deletion apps/sim/executor/utils/resolved-secret-matcher.ts
Original file line number Diff line number Diff line change
@@ -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'
Expand Down Expand Up @@ -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)
Expand Down
Loading
Loading