Compare commits

...

2 Commits

Author SHA1 Message Date
diegosouzapw
7f71c4cc63 feat(video): substring-redact transcript in derived-prompt dispatch logs (#12430 item 4)
Extend applyVideoBridgeLogRedaction with a string-content branch:
pipeline-strategy stages, smart-auto-pipeline, and context-handoff
summaries embed the transcript as a substring of a rendered prompt
string rather than an exact array part, so the existing exact
part-array match silently skipped them. Adds a mutually-exclusive
string branch (Array.isArray vs typeof === "string") that does a
replaceAll of the trusted fullText literal against a lazily cloned
message, reusing the existing rootClone/clonedContainers/clonedMessages
clone-on-write pattern so siblings keep original references and the
input is never mutated.
2026-09-03 09:41:12 -03:00
diegosouzapw
5fc9e37d22 feat(video): redact transcript fields in the in-memory pending-request snapshot (#12430 item 6)
trackPendingRequest (open-sse/handlers/chatCore.ts) stored the raw client
body (with video transcript/audioTranscript cues) under `clientRequest`,
live-exposed via /api/usage/call-logs (pendingDetails), /api/logs/[id] and
/api/conversations while a request is in-flight. P2a redacted the persisted
detailed-log snapshot but not this in-memory copy.

Add redactPendingBody() to videoBridgeSnapshotRedaction.ts (sibling to
logClientRawRequestRedacted from P2a): when videoBridgeObserved, returns the
redacted clone from redactVideoTranscriptFieldsForLog; otherwise returns the
exact same reference. Wire it into the trackPendingRequest call site
(chatCore.ts:934), keeping the file within its frozen 5976-line budget
(5971 -> 5974).
2026-09-03 08:26:26 -03:00
5 changed files with 312 additions and 3 deletions

View File

@@ -360,7 +360,10 @@ import { deleteSessionAccountAffinity } from "@/lib/db/sessionAccountAffinity";
import { getCacheControlSettings } from "@/lib/cacheControlSettings";
import { guardrailRegistry } from "@/lib/guardrails";
import type { VideoBridgeLogRedactionEntry } from "@/lib/guardrails/videoBridge";
import { logClientRawRequestRedacted } from "@/lib/guardrails/videoBridgeSnapshotRedaction";
import {
logClientRawRequestRedacted,
redactPendingBody,
} from "@/lib/guardrails/videoBridgeSnapshotRedaction";
import {
shouldPreserveCacheControl,
resolveConnectionCacheOverride,
@@ -928,7 +931,7 @@ export async function handleChatCore({
const pendingRequestId =
trackPendingRequest(model, provider, pendingConnId, true, {
clientEndpoint: clientRawRequest?.endpoint || "/v1/chat/completions",
clientRequest: clientRawRequest?.body ?? body,
clientRequest: redactPendingBody(clientRawRequest?.body ?? body, videoBridgeObserved),
providerRequest: initialProviderRequest,
stage: "registered",
correlationId,

View File

@@ -50,6 +50,16 @@ import { attachLogMeta } from "./cacheUsageMeta.ts";
* never touches a part whose text differs — see
* `tests/unit/video-bridge-log-redaction.test.ts`'s "Scenario A" test for the
* reproduction this fixes.
*
* #12430 item 4 (P2c): a message's `content` can also be a plain STRING that
* embeds `fullText` as a SUBSTRING rather than an exact array part — derived
* dispatches (pipeline-strategy stages, smart-auto-pipeline, context-handoff
* summaries) all interpolate the transcript blob into a larger rendered
* prompt string before calling `handleSingleModel`. That string branch is
* mutually exclusive with the array branch (a message's `content` is one or
* the other, never both) and uses `String.prototype.replaceAll` against the
* trusted `fullText` literal to swap every occurrence — see
* `tests/unit/video-bridge-derived-prompt-redaction.test.ts`.
*/
export function applyVideoBridgeLogRedaction(
body: unknown,
@@ -78,6 +88,47 @@ export function applyVideoBridgeLogRedaction(
const originalMessage = originalContainer[messageIndex];
if (!originalMessage || typeof originalMessage !== "object") continue;
const originalContent = (originalMessage as Record<string, unknown>).content;
// Derived-prompt dispatches (pipeline-strategy stages, smart-auto-pipeline,
// context-handoff summaries — #12430 item 4) embed the transcript as a
// SUBSTRING of a plain string `content`, e.g. a rendered stage prompt or a
// `{HISTORY}`-interpolated handoff summary, never as an exact array part.
// Mutually exclusive with the array branch below: a message's `content`
// is either a string or an array, never both, so this and the
// `Array.isArray` check never both match the same message.
if (typeof originalContent === "string") {
if (!originalContent.includes(fullText)) continue;
// Same lazy clone-on-write as the array branch: root -> container
// array -> this message. Siblings keep referencing the originals.
if (!rootClone) rootClone = { ...source };
let containerClone = clonedContainers.get(container);
if (!containerClone) {
containerClone = [...originalContainer];
clonedContainers.set(container, containerClone);
rootClone[container] = containerClone;
}
const messageKey = `${container}:${messageIndex}`;
let messageClone = clonedMessages.get(messageKey);
if (!messageClone) {
messageClone = { ...(originalMessage as Record<string, unknown>) };
clonedMessages.set(messageKey, messageClone);
containerClone[messageIndex] = messageClone;
}
// Re-read from the (possibly already-cloned) message so a second
// redaction entry matching the same string content composes with the
// first instead of clobbering it. `fullText` is a trusted literal
// (the `[Video description:...]` blob), so replaceAll(string, string)
// needs no regex and is safe. replaceAll (not replace): a stage/summary
// prompt can quote the transcript back more than once.
const currentText =
typeof messageClone.content === "string" ? messageClone.content : originalContent;
messageClone.content = currentText.replaceAll(fullText, redactedText);
redacted = true;
continue;
}
if (!Array.isArray(originalContent)) continue;
for (let partIndex = 0; partIndex < originalContent.length; partIndex++) {

View File

@@ -133,3 +133,17 @@ export function logClientRawRequestRedacted(
clientRawRequest.headers
);
}
/**
* Call-site wrapper for the `clientRequest` field stored by `trackPendingRequest`
* (open-sse/handlers/chatCore.ts): the sibling in-memory leak to
* `logClientRawRequestRedacted` above — same raw body, but live-exposed via
* /api/usage/call-logs (pendingDetails), /api/logs/[id] and /api/conversations
* while the request is in-flight, not just in the persisted detailed-log
* snapshot. Identical observed/non-observed branching: a non-observed request
* keeps the exact same reference (no clone); an observed one gets the redacted
* clone.
*/
export function redactPendingBody(clientRequest: unknown, videoBridgeObserved: boolean): unknown {
return videoBridgeObserved ? redactVideoTranscriptFieldsForLog(clientRequest) : clientRequest;
}

View File

@@ -7,7 +7,10 @@
import assert from "node:assert/strict";
import test from "node:test";
import { redactVideoTranscriptFieldsForLog } from "../../../src/lib/guardrails/videoBridgeSnapshotRedaction.ts";
import {
redactVideoTranscriptFieldsForLog,
redactPendingBody,
} from "../../../src/lib/guardrails/videoBridgeSnapshotRedaction.ts";
// Heavy import is fine here (test only, never in the production module under test) — used
// solely to prove the local placeholder literal never drifts from the canonical P1 constant.
import { VIDEO_TRANSCRIPT_REDACTION_PLACEHOLDER } from "../../../src/lib/guardrails/videoBridgeHelpers.ts";
@@ -205,3 +208,57 @@ test("the redaction placeholder matches the canonical P1 constant (no drift)", (
const part = contentAt(result, "messages", 0)[0];
assert.equal(part.transcript, VIDEO_TRANSCRIPT_REDACTION_PLACEHOLDER);
});
// #12430 item 6 (P2c): the sibling in-memory leak. `trackPendingRequest`
// (open-sse/handlers/chatCore.ts) stores the same raw client body under
// `clientRequest`, live-exposed via /api/usage/call-logs (pendingDetails),
// /api/logs/[id] and /api/conversations while the request is in-flight. This
// helper is the guarded call-site wrapper chatCore.ts uses, mirroring
// logClientRawRequestRedacted's observed/non-observed branching.
test("redactPendingBody: observed=true delegates to redactVideoTranscriptFieldsForLog", () => {
const body = {
messages: [
{
role: "user",
content: [
{
type: "input_video",
video_url: "https://example.com/clip.mp4",
transcript: { cues: [{ text: "pending secret" }] },
},
],
},
],
};
const result = redactPendingBody(body, true);
assert.notEqual(
result,
body,
"observed path must return a new structure, not the same reference"
);
const part = contentAt(result, "messages", 0)[0];
assert.equal(part.transcript, VIDEO_TRANSCRIPT_REDACTION_PLACEHOLDER);
assert.ok(!JSON.stringify(result).includes("pending secret"));
assert.deepEqual(result, redactVideoTranscriptFieldsForLog(body));
});
test("redactPendingBody: observed=false returns the SAME reference unchanged", () => {
const body = {
messages: [
{
role: "user",
content: [
{
type: "input_video",
video_url: "https://example.com/clip.mp4",
transcript: { cues: [{ text: "not observed" }] },
},
],
},
],
};
const result = redactPendingBody(body, false);
assert.equal(result, body, "non-observed path must return the exact same reference");
});

View File

@@ -0,0 +1,184 @@
// tests/unit/video-bridge-derived-prompt-redaction.test.ts
// P2c of #12150/#12430 (Video Bridge transcript retention — derived-prompt
// dispatch logs, item 4).
//
// Seam trace finding (decisive): videoBridgeLog is ALREADY threaded end-to-end
// to every nested handleChatCore — pipeline-strategy stages
// (src/domain/pipeline.ts::executeStage), smart-auto-pipeline, and
// context-handoff summaries (open-sse/services/contextHandoff.ts) — because
// all of them dispatch through the single P1b `handleSingleModel` closure and
// terminate in the SAME handleChatCore -> persistAttemptLogs ->
// applyVideoBridgeLogRedaction logging path. No plumbing/param changes were
// needed anywhere.
//
// The gap this file proves closed: those derived dispatches embed the
// transcript as a SUBSTRING of a plain STRING `content` message —
// `{ role: "user", content: <rendered prompt string containing the
// "[Video description: ...]" blob> }` — built by executeStage()
// (pipeline.ts:196-199 via prompts.ts interpolation) and by the
// context-handoff summary builders (contextHandoff.ts:415/729, `{HISTORY}`
// template substitution). Before this fix, applyVideoBridgeLogRedaction only
// matched ARRAY-content parts by exact text (`part.text === fullText`), so it
// silently skipped these string-content messages and the raw transcript
// persisted in the stage/summary sub-request call logs.
//
// This suite calls the real, already-exported `applyVideoBridgeLogRedaction`
// (open-sse/handlers/chatCore/attemptLogging.ts) directly — it is a pure
// function (no DB), so no persistAttemptLogs/DB harness is needed here; that
// integration-level proof already lives in
// tests/unit/video-bridge-log-redaction.test.ts.
import { test } from "node:test";
import assert from "node:assert/strict";
import { applyVideoBridgeLogRedaction } from "../../open-sse/handlers/chatCore/attemptLogging.ts";
import type { VideoBridgeLogRedactionEntry } from "../../src/lib/guardrails/videoBridge.ts";
const SECRET = "secret words";
const FULL_TEXT = `[Video description: transcript[source=client] ${SECRET}]`;
const REDACTED_TEXT = "[Video description: transcript[source=client] [redacted-video-transcript]]";
function entry(
overrides: Partial<VideoBridgeLogRedactionEntry> = {}
): VideoBridgeLogRedactionEntry {
return {
container: "messages",
messageIndex: 0,
partIndex: 0,
fullText: FULL_TEXT,
redactedText: REDACTED_TEXT,
...overrides,
};
}
test("derived-prompt (pipeline stage): a string-content message with the transcript embedded as a substring is redacted, secret absent, surrounding prompt text intact", () => {
const body = {
model: "openai/gpt-x",
messages: [
{ role: "system", content: "You are a summarization stage." },
{
role: "user",
content: `Summarize the following context.\n\n${FULL_TEXT}\n\nEnd of context.`,
},
],
};
const result = applyVideoBridgeLogRedaction(body, [
entry({ messageIndex: 1, partIndex: 0 }),
]) as typeof body;
const redactedContent = result.messages[1].content;
assert.equal(
redactedContent,
`Summarize the following context.\n\n${REDACTED_TEXT}\n\nEnd of context.`
);
assert.ok(!redactedContent.includes(SECRET), "the raw transcript must not survive redaction");
assert.ok(
redactedContent.startsWith("Summarize the following context.\n\n"),
"surrounding prompt text before the blob must stay intact"
);
assert.ok(
redactedContent.endsWith("\n\nEnd of context."),
"surrounding prompt text after the blob must stay intact"
);
assert.equal(JSON.stringify(result).includes(SECRET), false);
});
test("derived-prompt (context-handoff summary): input container string content is redacted the same way as messages", () => {
const body = {
model: "openai/gpt-x",
input: [
{
role: "user",
content: `Continue the conversation given this history.\n\n${FULL_TEXT}\n\nContinue now.`,
},
],
};
const result = applyVideoBridgeLogRedaction(body, [
entry({ container: "input", messageIndex: 0, partIndex: 0 }),
]) as typeof body;
const redactedContent = result.input[0].content;
assert.equal(
redactedContent,
`Continue the conversation given this history.\n\n${REDACTED_TEXT}\n\nContinue now.`
);
assert.ok(!redactedContent.includes(SECRET));
assert.equal(JSON.stringify(result).includes(SECRET), false);
});
test("multiple occurrences of fullText within the same string are ALL replaced (replaceAll, not replace)", () => {
const body = {
messages: [
{
role: "user",
content: `First mention: ${FULL_TEXT}\n\nQuoted back for grounding: ${FULL_TEXT}\n\nDone.`,
},
],
};
const result = applyVideoBridgeLogRedaction(body, [entry({ messageIndex: 0, partIndex: 0 })]) as {
messages: Array<{ content: string }>;
};
const redactedContent = result.messages[0].content;
assert.equal(
redactedContent,
`First mention: ${REDACTED_TEXT}\n\nQuoted back for grounding: ${REDACTED_TEXT}\n\nDone.`
);
assert.equal(
redactedContent.split(REDACTED_TEXT).length - 1,
2,
"both occurrences must be replaced"
);
assert.ok(!redactedContent.includes(SECRET));
});
test("regression: the existing ARRAY-content exact-part-match path still redacts (no regression from the new string branch)", () => {
const body = {
messages: [
{ role: "system", content: "sys" },
{
role: "user",
content: [
{ type: "text", text: "look at this video" },
{ type: "text", text: FULL_TEXT },
],
},
],
};
const result = applyVideoBridgeLogRedaction(body, [entry({ messageIndex: 1, partIndex: 1 })]) as {
messages: Array<{ content: unknown }>;
};
const content = result.messages[1].content as Array<{ text: string }>;
assert.equal(content[1].text, REDACTED_TEXT);
assert.ok(!content[1].text.includes(SECRET));
assert.equal(content[0].text, "look at this video", "sibling part must stay untouched");
});
test("no mutation of the input object: the caller's body is byte-identical after redaction (string-content path)", () => {
const body = {
messages: [{ role: "user", content: `before ${FULL_TEXT} after` }],
};
const snapshotBefore = JSON.parse(JSON.stringify(body));
applyVideoBridgeLogRedaction(body, [entry({ messageIndex: 0, partIndex: 0 })]);
assert.deepEqual(body, snapshotBefore, "the original body must never be mutated");
});
test("non-matching string content is returned unchanged, with the SAME root reference (nothing redacted -> no clone allocated)", () => {
const body = {
messages: [{ role: "user", content: "nothing to see here, no transcript blob at all" }],
};
const result = applyVideoBridgeLogRedaction(body, [entry({ messageIndex: 0, partIndex: 0 })]);
assert.equal(
result,
body,
"when no fullText matches, the exact same object reference is returned"
);
});