Repository navigation
feat(ai-research): move the v4 image ref grammar into the anonymizer - #126496
Conversation
…mizer The v4 recorder wrote its image references and decline markers from hand-written format strings, and MLHog's v4 text scrub matched them with its own regex. The two copies drifted once already. posthog-replay-anonymizer gets a v4_image_refs module with the prefixes, the format functions, every decline marker and a strict parse. replay-core-v4 and replay-recorder-v4 now write through it, and the recorder's URL decline labels come from the anonymizer's Decline. The recorder tests now assert the literal marker strings, so they pin the bytes the recorder writes. Bumps the crate to 0.13.0. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
😎 Merged successfully - details. |
🤖 CI report✅ Trunk lane — non-backend laneThis PR is assigned to the non-backend lane. It does not run backend Python tests and may merge in parallel with PRs in other lanes. 🚨 Comment density — 6% of added code lines are comments (28 of 453)This section warns when comments are more than 3% of the code lines a PR adds, and alerts above 6%. Before agent-assisted PRs, the typical share was about 2%. Only full-line comments count. Docstrings, generated files, snapshots, migrations, and workflow files are left out. Comments that restate the code, record how the change came about, or narrate the next line add noise for the next reader. Keep the comments that explain a reason the code cannot show, and remove the rest. See Files with the most added comment lines:
This check does not block merging. It updates on every push and clears when the share drops. |
Drops doc comments that restate their item, and moves the warning that a Decline label also ends a v4 URL marker to where the labels live. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
HostHog preview —
|
|
Risk: High · 1 high, 1 medium This increment is a merge of origin/master that rewrites the recorder's inline-image pipeline: Sentinel reviewed |
…mizer-v4-refs-c4fad1 Master's CSS rewrite added two calls to the recorder's declined_marker, which this branch replaced with v4_image_refs::url_declined_marker. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
| let (replacement, declined) = match url_reference(url, team_id, key_month, &url_key) { | ||
| Ok((_, reference)) => (reference, None), | ||
| Err(decline) => (declined_marker(decline.label()), Some(decline.label())), | ||
| Err(decline) => (url_declined_marker(decline.into()), Some(decline.label())), |
There was a problem hiding this comment.
A signed URL written in CSS with hex or partial escapes is stored in a block that passes as safe
The bytes of a URL the policy declines — a signature or credential — can stay in a stored AI-training block. The grammar change on this line is byte-identical and not itself the cause; the gap is in the escaped-URL scrub this branch now carries (rewrite.rs:153-156, 659): it recognizes a declined URL in only two spellings, plain and the exact CSSOM escape form escape_in_identifier produces (css.rs:168). Any other valid CSS spelling survives, while a plain copy elsewhere in the same CSS marks the target found and the block passes the safety check, so the fallback host scrub that would decode those escapes (scrub.rs unit()) never runs.
How:
- A recorded page sends
replaceSynctext holding.a{background:url(https://cdn.example.com/s.png?X-Amz-Signature=abc123)}plus a selector spelling the same URL with hex (\3a\2f\2f) or partial escapes (only./?/=escaped). - The inspector collects the plain copy (
collectable, css.rs:357) and the policy declines it (x-amz-signature, url_policy.rs:56), so this line builds a declined target. replace_urlsreplaces the plain copy, settingfound=true; the escaped copy matches neither the plain pattern nor the escaped pattern (rewrite.rs:155), and theholds_escaped_urlgate (rewrite.rs:659) never fires for those spellings — verified byte-level.- Both
declined_keptscans use the same two spellings (rewrite.rs:688-694, 711-713), sofinish()reports safe (rewrite.rs:737) and the signature bytes are stored for training.
Fix: Detect declined URLs by decoding JSON and CSS escapes the way scrub.rs's unit() already does, instead of matching precomputed escape spellings.
React with 👍 if useful or 👎 if not
Master brought #126379, which added the image markers over_limit, undecodable and unsupported to replay-core-v4 as its own Declined enum. Those markers now live in v4_image_refs::ImageDecline with their metric labels and the marker prefix, and replay-core-v4 and the recorder use them from there, so parse accepts every marker the recorder writes. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
| @@ -1,5 +1,6 @@ | |||
| //! Moves inline images out of a v4 block. Each raster data URI becomes an `image:v4` reference, and its decoded bytes | |||
| //! go to the flush's image pack. An image the image scrub cannot read, or one over a cap, becomes a [`Declined`] marker. | |||
| //! go to the flush's image pack. An image the image scrub cannot read, or one over a cap, becomes an [`ImageDecline`] | |||
There was a problem hiding this comment.
A data URI whose prefix is CSS-escaped keeps its raw image bytes in the AI-training block
The extractor's contract, stated on this changed line and in products/ai_training/docs/replay-data.md:465 ("An inline image that the recorder does not store becomes a marker ... its bytes are stored nowhere"), is that no non-SVG image bytes stay in a stored block. A CSS escape breaks it: canonical_strings leaves \\61 (CSS \\61 = a) untouched, and extract_images matches data: byte for byte (rust/ai-research/replay-core-v4/src/scan/images.rs:448), so a URI written d\\61ta:image/png;base64,... inside a CSS url() is never found. Its base64 bytes stay inline in the sealed training block, bypassing the scrub lane that blanks images it cannot take, so unblanked image content (e.g. a PII-bearing canvas or screenshot) reaches the training dataset. A >64-byte meta holding whitespace hides it the same way (read_meta → Meta::NoPayload → NotImage).
How:
- A recorded page renders an inline image via CSS that escapes a prefix letter:
background:url(d\\61ta:image/png;base64,<bytes>). extract_event_imagescanonicalizes JSON escapes only; the CSS escape survives (rust/ai-research/replay-core-v4/src/scan/cv.rs:424).extract_imagesscans for literaldatabefore a colon and misses the URI (images.rs:446-450), so it is neither moved to the image pack nor declined.- The bytes are sealed into the training block verbatim; no URL target or unsafe flag exists, so no fallback scrub runs.
Fix: decode CSS escapes (or decline as undecodable) when the scan meets a data prefix written with them, as wrapped_end already does for payloads.
React with 👍 if useful or 👎 if not
Problem
imageurl:v4:declined:*reasons, and the scrub redacted those markers.Changes
posthog-replay-anonymizer0.13.0 adds av4_image_refsmodule: the prefixes, the format functions, every decline marker, and one strictparse.over_limit,undecodableandunsupported.ImageDeclinereplaces that PR'sDeclinedenum inreplay-core-v4.parsesays whether a string is a reference, which names image bytes, or a marker, which names none. It rejects near misses, such as a leading zero, month 13 or a trailing byte.replay-core-v4andreplay-recorder-v4write through the module, by path withdefault-features = false. The bytes they write do not change.Decline. A new recorder reason does not compile until the shared grammar has it.Follow-up:
replay-core-v4/src/url_policy.rsis a near-copy of the anonymizer'surl_policy.rs. A later PR can move the recorder onto the anonymizer's copy.Before:
flowchart LR core["replay-core-v4<br/>image_ref, AVIF_MARKER"] --> rec["replay-recorder-v4<br/>declined_marker, UNCHECKED_MARKER"] rec --> block[("v4 block")] block --> scrub{{"MLHog v4 text scrub<br/>hand-written regex"}} classDef phBlue fill:#1d4aff,stroke:#1d4aff,color:#fff; classDef phGray fill:#e5e7eb,stroke:#c7ccd1,color:#000; classDef phRed fill:#f54e00,stroke:#f54e00,color:#fff; class core,rec phBlue; class block phGray; class scrub phRed;After:
flowchart LR anon["posthog-replay-anonymizer<br/>v4_image_refs"] --> core["replay-core-v4"] anon --> rec["replay-recorder-v4"] core --> rec rec --> block[("v4 block")] block --> scrub{{"MLHog v4 text scrub"}} anon -. "parse, after the MLHog pin bump" .-> scrub classDef phBlue fill:#1d4aff,stroke:#1d4aff,color:#fff; classDef phGray fill:#e5e7eb,stroke:#c7ccd1,color:#000; classDef phRed fill:#f54e00,stroke:#f54e00,color:#fff; classDef phYellow fill:#f9bd2b,stroke:#f9bd2b,color:#000; class core,rec phBlue; class block phGray; class scrub phRed; class anon phYellow;How did you test this code?
cargo testfor the anonymizer,replay-core-v4andreplay-recorder-v4, on master and on this branch. Both pass.v4_vector.rsstill checks both reference formats against the Node-writtenencryption-vector-v4.json.cargo clippy -D warningsacross the workspace. The local clippy 1.96 flags untouched files, such assnapshot.rsandlifecycle. The touched files have no findings.Test rationale: No test covered parsing, because no parse existed.
every_string_the_recorder_writes_round_tripscatches a marker thatparserejects, which is the drift above. It also fails when a decline variant has no literal vector.rejects_near_missescatches a looserparse.parseeach failed one of the two tests: a leading zero, month 13, a droppedunchecked, a reason missing fromDecline::ALL, and a reason under the wrong prefix.Release status
Docs update
products/ai_training/docs/replay-data.mdnames the module as the definition of the reference and marker shapes.🤖 Agent context
Autonomy: Human-driven (agent-assisted)
Agent: Claude Code, Claude Opus 5.5 (
claude-opus-5-5)Skills:
/writing-code-comments,/writing-tests,/writing-pr-descriptions,/asd-ste100More context for AI agents
Decline, not a third reason enum. The coreDecline::labeldelegates to it.Displayimplementation, so the round trip covers both.ImageDecline.=0.4.8pin and replaces its regex withparse.url_policy.rs.parseinrust/replay-anonymizer/src/v4_image_refs.rs, andFrom<Decline>inrust/ai-research/replay-core-v4/src/url_policy.rs.🤖 Generated with Claude Code