Skip to content

Commit 5dd18c3

Browse files
author
RealDiligent
committed
fix(visual): filter a vision finding's model-authored path through toPublicSafe
VisualVisionFinding's JSDoc says both path and body are already public-safe, but parseVisualVisionResponse only ran body through toPublicSafe — path got a bare .trim(). path is model-authored free text off the vision response JSON, interpolated verbatim into a public finding title ("Possible visual regression: <path>"), and visual-followup's renderer trusts the JSDoc and does not re-scrub. So injected markdown/@mentions in a model path reached a public comment live. Run path through toPublicSafe exactly as body is (drop the entry when it returns null), and bound it with a new MAX_VISUAL_PATH_CHARS constant so one finding's title cannot flood a comment. An ordinary route path survives toPublicSafe byte-identically, so findVisualEvidence's route.path === path lookup still matches (pinned by a test). body's filtering, MAX_VISUAL_FINDINGS, the category parse, and the followup re-scrub are unchanged. Closes #10062
1 parent a7673e2 commit 5dd18c3

3 files changed

Lines changed: 43 additions & 3 deletions

File tree

src/review/visual/visual-findings.ts

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -134,6 +134,10 @@ export type VisualVisionFinding = { path: string; body: string; category?: "regr
134134
/** Cap on findings kept from a single vision response — mirrors `composeAdvisoryNotes`'s selectivity so a
135135
* verbose model can't pad the comment with a long list of minor observations. */
136136
const MAX_VISUAL_FINDINGS = 3;
137+
/** Upper bound on a model-authored finding `path` after public-safe filtering, so one finding's title cannot
138+
* flood a public comment. A real route path is far shorter; a value past this is truncated for display and
139+
* simply won't match any captured route in findVisualEvidence (#10062). */
140+
const MAX_VISUAL_PATH_CHARS = 256;
137141

138142
export const VISUAL_VISION_SYSTEM_PROMPT = [
139143
"You are reviewing a BEFORE (production) vs AFTER (this pull request's preview deploy) screenshot pair for the same route.",
@@ -217,10 +221,14 @@ export function parseVisualVisionResponse(text: string): VisualVisionFinding[] {
217221
if (out.length >= MAX_VISUAL_FINDINGS) break;
218222
if (!entry || typeof entry !== "object") continue;
219223
const record = entry as Record<string, unknown>;
220-
const path = typeof record.path === "string" ? record.path.trim() : "";
224+
// path is model-authored free text off the vision JSON, interpolated into a public finding title — filter
225+
// it through toPublicSafe exactly as body is, dropping the entry when it returns null (#10062).
226+
const rawPath = typeof record.path === "string" ? record.path : "";
227+
const filteredPath = toPublicSafe(rawPath);
221228
const rawBody = typeof record.body === "string" ? record.body : "";
222229
const body = toPublicSafe(rawBody);
223-
if (!path || !body) continue;
230+
if (!filteredPath || !body) continue;
231+
const path = filteredPath.slice(0, MAX_VISUAL_PATH_CHARS);
224232
const category = record.category === "regression" || record.category === "unrelated" ? record.category : undefined;
225233
out.push(category ? { path, body, category } : { path, body });
226234
}

test/unit/visual-findings.test.ts

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -200,6 +200,28 @@ describe("parseVisualVisionResponse", () => {
200200
expect(parseVisualVisionResponse(text)).toEqual([]);
201201
});
202202

203+
it("REGRESSION (#10062): filters public-unsafe markup out of a model-authored path", () => {
204+
const text = JSON.stringify({ findings: [{ path: "/app [pwn](http://evil.example) @maintainer", body: "Broke." }] });
205+
const [finding] = parseVisualVisionResponse(text);
206+
expect(finding).toBeDefined();
207+
// The live link-injection markup no longer survives into the public finding path.
208+
expect(finding!.path).not.toContain("](http://evil.example)");
209+
expect(finding!.path).not.toContain("[pwn]");
210+
});
211+
212+
it("REGRESSION (#10062): bounds a path to the MAX_VISUAL_PATH_CHARS constant", () => {
213+
const text = JSON.stringify({ findings: [{ path: `/${"a".repeat(500)}`, body: "Broke." }] });
214+
const [finding] = parseVisualVisionResponse(text);
215+
expect(finding!.path.length).toBe(256);
216+
});
217+
218+
it("REGRESSION (#10062): an ordinary route path round-trips byte-identically and still attaches visualEvidence", () => {
219+
const findings = parseVisualVisionResponse(JSON.stringify({ findings: [{ path: "/app", body: "Broke." }] }));
220+
expect(findings).toEqual([{ path: "/app", body: "Broke." }]); // survives toPublicSafe unchanged
221+
const built = buildVisualRegressionFindings(findings, [changedRoute("/app")]);
222+
expect(built[0]?.visualEvidence?.path).toBe("/app"); // route lookup still matches on the scrubbed path
223+
});
224+
203225
it("drops an entry with a blank/empty body (fails toPublicSafe's emptiness guard)", () => {
204226
const text = JSON.stringify({ findings: [{ path: "/pricing", body: "" }] });
205227
expect(parseVisualVisionResponse(text)).toEqual([]);

test/unit/visual-followup.test.ts

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import {
55
selectUnrelatedVisualFindings,
66
} from "../../src/review/visual/visual-followup";
77
import { VISUAL_FOLLOWUP_COMMENT_MARKER } from "../../src/github/comments";
8-
import { VISUAL_REGRESSION_FINDING_CODE, VISUAL_UNRELATED_ISSUE_FINDING_CODE } from "../../src/review/visual/visual-findings";
8+
import { buildVisualRegressionFindings, parseVisualVisionResponse, VISUAL_REGRESSION_FINDING_CODE, VISUAL_UNRELATED_ISSUE_FINDING_CODE } from "../../src/review/visual/visual-findings";
99
import type { AdvisoryFinding } from "../../src/types";
1010

1111
const unrelatedFinding = (overrides: Partial<AdvisoryFinding> = {}): AdvisoryFinding => ({
@@ -135,4 +135,14 @@ describe("buildVisualFollowupComment", () => {
135135
expect(body).not.toContain("Possible visual regression");
136136
expect(body).toContain("Possible unrelated visual issue");
137137
});
138+
139+
it("REGRESSION (#10062): a hostile model path is scrubbed at parse time and never reaches the follow-up body as live markup", () => {
140+
const hostile = JSON.stringify({
141+
findings: [{ path: "/app [pwn](http://evil.example) @maintainer", body: "Layout looks off, unrelated to this change.", category: "unrelated" }],
142+
});
143+
const built = buildVisualRegressionFindings(parseVisualVisionResponse(hostile));
144+
const body = buildVisualFollowupComment(built, ["jsonbored"]);
145+
expect(body).not.toBeNull();
146+
expect(body!).not.toContain("](http://evil.example)"); // the injected link never renders live
147+
});
138148
});

0 commit comments

Comments
 (0)