diff --git a/docs/assets/personal-workspace/steward-reports/citations/after-desktop.png b/docs/assets/personal-workspace/steward-reports/citations/after-desktop.png new file mode 100644 index 0000000000..3e15896d9f Binary files /dev/null and b/docs/assets/personal-workspace/steward-reports/citations/after-desktop.png differ diff --git a/docs/assets/personal-workspace/steward-reports/citations/after-narrow.png b/docs/assets/personal-workspace/steward-reports/citations/after-narrow.png new file mode 100644 index 0000000000..8f6b935fe4 Binary files /dev/null and b/docs/assets/personal-workspace/steward-reports/citations/after-narrow.png differ diff --git a/docs/assets/personal-workspace/steward-reports/citations/before-desktop.png b/docs/assets/personal-workspace/steward-reports/citations/before-desktop.png new file mode 100644 index 0000000000..e879c7452f Binary files /dev/null and b/docs/assets/personal-workspace/steward-reports/citations/before-desktop.png differ diff --git a/docs/assets/personal-workspace/steward-reports/citations/before-narrow.png b/docs/assets/personal-workspace/steward-reports/citations/before-narrow.png new file mode 100644 index 0000000000..6b1848d48c Binary files /dev/null and b/docs/assets/personal-workspace/steward-reports/citations/before-narrow.png differ diff --git a/docs/assets/personal-workspace/steward-reports/citations/html-desktop.png b/docs/assets/personal-workspace/steward-reports/citations/html-desktop.png new file mode 100644 index 0000000000..84d474a74e Binary files /dev/null and b/docs/assets/personal-workspace/steward-reports/citations/html-desktop.png differ diff --git a/docs/assets/personal-workspace/steward-reports/citations/html-narrow.png b/docs/assets/personal-workspace/steward-reports/citations/html-narrow.png new file mode 100644 index 0000000000..57f1edd4fe Binary files /dev/null and b/docs/assets/personal-workspace/steward-reports/citations/html-narrow.png differ diff --git a/docs/product/periodic-reports.md b/docs/product/periodic-reports.md index d5362380a5..45f45fdb21 100644 --- a/docs/product/periodic-reports.md +++ b/docs/product/periodic-reports.md @@ -6,6 +6,43 @@ Agent Session calendar reports are implemented. Installation, an enabled subscription and a natural scheduled delivery require their own readback evidence. Preparing an identity enables neither monitoring nor reports. +Markdown, the HTML report and its copied full text use the same source-link +rule. An unchanged, well-formed HTTP(S) address is a link. Opaque evidence +digests, native Task/artifact references and private relative paths stay source +references; they are never presented as working web links. Malformed addresses, +credentials in an address, control characters and non-web schemes remain inert. +Markdown escapes destination punctuation without inventing a different source. +This is presentation validation, not a claim that a URL is reachable, public or +authorized for another audience. Source collection, frozen evidence, redaction +and delivery authorization retain those responsibilities. + +An already frozen report retains its original bytes and generation identity. +Validation can reconstruct the exact previous source-link presentation without +rewriting its artifacts, invoking generation or acknowledging publication. +The full frozen source, authored response, bundle, candidate and body must still +match; an unsupported or altered presentation remains unavailable. New reports +always use the shared source-link rule above. + +The following synthetic reports use the production formatters. Text views use +the compiled App Markdown component and its stylesheet; HTML views use the +standalone report artifact. The same normalized records retain their outcomes, +counterevidence, next actions and source identities. These captures qualify +format rendering, separately from the full packaged App, installed scheduling, +live-model content and provider delivery. Desktop is 1440×1000; narrow views +are 390×844 viewport emulations. + +| Text report before | Text report after | +| --- | --- | +| ![Opaque sources appeared as Markdown link syntax](../assets/personal-workspace/steward-reports/citations/before-desktop.png) | ![Opaque sources remain readable references](../assets/personal-workspace/steward-reports/citations/after-desktop.png) | + +| Narrow text before | Narrow text after | +| --- | --- | +| ![Previous text report at a narrow viewport](../assets/personal-workspace/steward-reports/citations/before-narrow.png) | ![Source references wrap without horizontal overflow](../assets/personal-workspace/steward-reports/citations/after-narrow.png) | + +| HTML report | Narrow HTML report | +| --- | --- | +| ![HTML retains the same source references and substantive report](../assets/personal-workspace/steward-reports/citations/html-desktop.png) | ![The standalone HTML report at a narrow viewport](../assets/personal-workspace/steward-reports/citations/html-narrow.png) | + The same local calendar can explicitly repeat an already completed native Task in its original managed Session. This backend/configuration slice is described below; arbitrary new tasks from natural language and the unified Agent UI diff --git a/loopx/capabilities/periodic_report/private_generation.py b/loopx/capabilities/periodic_report/private_generation.py index 712f94f117..0c4efd9282 100644 --- a/loopx/capabilities/periodic_report/private_generation.py +++ b/loopx/capabilities/periodic_report/private_generation.py @@ -250,6 +250,13 @@ def validate_private_generation(folder: Path) -> dict[str, Any]: bundle = _read(folder / "generation-bundle.json") checked = build_periodic_report_generation_bundle(document=bundle["document"], artifacts=[ render_periodic_report_markdown(bundle["document"]), render_periodic_report_html(bundle["document"])]) + if checked != bundle: + # Rendering can improve after a window was frozen. Reconstruct only the + # exact prior source-link presentation; never rewrite or regenerate it. + # Both candidates still require full bundle, source and body equality. + checked = build_periodic_report_generation_bundle(document=bundle["document"], artifacts=[ + render_periodic_report_markdown(bundle["document"], _legacy_source_links=True), + render_periodic_report_html(bundle["document"], _legacy_source_links=True)]) candidate = _read(folder / "publication-candidate.json") expected_candidate = build_periodic_report_publication_candidate(goal_id=window["goal_id"], agent_id=window["agent_id"], generation_id=checked["generation_receipt"]["generation_id"], @@ -264,6 +271,8 @@ def validate_private_generation(folder: Path) -> dict[str, Any]: or receipt["publication_candidate_sha256"] != _canonical_digest(candidate) or receipt["request_digest"] != request["request_digest"] or receipt["body_sha256"] != hashlib.sha256(report_bytes).hexdigest() - or report_bytes.decode("utf-8") != render_periodic_report_markdown(bundle["document"])["content"]): + or report_bytes.decode("utf-8") != next( + artifact["content"] for artifact in checked["artifacts"] + if artifact["renderer_kind"] == "markdown")): raise ValueError("frozen generation or full report body changed") return receipt diff --git a/loopx/presentation/renderers/periodic_report_html.py b/loopx/presentation/renderers/periodic_report_html.py index 052b19f51c..b2b73a5409 100644 --- a/loopx/presentation/renderers/periodic_report_html.py +++ b/loopx/presentation/renderers/periodic_report_html.py @@ -5,7 +5,6 @@ from collections.abc import Mapping, Sequence from html import escape from typing import Any -from urllib.parse import urlparse from ...capabilities.periodic_report.adapters import ( ARTIFACT_SCHEMA, @@ -16,6 +15,7 @@ from ..public_safety import redact_public_text from .periodic_report_markdown import ( periodic_report_labels, + periodic_report_source_url, render_periodic_report_markdown, ) @@ -38,12 +38,9 @@ def _safe_attr(value: object, *, maximum: int) -> str: return escape(str(redact_public_text(value, limit=maximum)), quote=True) -def _safe_http_url(value: object) -> str | None: - raw = str(redact_public_text(value, limit=500)) - parsed = urlparse(raw) - if parsed.scheme not in {"http", "https"} or not parsed.netloc: - return None - return escape(raw, quote=True) +def _safe_http_url(value: object, *, _legacy_source_links: bool = False) -> str | None: + url = periodic_report_source_url(value, _legacy_source_links=_legacy_source_links) + return escape(url, quote=True) if url else None def _document_digest(document: Mapping[str, Any]) -> str: @@ -207,13 +204,14 @@ def _render_item( *, index: int, labels: Mapping[str, str], + _legacy_source_links: bool = False, ) -> str: title = _safe_text(item.get("title"), maximum=240) summary = _safe_text(item.get("summary"), maximum=1000) status = _safe_text(item.get("status"), maximum=80) next_action = _safe_text(item.get("next_action"), maximum=500) source_ref = item.get("source_ref") - source_url = _safe_http_url(source_ref) + source_url = _safe_http_url(source_ref, _legacy_source_links=_legacy_source_links) content_kind = _safe_attr(item.get("content_kind") or "unspecified", maximum=128) search_text = " ".join( str(item.get(field) or "") @@ -271,12 +269,14 @@ def _render_section( section_index: int, item_start: int, labels: Mapping[str, str], + _legacy_source_links: bool = False, ) -> tuple[str, int]: section_id = _safe_attr(section.get("section_id"), maximum=128) title = _safe_text(section.get("title"), maximum=160) items = _primary_items(section) rows = "".join( - _render_item(item, index=item_start + offset, labels=labels) + _render_item(item, index=item_start + offset, labels=labels, + _legacy_source_links=_legacy_source_links) for offset, item in enumerate(items) ) if not rows: @@ -297,6 +297,7 @@ def _render_supporting_sections( sections: Sequence[Mapping[str, Any]], *, labels: Mapping[str, str], + _legacy_source_links: bool = False, ) -> str: groups = [] for section in sections: @@ -313,7 +314,7 @@ def _render_supporting_sections( ) label = " · ".join(value for value in (kind, status) if value) next_action = _safe_text(item.get("next_action"), maximum=500) - source_url = _safe_http_url(item.get("source_ref")) + source_url = _safe_http_url(item.get("source_ref"), _legacy_source_links=_legacy_source_links) source = ( f'Source ↗' @@ -602,7 +603,7 @@ def _render_source_receipts( """ -def render_periodic_report_html(document: Mapping[str, Any]) -> dict[str, Any]: +def render_periodic_report_html(document: Mapping[str, Any], *, _legacy_source_links: bool = False) -> dict[str, Any]: """Render one dense, self-contained report without changing its semantics.""" if document.get("schema_version") != DOCUMENT_SCHEMA: @@ -624,7 +625,7 @@ def render_periodic_report_html(document: Mapping[str, Any]) -> dict[str, Any]: item_count = sum(len(_primary_items(section)) for section in sections) supporting_item_count = sum(len(_supporting_items(section)) for section in sections) document_digest = _document_digest(document) - markdown_artifact = render_periodic_report_markdown(document) + markdown_artifact = render_periodic_report_markdown(document, _legacy_source_links=_legacy_source_links) filters = [ ' dict[str, Any]: section_index=section_index, item_start=next_item_index, labels=labels, + _legacy_source_links=_legacy_source_links, ) rendered_sections.append(section_html) @@ -680,7 +682,8 @@ def render_periodic_report_html(document: Mapping[str, Any]) -> dict[str, Any]: f'
{len(sources)}{labels["sources"]}
' "" ) - supporting_sections = _render_supporting_sections(sections, labels=labels) + supporting_sections = _render_supporting_sections(sections, labels=labels, + _legacy_source_links=_legacy_source_links) content = ( "\n" f'' diff --git a/loopx/presentation/renderers/periodic_report_markdown.py b/loopx/presentation/renderers/periodic_report_markdown.py index 6a5d7cdbf0..a00bb6bbd0 100644 --- a/loopx/presentation/renderers/periodic_report_markdown.py +++ b/loopx/presentation/renderers/periodic_report_markdown.py @@ -2,8 +2,11 @@ import hashlib import json +import re +import unicodedata from collections.abc import Mapping, Sequence from typing import Any +from urllib.parse import quote, urlsplit from ...capabilities.periodic_report.adapters import ( ARTIFACT_SCHEMA, @@ -32,6 +35,44 @@ def _visibility(item: Mapping[str, Any]) -> str: return str(item.get("visibility") or "primary") +def periodic_report_source_url(value: object, *, _legacy_source_links: bool = False) -> str | None: + """A display link is an unchanged HTTP URL, not evidence of access or trust.""" + if _legacy_source_links: + # Read-only reconstruction of an already frozen pre-fix artifact. + raw = _safe(value, maximum=500) + parsed = urlsplit(raw) + return raw if parsed.scheme in {"http", "https"} and parsed.netloc else None + if not isinstance(value, str) or not value or value != _safe(value, maximum=500): + return None + if any(char.isspace() or unicodedata.category(char).startswith("C") + or char in "<>\\`" for char in value): + return None + try: + parsed = urlsplit(value) + if (parsed.scheme not in {"http", "https"} or not parsed.hostname + or parsed.username is not None or parsed.password is not None): + return None + parsed.port # Reject malformed ports rather than linking a different address. + except ValueError: + return None + return value + + +def _markdown_source(value: object, labels: Mapping[str, str], *, _legacy_source_links: bool = False) -> str: + source = _safe(value, maximum=500) + if _legacy_source_links: + return f"[{labels['source']}]({source})" + url = periodic_report_source_url(value) + if url: + # Parentheses and quotes must not terminate a Markdown destination. + destination = quote(url, safe=":/?#[]@!$&'*+,;=%-._~") + return f"[{labels['source']}]({destination})" + # Retain opaque/private/malformed references as data, never fake links. + fence = "`" * (1 + max((len(run) for run in re.findall(r"`+", source)), default=0)) + padding = " " if source.startswith("`") or source.endswith("`") else "" + return f"{labels['source']}{labels['label_separator']}{fence}{padding}{source}{padding}{fence}" + + def periodic_report_labels(language: object) -> dict[str, str]: """Return the small built-in report UI vocabulary for one language.""" @@ -59,15 +100,16 @@ def _render_markdown_item( item: Mapping[str, Any], *, labels: Mapping[str, str], + _legacy_source_links: bool = False, ) -> None: item_title = _safe(item.get("title"), maximum=240) - source_ref = _safe(item.get("source_ref"), maximum=500) + source_ref = item.get("source_ref") status = _safe(item.get("status"), maximum=80) headline = f"- **{item_title}**" if status: headline += f" · `{status}`" if source_ref: - headline += f" · [{labels['source']}]({source_ref})" + headline += f" · {_markdown_source(source_ref, labels, _legacy_source_links=_legacy_source_links)}" lines.append(headline) lines.append(f" - {_safe(item.get('summary'), maximum=1000)}") for detail in _items(item.get("details")): @@ -85,6 +127,8 @@ def _render_markdown_item( def render_periodic_report_markdown( document: Mapping[str, Any], + *, + _legacy_source_links: bool = False, ) -> dict[str, Any]: """Render normalized sections without changing ordering or business meaning.""" @@ -128,13 +172,13 @@ def render_periodic_report_markdown( lines.append(f"- {labels['no_items']}") continue for item in section_items: - _render_markdown_item(lines, item, labels=labels) + _render_markdown_item(lines, item, labels=labels, _legacy_source_links=_legacy_source_links) if supporting_sections: lines.extend(["", f"## {labels['supporting_appendix']}"]) for section, section_items in supporting_sections: lines.extend(["", f"### {_safe(section.get('title'), maximum=160)}", ""]) for item in section_items: - _render_markdown_item(lines, item, labels=labels) + _render_markdown_item(lines, item, labels=labels, _legacy_source_links=_legacy_source_links) content = "\n".join(lines).rstrip() + "\n" content_digest = hashlib.sha256(content.encode("utf-8")).hexdigest() document_digest = hashlib.sha256( diff --git a/tests/capabilities/test_periodic_report_private_generation.py b/tests/capabilities/test_periodic_report_private_generation.py index abe4b8440a..6c331478c6 100644 --- a/tests/capabilities/test_periodic_report_private_generation.py +++ b/tests/capabilities/test_periodic_report_private_generation.py @@ -1,8 +1,13 @@ from __future__ import annotations import hashlib +import json +from html import escape from datetime import datetime, timezone from pathlib import Path +from urllib.parse import urlparse + +import pytest from loopx.capabilities.periodic_report.cadence_journal import ( admit_cadence_window, @@ -18,6 +23,7 @@ from loopx.capabilities.periodic_report.private_generation import ( attempt_directory, freeze_private_report, + validate_private_generation, ) from loopx.registry import atomic_write_json @@ -31,9 +37,7 @@ } -def test_private_report_artifacts_use_canonical_utf8_on_windows_text_mode( - tmp_path: Path, monkeypatch -) -> None: +def _prepared_report(tmp_path: Path): root = tmp_path / "runtime" window = admit_cadence_window( runtime_root=root, @@ -154,6 +158,14 @@ def test_private_report_artifacts_use_canonical_utf8_on_windows_text_mode( ], "sections": sections, } + return root, window, response + + +def test_private_report_artifacts_use_canonical_utf8_on_windows_text_mode( + tmp_path: Path, monkeypatch +) -> None: + root, window, response = _prepared_report(tmp_path) + folder = attempt_directory(root, window) real_write_text = Path.write_text real_read_text = Path.read_text @@ -176,3 +188,56 @@ def host_codec_read_text(path: Path, *args, **kwargs): report_bytes = (folder / "report.md").read_bytes() assert b"\r\n" not in report_bytes assert receipt["body_sha256"] == hashlib.sha256(report_bytes).hexdigest() + + +@pytest.mark.parametrize("tamper", [None, "report", "html", "source", "response"]) +def test_frozen_report_keeps_original_source_links_across_renderer_upgrade(tmp_path, monkeypatch, tamper): + from loopx.presentation.renderers import periodic_report_html as html + from loopx.presentation.renderers import periodic_report_markdown as markdown + + root, window, response = _prepared_report(tmp_path) + folder = attempt_directory(root, window) + + def previous_html_source(value, **_): + raw = markdown._safe(value, maximum=500) + parsed = urlparse(raw) + return escape(raw, quote=True) if parsed.scheme in {"http", "https"} and parsed.netloc else None + + # Seed through the real freezer with the previous independently specified + # source rules, not through the new validator's compatibility switch. + with monkeypatch.context() as previous: + previous.setattr(markdown, "_markdown_source", lambda value, labels, **_: + f"[{labels['source']}]({markdown._safe(value, maximum=500)})") + previous.setattr(html, "_safe_http_url", previous_html_source) + receipt = freeze_private_report(root=root, window=window, response=response) + original = {path.name: path.read_bytes() for path in folder.iterdir() if path.is_file()} + assert "[来源](sha256:" in original["report.md"].decode("utf-8") + assert "[来源](sha256:" not in markdown.render_periodic_report_markdown( + json.loads(original["generation-bundle.json"])["document"])["content"] + + if tamper == "report": + body = original["report.md"] + b"\nA different unapproved claim.\n" + (folder / "report.md").write_bytes(body) + atomic_write_json(folder / "private-generation.json", { + **receipt, "body_sha256": hashlib.sha256(body).hexdigest()}) + elif tamper == "html": + bundle = json.loads(original["generation-bundle.json"]) + artifact = next(item for item in bundle["artifacts"] if item["renderer_kind"] == "html") + artifact["content"] += "

A different unapproved claim.

" + artifact["content_digest"] = "sha256:" + hashlib.sha256(artifact["content"].encode()).hexdigest() + atomic_write_json(folder / "generation-bundle.json", bundle) + elif tamper in {"source", "response"}: + name = "source.json" if tamper == "source" else "editorial-response.json" + changed = json.loads(original[name]) + changed["rows" if tamper == "source" else "title"] = [] if tamper == "source" else "Another title" + if tamper == "source": + changed["gaps"] = ["An unapproved source change"] + atomic_write_json(folder / name, changed) + if tamper: + with pytest.raises(ValueError): + validate_private_generation(folder) + return + + assert validate_private_generation(folder) == receipt + assert validate_private_generation(folder) == receipt # Restart/readback is effect-free. + assert {path.name: path.read_bytes() for path in folder.iterdir() if path.is_file()} == original diff --git a/tests/presentation/test_periodic_report_html.py b/tests/presentation/test_periodic_report_html.py index 23b9cd0312..51a4f0cb7f 100644 --- a/tests/presentation/test_periodic_report_html.py +++ b/tests/presentation/test_periodic_report_html.py @@ -1,5 +1,7 @@ from __future__ import annotations +from html.parser import HTMLParser + import pytest from loopx.capabilities.periodic_report import ( @@ -219,6 +221,64 @@ def test_html_and_markdown_share_one_document_and_primary_content() -> None: assert markdown_artifact["content_policy"]["supporting_item_count"] == 1 +@pytest.mark.parametrize("language", ["en", "zh-CN"]) +@pytest.mark.parametrize("reference, linked", [ + ("https://example.test/releases/2.5", True), + ("http://example.test/notes?view=full&part=2", True), + ("https://example.test/notes/(recovery)?title=\"before\"", True), + ("sha256:" + "a" * 64, False), + ("todo:completed-source", False), + ("artifact:report/window", False), + ("reports/private-window.md", False), + ("javascript:alert(1)", False), + ("data:text/html,", False), + ("//example.test/relative", False), + ("https:///missing-host", False), + ("https://example.test:broken/path", False), + ("https://[broken/path", False), + ("https://user@example.test/private", False), + ("https://example.test/line\nbreak", False), + ("https://example.test/hidden\u202eaddress", False), + ("https://example.test/\\different-address", False), + ("https://example.test/" + "x" * 500, False), +]) +def test_report_sources_keep_link_eligibility_in_both_formats(reference, linked, language): + class Links(HTMLParser): + def __init__(self): + super().__init__() + self.targets = [] + + def handle_starttag(self, tag, attrs): + values = dict(attrs) + if tag == "a" and values.get("class") == "source-link": + self.targets.append(values["href"]) + + document = _document(language=language) + document["sections"][0]["items"][0]["source_ref"] = reference + html = periodic_report_html_renderer_adapter().render(document) + markdown = periodic_report_markdown_renderer_adapter().render(document) + links = Links() + links.feed(html["content"]) + label = "来源" if language == "zh-CN" else "source" + assert (reference in links.targets) is linked + assert markdown["content"].count(f"[{label}](") == (2 if linked else 1) + assert html["companion_markdown_digest"] == markdown["content_digest"] + assert html["document_digest"] == markdown["document_digest"] + # Presentation does not replace substantive outcomes with citation metadata. + for text in ("Published & verified.", "One staged rollout completed.", "Observe adoption."): + assert text in markdown["content"] + if not linked and reference.startswith(("sha256:", "todo:", "artifact:")): + assert f"`{reference}`" in markdown["content"] + + +def test_markdown_source_destination_does_not_end_at_literal_parentheses_or_quotes(): + document = _document() + document["sections"][0]["items"][0]["source_ref"] = 'https://example.test/(before)?title="release"' + markdown = periodic_report_markdown_renderer_adapter().render(document)["content"] + assert '[source](https://example.test/%28before%29?title=%22release%22)' in markdown + assert '[source](https://example.test/(before)' not in markdown + + def test_source_receipts_are_supporting_context_not_visible_body_copy() -> None: artifact = periodic_report_html_renderer_adapter().render(_document()) content = artifact["content"]