From 7b2c9b5548bbc0339bf1e14ce5f257514245df79 Mon Sep 17 00:00:00 2001 From: Diego Rodrigues de Sa e Souza Date: Sat, 5 Sep 2026 03:14:49 -0300 Subject: [PATCH] fix(sse): redact video transcript in pre-guardrail rejected-request logs (#12150 P2 item 7) (#12710) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Merged. Focused, correct, and tested. `recordRejectedRequestUsage` runs on the path where the request never reached the guardrail chain — circuit-breaker-open and combo-exhausted rejections — so the video-bridge guardrail never got the chance to rewrite the transcript, and the raw cues went straight into `call_logs`. Routing the body through `redactVideoTranscriptFieldsForLog` at the persistence boundary is the right place: a no-op clone for non-video bodies, structured field substitution for video ones, and not bypassable by cue content. The `requestBody == null ? requestBody : …` guard keeps the existing "no body available" case behaving exactly as before, which the neighbouring test still covers. Validated on `release/v3.8.51`: `tests/unit/rejected-request-usage.test.ts` green, including the new case asserting the secret cue text does not survive into the persisted detail and that the field reads `[redacted-video-transcript]`. `typecheck:core` and `lint` clean. --- src/sse/handlers/rejectedRequestUsage.ts | 8 ++- tests/unit/rejected-request-usage.test.ts | 62 +++++++++++++++++++++++ 2 files changed, 69 insertions(+), 1 deletion(-) diff --git a/src/sse/handlers/rejectedRequestUsage.ts b/src/sse/handlers/rejectedRequestUsage.ts index fdde178038..958ee5cbce 100644 --- a/src/sse/handlers/rejectedRequestUsage.ts +++ b/src/sse/handlers/rejectedRequestUsage.ts @@ -18,6 +18,7 @@ * never turn into a second failure on the response path. */ import { saveCallLog, saveRequestUsage } from "@/lib/usageDb"; +import { redactVideoTranscriptFieldsForLog } from "@/lib/guardrails/videoBridgeSnapshotRedaction"; export interface RejectedRequestUsageInput { status: number; @@ -82,7 +83,12 @@ export async function recordRejectedRequestUsage(input: RejectedRequestUsageInpu duration, tokens: {}, error: error || null, - requestBody, + // #12150 P2 item 7: this request was rejected BEFORE the guardrail chain ran + // (circuit-breaker-open / combo-exhausted), so the video-bridge guardrail + // never redacted the transcript. Redact defensively here — a no-op clone for + // any non-video body, structured field substitution (never bypassable by cue + // content) for a video one. See videoBridgeSnapshotRedaction.ts. + requestBody: requestBody == null ? requestBody : redactVideoTranscriptFieldsForLog(requestBody), comboName, comboStepId, comboExecutionKey, diff --git a/tests/unit/rejected-request-usage.test.ts b/tests/unit/rejected-request-usage.test.ts index 1783129e3a..f7d9cdba79 100644 --- a/tests/unit/rejected-request-usage.test.ts +++ b/tests/unit/rejected-request-usage.test.ts @@ -136,6 +136,68 @@ test("combo-exhausted rejection persists the client request body for dashboard i }); }); +// #12150 P2 item 7: recordRejectedRequestUsage persists the raw client body for +// a request rejected BEFORE the guardrail chain runs (circuit-breaker-open / +// combo-exhausted), so the video-bridge guardrail never got a chance to redact +// the transcript. The body is persisted defensively through +// redactVideoTranscriptFieldsForLog, so a rejected video request's stored log +// never retains the raw transcript cues. +test("#12150 P2 item 7: a rejected request's persisted body has its video transcript redacted", async () => { + const SECRET = "top secret cue text"; + await recordRejectedRequestUsage({ + status: 503, + model: "default", + requestedModel: "default", + provider: "-", + endpoint: "/v1/chat/completions", + error: "[503] Pipeline gate rejected", + apiKeyId: "key-video-reject", + apiKeyName: "video-reject-test", + correlationId: "corr-video-reject", + startTime: Date.now() - 10, + requestBody: { + model: "default", + messages: [ + { + role: "user", + content: [ + { type: "text", text: "look at this video" }, + { + type: "input_video", + video_url: "https://example.com/clip.mp4", + transcript: { cues: [{ text: SECRET, startSeconds: 0, endSeconds: 2 }] }, + }, + ], + }, + ], + }, + }); + + let rejected: { id: string } | undefined; + for (let i = 0; i < 50 && !rejected; i++) { + const logs = await callLogs.getCallLogs({}); + const list = (logs.logs ?? logs) as Array<{ apiKeyName?: string | null }>; + const found = (list ?? []).find((l) => l.apiKeyName === "video-reject-test"); + if (found) rejected = found as unknown as { id: string }; + else await new Promise((r) => setTimeout(r, 10)); + } + assert.ok(rejected, "expected a call_logs row for the rejected video request"); + + const detail = await callLogs.getCallLogById(rejected.id); + assert.ok(detail, "expected to load the call log detail"); + assert.equal( + JSON.stringify(detail!.requestBody).includes(SECRET), + false, + "the rejected request's persisted body must not retain the raw video transcript" + ); + const transcriptField = ( + detail!.requestBody as { + messages: Array<{ content: Array<{ transcript?: unknown }> }>; + } + ).messages[0].content[1].transcript; + assert.equal(transcriptField, "[redacted-video-transcript]"); +}); + test("combo-exhausted rejection without a request body still logs cleanly (no request body available)", async () => { await recordRejectedRequestUsage({ status: 503,