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 <franksong2702@users.noreply.github.com>
This commit is contained in:
1 parent
001148d208
commit
81a4945be8
2 files changed
+48
-1
No files matched your search
+15
-1
@@ -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 {}):
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
Reference in new issue
Block a user