fix(insights): strip lowercase chart tokens from AI prose

Marc reported a raw "metric: blood_pressure_sweet" string at the
bottom of /insights — looked like a debug code or unrendered
template. Root cause: the v1.4.16 strip regex
`/metric:[A-Z_]+/g` only matches uppercase letters and underscores,
but the model occasionally emits lowercase / snake_case forms like
`metric:blood_pressure` or `metric:weight_trend`. Those slipped
through `stripChartTokens()` unchanged and the literal token text
landed in the DOM.

Split the regex in two:
 - `STRIP_TOKEN_REGEX` (`[A-Za-z0-9_]+`) — permissive; cleaves any
   case from prose so unrenderable / hallucinated tokens never
   surface as text.
 - `PARSE_TOKEN_REGEX` (`[A-Z_]+`) — strict; the render path still
   only mounts charts for the uppercase allowlisted vocabulary.

Allowlist semantics are unchanged — `parseChartTokens()` still
returns only entries from `ALLOWED_CHART_TOKENS`. The change is
purely about defending the visible-prose surface from the wider
class of token shapes the LLM occasionally emits.

XSS-style trailing junk (`metric:WEIGHT' onclick='alert(1)'`)
still cleaves cleanly: the apostrophe terminates either class.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Marc-André BombeckandClaude Opus 4.7 committed 2026-05-10 13:14:53 +02:00
1 parent 335f288d69
commit fa91a73aef
1 file changed
+16 -7
+16 -7
View File
@@ -39,11 +39,20 @@ export type ChartToken = (typeof ALLOWED_CHART_TOKENS)[number];
const ALLOWED_SET = new Set<string>(ALLOWED_CHART_TOKENS);
// Greedy `[A-Z_]+` match. Uppercase letters and underscores only — anything
// else (apostrophes, spaces, lowercase, digits) terminates the token, so
// trailing junk like `metric:WEIGHT' onclick='alert(1)'` cleaves cleanly
// into the safe metric and an inert text remainder.
const TOKEN_REGEX = /metric:[A-Z_]+/g;
// v1.4.19 A3 — strip vs parse split. The strip-side character class
// is permissive (`[A-Za-z0-9_]+`) so model-emitted lowercase or
// snake_case tokens like `metric:blood_pressure_sweet_spot` (verbatim
// Marc 2026-05-10) get cleaved out of prose instead of leaking to the
// DOM. The parse-side class stays uppercase-only so unrenderable /
// hallucinated tokens are dropped from the *render* path entirely;
// only the *strip* is permissive.
//
// Trailing junk like `metric:WEIGHT' onclick='alert(1)'` still
// cleaves cleanly: the apostrophe terminates either character class,
// and the surviving uppercase token is the only one filtered through
// the allowlist for rendering.
const STRIP_TOKEN_REGEX = /metric:[A-Za-z0-9_]+/g;
const PARSE_TOKEN_REGEX = /metric:[A-Z_]+/g;
/**
* Find every well-formed chart token in `text` and return only those that
@@ -62,7 +71,7 @@ export function parseChartTokens(
text: string | null | undefined,
): ChartToken[] {
if (typeof text !== "string") return [];
const matches: string[] = text.match(TOKEN_REGEX) ?? [];
const matches: string[] = text.match(PARSE_TOKEN_REGEX) ?? [];
return matches.filter((m): m is ChartToken => ALLOWED_SET.has(m));
}
@@ -78,7 +87,7 @@ export function parseChartTokens(
export function stripChartTokens(text: string | null | undefined): string {
if (typeof text !== "string") return "";
return text
.replace(TOKEN_REGEX, "")
.replace(STRIP_TOKEN_REGEX, "")
.replace(/\s{2,}/g, " ")
.trim();
}