From 81a4945be8d6e841545d73c2ffde439e77210e44 Mon Sep 17 00:00:00 2001 From: nesquena-hermes Date: Sat, 20 Jun 2026 22:05:01 +0000 Subject: [PATCH] harden(#4411): read-side anchor-scene ref-ambiguity guard (Opus finding) Mirror the write-side _find_anchor_scene_message ambiguity guard on the read side: _hydrate_anchor_activity_scenes now counts ref occurrences and skips ref-based attachment for any ref resolving to >1 assistant message (falls through to the positional index match), so a duplicate content-digest ref can't double-render the same worklog scene. Low-severity defense-in-depth on the crown-jewel render surface. Co-authored-by: franksong2702 --- api/routes.py | 16 ++++++++++++- tests/test_anchor_scene_persistence.py | 33 ++++++++++++++++++++++++++ 2 files changed, 48 insertions(+), 1 deletion(-) diff --git a/api/routes.py b/api/routes.py index 2ec74bb8..fa3b1fe8 100644 --- a/api/routes.py +++ b/api/routes.py @@ -3490,11 +3490,25 @@ def _hydrate_anchor_activity_scenes(messages, records, *, message_offset=0, tool if idx is not None: by_index[idx] = record out = list(messages) + # Read-side ref-ambiguity guard (parity with the write-side + # _find_anchor_scene_message, which returns None when a ref matches >1 + # message). If two assistant messages ever share a ref (byte-identical + # whitespace-normalized content + identical _ts), attaching the same scene + # to both would render duplicate worklog groups. Count ref occurrences and + # fall through to the index-based match (which is positional, unambiguous) + # for any ref that resolves to more than one assistant message. + _ref_counts: dict[str, int] = {} + for _m in messages: + if isinstance(_m, dict) and _m.get("role") == "assistant": + _r = _assistant_anchor_scene_message_ref(_m) + if _r: + _ref_counts[_r] = _ref_counts.get(_r, 0) + 1 for local_idx, message in enumerate(messages): if not isinstance(message, dict) or message.get("role") != "assistant": continue absolute_idx = int(message_offset or 0) + local_idx - record = by_ref.get(_assistant_anchor_scene_message_ref(message)) + _msg_ref = _assistant_anchor_scene_message_ref(message) + record = by_ref.get(_msg_ref) if _ref_counts.get(_msg_ref, 0) <= 1 else None if not record: candidate = by_index.get(absolute_idx) if candidate and _anchor_scene_candidate_matches_scene(message, candidate.get("scene") or {}): diff --git a/tests/test_anchor_scene_persistence.py b/tests/test_anchor_scene_persistence.py index 56e8346d..552ec64c 100644 --- a/tests/test_anchor_scene_persistence.py +++ b/tests/test_anchor_scene_persistence.py @@ -187,6 +187,39 @@ def test_anchor_scene_persistence_rejects_cross_profile_write(tmp_path, monkeypa ) +def test_anchor_scene_hydration_skips_ambiguous_ref_match(monkeypatch): + """#4411 defense-in-depth: when two assistant messages share a ref (byte-identical + content + identical _ts), the read-side hydration must NOT attach the same scene to + both (which would render duplicate worklog groups) — mirroring the write-side + _find_anchor_scene_message ambiguity guard. The ambiguous ref falls through to the + positional index match instead.""" + from api import routes + + # Two assistant messages that normalize to the SAME ref. + messages = [ + {"role": "assistant", "content": "dup answer", "timestamp": 5.0}, + {"role": "assistant", "content": "dup answer", "timestamp": 5.0}, + ] + ref = routes._assistant_anchor_scene_message_ref(messages[0]) + assert ref == routes._assistant_anchor_scene_message_ref(messages[1]), "refs must collide for this test" + + # A single record keyed by that ambiguous ref, index-targeted at message 0. + records = { + ref: { + "version": "anchor_activity_scene_record_v1", + "message_index": 0, + "message_ref": ref, + "scene": {"version": "activity_scene_v1", "activity_rows": [], "final_answer": "dup answer"}, + } + } + out = routes._hydrate_anchor_activity_scenes(messages, records, message_offset=0) + attached = [("_anchor_activity_scene" in m) for m in out] + # The ambiguous ref must NOT fan the scene out to BOTH messages. + assert attached.count(True) <= 1, ( + f"ambiguous ref must not double-attach the scene; got {attached}" + ) + + def test_anchor_scene_hydration_rejects_stale_index_fallback_when_final_answer_mismatches(): from api import routes