test(platform): prove the Langfuse privacy gating (#325) - #335
Merged
Conversation
… it exposed (#325) #320 stage 2 shipped three privacy guarantees with no test coverage. Writing the tests found that two of the three did not actually hold. Fixed: - `LANGFUSE_ALLOW_REMOTE_PAYLOADS` was `z.coerce.boolean()`, i.e. `Boolean(string)`. Since an env var is always a string, `=false`, `=0` and `=no` ALL evaluated to true — an operator writing `=false` to keep memory text on-box would have started shipping prompts and completions to a remote Langfuse. Replaced with a strict `envBoolean` that accepts only known spellings and hard-fails on anything else. - `safePayload`'s unconditional `isPrivate` redaction was unreachable: `CompleteArgs` had no `isPrivate` field and `traceGeneration` never passed one, so the strongest of the three guarantees was dead code. Threaded the flag through so the fence actually engages. - `vitest.config.ts` pinned every AI provider to its fake but not Langfuse, so a developer with Langfuse keys in `.env` had the suite build a real client and emit traces of test data. Both keys are now blanked under test. Also made the gating functions take an injectable `TracingConfig` (defaulting to env), mirroring `resolve(class, keys)` in the router, so the gate is testable hermetically without touching global env or the network. 29 new tests across three files. Each acceptance criterion was mutation-checked: reverting the coercion fix, the isPrivate threading, the private-redaction branch, or the remote-host gate each turns the suite red. Closes #325 Co-authored-by: Claude <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What and why
#320 stage 2 shipped three privacy guarantees for Langfuse tracing with zero test
coverage. This adds the coverage the issue asked for — and writing it found that
two of the three guarantees did not actually hold.
That is the issue's own thesis proving itself: green healthchecks proved nothing
about stage 1's behaviour, and untested gating was the same trap. Trace input and
output are prompts and completions, i.e. memory text, so these are §2.2 holes.
The two holes (both fixed here)
1 ·
LANGFUSE_ALLOW_REMOTE_PAYLOADS=falsemeant trueThe flag was
z.coerce.boolean(), which isBoolean(value). An env var is always astring, so every non-empty value is truthy:
An operator writing
LANGFUSE_ALLOW_REMOTE_PAYLOADS=false— the most natural way toexpress "keep memory text on-box" — would have enabled full payloads to a remote
Langfuse. The flag is the §2.2 escape hatch; inverting it silently is the whole of
failure mode M9.
Replaced with a strict
envBooleanaccepting only1/true/yes/onand0/false/no/off/"", and hard-failing on anything else. Failing closed and loud atboot beats a security gate whose state nobody can predict.
Not currently triggered on this machine (base URL is loopback and the flag is
unset), but it was one
.envedit away.2 · The
isPrivateredaction was unreachablesafePayloadredactsisPrivatecontent unconditionally — the module's strongestrule. But
CompleteArgshad noisPrivatefield andtraceGenerationnever passedone, so no caller could ever reach that branch. The guarantee was a comment.
Threaded the flag through
CompleteArgs → traceGeneration → safePayload.Worth being precise about the live risk: recall does not leak today, because
memory.service.tsfilters private memories out ofcontextsupstream(
publicHits) before the router sees them. The fence held by accident of ordering,one layer up. Any future routed call that handles private content would have had no
working fence at all — now it does, and
isPrivate: trueis the documented way toask for it.
Plus a hermeticity gap (AC5)
vitest.config.tspins every AI provider to its fake so the suite stays offline —but not Langfuse, which keys off the presence of two env vars. On any machine with
Langfuse keys in
.env(including this one),pnpm testbuilt a real client andemitted traces of test data into it. Both keys are now blanked under test.
Design note
The gating functions now take an injectable
TracingConfigdefaulting to env,mirroring
resolve(class, keys)in the model router — the repo's existing posture ofinjection over mocking. Production call sites are unchanged; the suite drives every
host/flag/private combination without touching global env or the network.
setLangfuseClient()is a test seam shaped exactly like the router's existingsetBudgetTracker(). It is needed because tracing is correctly off in tests, sowithout it there is no way to assert what would have been sent.
Acceptance criteria
safePayloadstops redactingisPrivatecontent.Mutation-checked: deleting the
isPrivatebranch turns 5 tests red across two files. Covered on loopback, on a remote host, and with the remote opt-in set — the redaction must win in all three.Mutation-checked: making
payloadsAllowedreturntrueturns 5 tests red. RevertingenvBooleantoz.coerce.boolean()turns 4 more red. Also covers thelocalhost.evil.comnear-miss and an unparseable URL (both must fail closed).langfuse-trace.test.tsinjects a spy client, drives a realcomplete()withisPrivate: true, and asserts the private string appears nowhere in anything handed to it. It also asserts the spy was called and that non-private content does get through — so the assertion can't pass vacuously. Mutation-checked: removing the threading turns 2 tests red.Spy stubbed to throw on
trace():complete()still resolves with a non-empty answer, and the error is logged (langfuse.emit-failed) not propagated. Also covered for the no-client case.Required the
vitest.config.tsfix above.isTracingEnabled()andgetLangfuse()are asserted against the real process env inside the suite, so the harness itself is under test — that assertion fails if someone unblanks the keys.Verification summary
29 new tests, all written red-first. Every AC was mutation-checked rather than
just asserted — for each guarantee I reverted the code and confirmed the suite goes
red, then restored it. A test that cannot fail proves nothing, which is precisely the
gap this issue existed to close.
Scope note
The issue was framed as test-only ("the code exists; nothing proves it works"). Two
of its ACs could not be satisfied without the fixes above — a spy test asserting
private text never reaches Langfuse would have failed, and one asserting the remote
gate would have passed for the wrong reason. Fixing them is ~20 lines and squarely
the point of a
type: securityticket, so I did rather than filing a follow-up.Flagging it because it makes this a test + fix PR rather than pure coverage.
Invariant check
isPrivateis optional and defaults to undefined, so every existingcomplete()caller is unaffected (871 API tests pass unchanged).
keys: {}forces the fake provider; the Langfuse client is alocal spy; no
new Date()around time-dependent logic.Closes #325
Co-authored-by: Claude noreply@anthropic.com