From be2c434a7d7e8ff9ea68686d2b11deed5ec9d238 Mon Sep 17 00:00:00 2001 From: JasonBuildAI Date: Tue, 13 Oct 2026 13:18:45 +0800 Subject: [PATCH 1/2] fix(periodic-report): reject native, UNC and escaped Windows private paths in the run core The run-request core is the gate for the periodic-report capability, and its private-path rule recognized only the POSIX spelling of the private roots. The same path written the way a Windows host or a serialized string spells it (C:\Users\\..., \\server\Users\\..., the rooted \Users\\..., and the escaped C:\\Users\\\\...) was accepted into the run identity and echoed in the composed packet while the POSIX spelling was refused, contrary to the protocol's documented "rejects ... private paths" contract. Match a separator run at every junction of the same private roots, the class the boundary scan (#6161) and the public-safe-outbound scanner (#6223) already reject. Paths outside the private roots, and URLs, stay acceptable; the native, UNC, rooted and escaped spellings plus the CLI refusal path are pinned by tests. Closes #6358 Signed-off-by: JasonBuildAI --- loopx/capabilities/periodic_report/core.py | 9 ++- tests/capabilities/test_periodic_report.py | 68 ++++++++++++++++++++++ 2 files changed, 76 insertions(+), 1 deletion(-) diff --git a/loopx/capabilities/periodic_report/core.py b/loopx/capabilities/periodic_report/core.py index bce688f995..68e120c3c2 100644 --- a/loopx/capabilities/periodic_report/core.py +++ b/loopx/capabilities/periodic_report/core.py @@ -35,8 +35,15 @@ "primary_goal_outcome", "vision_closed", } +# The private roots take whatever separator the host or a serialized string +# chose: a separator run at every junction also covers the native Windows +# spelling (C:\Users\\...), the UNC spelling (\\server\Users\\...) +# and the escaped spelling a JSON string produces (C:\\Users\\\\...), +# the class the boundary scan (#6161) and the public-safe-outbound scanner +# (#6223) reject. Paths outside these roots stay untouched. _LOCAL_PATH_SURFACE_PATTERN = re.compile( - r"(?]+" + r"(?]+" ) _FORBIDDEN_RAW_KEYS = { "credential", diff --git a/tests/capabilities/test_periodic_report.py b/tests/capabilities/test_periodic_report.py index b71accf413..8240b1c388 100644 --- a/tests/capabilities/test_periodic_report.py +++ b/tests/capabilities/test_periodic_report.py @@ -215,6 +215,48 @@ def test_raw_content_and_incomplete_sink_roles_are_rejected() -> None: build_periodic_report_run(missing_delivery) +def _request_with_snapshot_ref(value: str) -> dict[str, object]: + request = _request() + request["source_snapshots"][0]["snapshot_ref"] = value # type: ignore[index] + return request + + +def test_private_path_spelling_variants_are_rejected() -> None: + # The same private path spelled the way a Windows host or a serialized + # string writes it is refused like the POSIX spelling above. + separator = "\\" + doubled = separator * 2 + parts = ["Users", "example-user", "notes.md"] + denied = { + "native-drive": "C:" + separator + separator.join(parts), + "unc": doubled + separator.join(["fileserver", *parts]), + "rooted-native": separator + separator.join(parts), + "escaped-drive": "C:" + doubled + doubled.join(parts), + "escaped-unc": doubled * 2 + doubled.join(["fileserver", *parts]), + "native-tmp": "C:" + separator + separator.join(["tmp", "scratch.txt"]), + } + for label, private_ref in denied.items(): + try: + build_periodic_report_run(_request_with_snapshot_ref(private_ref)) + except ValueError as exc: + assert "private path or credential-like value" in str(exc), label + else: + raise AssertionError(f"{label} accepted private path {private_ref!r}") + + +def test_public_windows_paths_and_urls_stay_accepted() -> None: + # Ordinary drive/UNC paths outside the private roots, and URLs, keep + # passing: the wider rule must not refuse every Windows path. + separator = "\\" + accepted = ( + separator.join(["C:", "Projects", "public-demo", "build"]), + separator * 2 + separator.join(["fileserver", "share", "public-notes.md"]), + "https://example.com/report", + ) + for public_ref in accepted: + build_periodic_report_run(_request_with_snapshot_ref(public_ref)) + + def test_periodic_report_cli_composes_json(tmp_path, capsys) -> None: request_path = tmp_path / "request.json" request_path.write_text(json.dumps(_request()), encoding="utf-8") @@ -237,6 +279,32 @@ def test_periodic_report_cli_composes_json(tmp_path, capsys) -> None: assert payload["run_state"]["status"] == "pending" +def test_periodic_report_cli_refuses_native_windows_private_path(tmp_path, capsys) -> None: + separator = "\\" + request = _request_with_snapshot_ref( + "C:" + separator + separator.join(["Users", "example-user", "notes.md"]) + ) + request_path = tmp_path / "request.json" + request_path.write_text(json.dumps(request), encoding="utf-8") + + assert ( + main( + [ + "--format", + "json", + "periodic-report", + "compose-run", + "--request-json", + str(request_path), + ] + ) + == 1 + ) + payload = json.loads(capsys.readouterr().out) + assert payload["ok"] is False + assert "private path or credential-like value" in payload["error"] + + class _MeteredStdin(io.StringIO): """Text stdin that records how many UTF-8 bytes the reader pulled.""" From 1698eb5eb24f186a8e7ed2e8e023479a8c736881 Mon Sep 17 00:00:00 2001 From: JasonBuildAI Date: Tue, 13 Oct 2026 14:15:43 +0800 Subject: [PATCH 2/2] perf(periodic-report): keep the private-path guard linear on separator runs An independent review of the first commit measured the leading separator-run quantifier retrying every start position inside a long non-root run, and the raw-value scan runs before field length validation, so a request carrying tens of kilobytes of separators stalled compose-run (32,768 backslashes: 9.1 s through the builder). A single leading separator is acceptance-identical (the engine can start at the last separator of a run; 200,000-case fuzz: 0 verdict differences and 0 loosening) and scans linearly (65,536 separators: 0.0013 s). A bounded regression test pins it, and the Windows spellings of the inner var/folders and private/tmp junctions get explicit cases. Signed-off-by: JasonBuildAI --- loopx/capabilities/periodic_report/core.py | 8 +++++--- tests/capabilities/test_periodic_report.py | 14 ++++++++++++++ 2 files changed, 19 insertions(+), 3 deletions(-) diff --git a/loopx/capabilities/periodic_report/core.py b/loopx/capabilities/periodic_report/core.py index 68e120c3c2..b9c4aeb9ef 100644 --- a/loopx/capabilities/periodic_report/core.py +++ b/loopx/capabilities/periodic_report/core.py @@ -36,13 +36,15 @@ "vision_closed", } # The private roots take whatever separator the host or a serialized string -# chose: a separator run at every junction also covers the native Windows +# chose: matching the separator at every junction covers the native Windows # spelling (C:\Users\\...), the UNC spelling (\\server\Users\\...) # and the escaped spelling a JSON string produces (C:\\Users\\\\...), # the class the boundary scan (#6161) and the public-safe-outbound scanner -# (#6223) reject. Paths outside these roots stay untouched. +# (#6223) reject. The lead is a single separator so a long non-root separator +# run cannot make the raw-value scan quadratic. Paths outside these roots +# stay untouched. _LOCAL_PATH_SURFACE_PATTERN = re.compile( - r"(?]+" ) _FORBIDDEN_RAW_KEYS = { diff --git a/tests/capabilities/test_periodic_report.py b/tests/capabilities/test_periodic_report.py index 8240b1c388..d749a8b5ba 100644 --- a/tests/capabilities/test_periodic_report.py +++ b/tests/capabilities/test_periodic_report.py @@ -5,6 +5,7 @@ import json import subprocess import sys +import time from pathlib import Path import pytest @@ -234,6 +235,8 @@ def test_private_path_spelling_variants_are_rejected() -> None: "escaped-drive": "C:" + doubled + doubled.join(parts), "escaped-unc": doubled * 2 + doubled.join(["fileserver", *parts]), "native-tmp": "C:" + separator + separator.join(["tmp", "scratch.txt"]), + "native-private-tmp": "C:" + separator + separator.join(["private", "tmp", "scratch.log"]), + "native-var-folders": "C:" + separator + separator.join(["var", "folders", "app-cache"]), } for label, private_ref in denied.items(): try: @@ -257,6 +260,17 @@ def test_public_windows_paths_and_urls_stay_accepted() -> None: build_periodic_report_run(_request_with_snapshot_ref(public_ref)) +def test_long_separator_run_stays_bounded() -> None: + # The raw-value scan runs before field length validation, so a long + # non-root separator run must stay linear; a quadratic rescan would + # stall the caller instead of failing fast on the length error. + request = _request_with_snapshot_ref("\\" * 32_768) + started = time.perf_counter() + with pytest.raises(ValueError, match="exceeds 500 characters"): + build_periodic_report_run(request) + assert time.perf_counter() - started < 1.0 + + def test_periodic_report_cli_composes_json(tmp_path, capsys) -> None: request_path = tmp_path / "request.json" request_path.write_text(json.dumps(_request()), encoding="utf-8")