diff --git a/docs/public-private-boundary.md b/docs/public-private-boundary.md index 0e3a39a1c6..f460d91d87 100644 --- a/docs/public-private-boundary.md +++ b/docs/public-private-boundary.md @@ -188,6 +188,36 @@ field or inspecting its value. String keys are classified after case, separator, and camelCase normalization; converting a non-string key with `str()` is not a safe substitute for a field name. +The typed public-export gate also rejects home-relative references, explicit +`path:` local references, historical colon/equal-prefixed local references and +`file://` URLs (including host-qualified and case-insensitive forms), in both +keys and recursively nested values. It consumes +`find_public_safe_local_path` from the shared text owner. Ordinary HTTP(S) +links, other remote URIs and repository-relative references remain subject to +their destination's existing rules; they are not generically private. + +This policy applies to the existing typed-export callers: + +| Destination | Behavior at the boundary | +| --- | --- | +| Goal lifecycle and acceptance projections | Omit unsafe labels and evidence references before compaction; preserve private source records. | +| Goal notices, attention and delivery review | Reject unsafe facts or synthesized output, or use the caller's existing fallback. | +| Research observations and diagnostic envelopes | Reject unsafe structured observations. | +| Peer routing, child task packets and native-child receipts | Reject unsafe candidates, references and packet fields. | +| Team plans, governed transition receipts, lane settlements and previews | Reject unsafe public payloads before admission/publication. | +| Extension capability requests and results | Reject unsafe provider payloads at the existing admission boundary. | + +Decision-context packets and material-lifecycle fields already rejected file +URLs. They now report their field-specific local-path error instead of a raw-URL +error; their length limits and rejection verdicts remain unchanged. Historical +absolute-path diagnostics at the typed export gate also remain unchanged. + +This is a destination-specific migration under #5136. The internal text-owner +category policies, TypeScript Vision checkpoint, generic +`public_safe_compact_text` helper and presentation redactor are unchanged; +using that compactor alone is not proof that a payload passed the typed export +gate. Other caller migrations remain separate work. + ## Sub-Agent Data Sub-agent orchestration increases leakage risk because child prompts often diff --git a/loopx/control_plane/runtime/public_safety.py b/loopx/control_plane/runtime/public_safety.py index c229e1011e..8526679241 100644 --- a/loopx/control_plane/runtime/public_safety.py +++ b/loopx/control_plane/runtime/public_safety.py @@ -90,7 +90,9 @@ def validate_public_safe_value( Mapping field names must be strings. Field classification is exact after case, separator, and camelCase normalization. Values are then checked recursively so nested maps and lists cannot bypass the same credential - and local-path boundary. + and local-path boundary. This public-export policy rejects home-relative, + explicit path-prefixed and file-URL locators as well as absolute paths; + ordinary remote URLs and repository-relative references remain allowed. """ if isinstance(value, Mapping): @@ -100,7 +102,7 @@ def validate_public_safe_value( f"{path} contains a non-string field name ({type(key).__name__})" ) key_text = key - if LOCAL_PATH_SURFACE_PATTERN.search( + if find_public_safe_local_path( key_text ) or SECRET_LIKE_SURFACE_PATTERN.search(key_text): raise ValueError(f"{path} contains an unsafe field name") @@ -121,8 +123,14 @@ def validate_public_safe_value( return if not isinstance(value, str): return - if LOCAL_PATH_SURFACE_PATTERN.search(value): - raise ValueError(f"{path} contains an absolute local path") + if find_public_safe_local_path(value): + # Preserve the existing diagnostic for the historical absolute shapes. + kind = ( + "an absolute local path" + if LOCAL_PATH_SURFACE_PATTERN.search(value) + else "a local path" + ) + raise ValueError(f"{path} contains {kind}") if SECRET_LIKE_SURFACE_PATTERN.search(value): raise ValueError(f"{path} contains a credential-like value") diff --git a/loopx/public_safe_text.py b/loopx/public_safe_text.py index b0c60ff4b7..1d7cfc5aaa 100644 --- a/loopx/public_safe_text.py +++ b/loopx/public_safe_text.py @@ -200,10 +200,9 @@ # path behind an explicit `path:` prefix. Whether a surface *rejects* what this # owner recognizes stays the caller's named policy, so a surface can still opt # into the narrower legacy set by asking for `LOCAL_PATH_SURFACE_PATTERN` alone. -# `file://` is deliberately not in this set: direction 3 does classify it as a -# local path, but every surface that has to stop carrying one already rejects it -# here as a raw remote location, and the surfaces that keep ordinary URLs would -# need a per-surface decision rather than a shared-pattern change. +# Typed public exports also reject file URLs, including host-qualified ones. +# The internal text-owner classifier and legacy compactor retain their separate +# policies; recognizing a locator here does not change those destinations. HOME_RELATIVE_PATH_PATTERN = re.compile(r"(?]+") PATH_PREFIX_LOCAL_PATTERN = re.compile( r"(?]+", re.IGNORECASE @@ -221,11 +220,13 @@ r"(?:^|[\s:=])(?:/Users/|/private/|/tmp/|~[/\\])", re.IGNORECASE, ) +FILE_URL_LOCAL_PATH_PATTERN = re.compile(r"\bfile://", re.IGNORECASE) PUBLIC_SAFE_LOCAL_PATH_PATTERNS: tuple[re.Pattern[str], ...] = ( LOCAL_PATH_SURFACE_PATTERN, HOME_RELATIVE_PATH_PATTERN, PATH_PREFIX_LOCAL_PATTERN, LOCAL_PATH_BOUNDARY_REFERENCE_PATTERN, + FILE_URL_LOCAL_PATH_PATTERN, ) # Presentation redaction keeps its historical Unix-root boundary behavior (it # catches paths even after a colon), consumes the shared absolute, drive-letter @@ -495,7 +496,8 @@ def find_public_safe_local_path(value: str | None) -> re.Pattern[str] | None: surfaces that publish outside the runtime (Refs #5136, direction 3): the absolute roots, the two gap shapes `classify_private_text` reaches only when a caller opts into `include_path_gaps`, and the colon/equals boundary form - the migrated surfaces already enforced. Recognition is still not permission: + the migrated surfaces already enforced, plus file URLs. Recognition is still + not permission: a caller that must keep a narrower historical verdict asks for `LOCAL_PATH_SURFACE_PATTERN` directly, and each surface keeps its own rejection message and length limit. diff --git a/tests/control_plane/test_goal_acceptance_observation.py b/tests/control_plane/test_goal_acceptance_observation.py index c617807d52..ee85e09d28 100644 --- a/tests/control_plane/test_goal_acceptance_observation.py +++ b/tests/control_plane/test_goal_acceptance_observation.py @@ -354,6 +354,9 @@ def test_lifecycle_public_safety_covers_all_emitted_text(): "/" + "etc/service/config.json", "/" + "workspace/fixture/result.json", "access_key=" + "synthetic" * 4, "token:" + "synthetic" * 4, "x" * 500 + " access_key=" + "synthetic" * 4, + "~/evidence.txt", "path:/srv/evidence.txt", + "file:///tmp/evidence.txt", "FILE://server/share/evidence.txt", + "x" * 500 + " ~/evidence.txt", ] for value in unsafe_values: projection = build_goal_artifact_lifecycle_projection( diff --git a/tests/control_plane/test_goal_artifact_work_observation.py b/tests/control_plane/test_goal_artifact_work_observation.py index 3b4f848b0e..fe7fa9f566 100644 --- a/tests/control_plane/test_goal_artifact_work_observation.py +++ b/tests/control_plane/test_goal_artifact_work_observation.py @@ -11,6 +11,51 @@ from loopx.status import collect_status +@pytest.mark.parametrize("locator", [ + "~/evidence.txt", "path:/srv/evidence.txt", "file:///tmp/evidence.txt", +]) +def test_persisted_local_evidence_is_omitted_from_public_lifecycle(tmp_path, locator): + """Read real history through status; keep private source bytes untouched.""" + project, runtime = tmp_path / "project", tmp_path / "runtime" + project.mkdir() + state = project / "ACTIVE_GOAL_STATE.md" + state.write_text("---\nstatus: active\n---\n\n# Goal\n", encoding="utf-8") + registry = project / "registry.json" + registry.write_text(json.dumps({ + "schema_version": 1, "common_runtime_root": str(runtime), + "goals": [{ + "id": "demo", "status": "active", "domain": "software", + "repo": str(project), "state_file": state.name, + "adapter": {"kind": "session_runtime", "status": "connected-read-only"}, + }], + }), encoding="utf-8") + runs = runtime / "goals" / "demo" / "runs" + runs.mkdir(parents=True) + run_path = runs / "run.json" + record = { + "goal_id": "demo", "generated_at": "2026-09-01T00:00:00+00:00", + "classification": "state_refreshed", "delivery_outcome": "outcome_progress", + "recommended_action": locator, "evidence_ref": locator, + "json_path": str(run_path), + } + run_path.write_text(json.dumps(record), encoding="utf-8") + index = runs / "index.jsonl" + index.write_text(json.dumps(record) + "\n", encoding="utf-8") + before = {path: path.read_bytes() for path in (state, registry, run_path, index)} + + result = collect_status( + registry_path=registry, runtime_root_override=str(runtime), + scan_roots=[], limit=5, include_public_boundary_scan=False, + ) + assert result["ok"] is True + lifecycle = result["run_history"]["goals"][0]["artifact_lifecycle"] + milestone = lifecycle["milestones"][0] + assert milestone["label"] == "outcome_progress" + assert milestone["reached_evidence_refs"] == [] + assert locator not in json.dumps(lifecycle) + assert {path: path.read_bytes() for path in before} == before + + @pytest.mark.parametrize("display_limit", [0, 5]) @pytest.mark.parametrize("adapter_kind,include_work_projection", [ ("session_runtime", True), ("session_runtime", False), diff --git a/tests/control_plane/test_public_safe_local_path_owner.py b/tests/control_plane/test_public_safe_local_path_owner.py index 3d932d32fa..2cbfe5f1e7 100644 --- a/tests/control_plane/test_public_safe_local_path_owner.py +++ b/tests/control_plane/test_public_safe_local_path_owner.py @@ -9,15 +9,14 @@ * both sites answer through `loopx/public_safe_text.find_public_safe_local_path` and no longer declare a local-path regex of their own; * the union the owner decides is `LOCAL_PATH_SURFACE_PATTERN`, the two - direction-3 gap shapes and the historical `:`/`=` boundary form, in that + direction-3 gap shapes, the historical `:`/`=` boundary form and file URLs, in that order, so dropping any arm is caught; * nothing the sites rejected before is accepted now (no loosening), and the widening is exactly the direction-3 shapes -- both halves are measured against the shared corpus, so "no silent loosening" is enforced rather than claimed; * each site keeps its own rejection message and length limits; -* `file://` is unchanged here (both sites still reject it through the - raw-remote-location rule), and the general runtime export gate is *not* - tightened by this PR, which `test_runtime_export_gate_is_unchanged` pins. +* `file://` remains rejected, now with the local-path diagnostic; the typed + runtime export gate also consumes this policy for keys and values. """ from __future__ import annotations @@ -138,6 +137,7 @@ def test_owner_recognizes_the_absolute_roots_the_gaps_and_the_boundary_form() -> owner.HOME_RELATIVE_PATH_PATTERN, owner.PATH_PREFIX_LOCAL_PATTERN, owner.LOCAL_PATH_BOUNDARY_REFERENCE_PATTERN, + owner.FILE_URL_LOCAL_PATH_PATTERN, ) @@ -301,22 +301,20 @@ def test_both_sites_keep_their_length_limits_and_clean_verdicts() -> None: "value", ["file:///Users/alex/notes.md", "file://./notes.md"], ) -def test_file_url_verdicts_are_unchanged_and_still_named_as_raw_urls( +def test_file_url_verdicts_are_unchanged_and_named_as_local_paths( value: str, ) -> None: - # The direction-3 `file://` decision is deferred, so these must keep failing - # through the raw-remote-location rule with the site's own wording. - with pytest.raises(ValueError, match="raw URL"): + # Both sites keep rejecting file URLs; the shared local-path policy now + # supplies their existing field-specific local-path diagnostic. + with pytest.raises(ValueError, match=LOCAL_PATH_MESSAGE): material_validation.compact_text(value, field="source_ref") - with pytest.raises(ValueError, match="raw URL"): + with pytest.raises(ValueError, match=LOCAL_PATH_MESSAGE): decision_packets._compact_text(value, field="source_ref") -def test_runtime_export_gate_is_unchanged() -> None: - # Disclosed non-change, pinned so the later tightening has to be a deliberate - # edit here: `validate_public_safe_value` still accepts a home-relative - # reference. Direction 3 decides that per destination, and how many of the - # gate's call sites would newly reject was not measured in this PR. +def test_runtime_export_gate_rejects_local_references_but_preserves_prose() -> None: + with pytest.raises(ValueError, match="target_layout contains a local path"): + public_safety.validate_public_safe_value({"target_layout": "~/.agents/skills"}) public_safety.validate_public_safe_value( - {"target_layout": "~/.agents/skills", "note": "path: is fine without a path"} + {"target_layout": "skills/", "note": "path: is fine without a path"} ) diff --git a/tests/control_plane/test_public_safety_path_shapes.py b/tests/control_plane/test_public_safety_path_shapes.py index f804b7cfbf..5a38729eb9 100644 --- a/tests/control_plane/test_public_safety_path_shapes.py +++ b/tests/control_plane/test_public_safety_path_shapes.py @@ -31,8 +31,36 @@ "id x:y/z", "docs/evidence.md", "access key rotation guide", + "path: is fine without a path", + "profile://example.org/report", ] +EXPORT_LOCAL_REFERENCES = [ + "~/evidence.txt", + "~" + chr(92) + "evidence.txt", + "path:/srv/evidence.txt", + "PATH:" + chr(92) + "evidence.txt", + "file:///tmp/evidence.txt", + "FILE://localhost/share/evidence.txt", + "file://server/share/evidence.txt", + "file://./evidence.txt", + "lookup :/Users/fixture/evidence.txt", +] + + +@pytest.mark.parametrize("value", EXPORT_LOCAL_REFERENCES) +@pytest.mark.parametrize("position", ["scalar", "nested_value", "nested_key"]) +def test_public_export_rejects_local_references_at_every_depth(value, position): + payload = { + "scalar": value, + "nested_value": {"items": ({"summary": value},)}, + "nested_key": {"items": [{value: "safe text"}]}, + }[position] + with pytest.raises(ValueError, match="local path|unsafe field name") as matched: + validate_public_safe_value(payload) + # Diagnostics name the field, without echoing the private locator. + assert value not in str(matched.value) + def _secret(key: str, separator: str, quote: str = "", key_quote: str = "") -> str: """Build a credential-shaped probe without a literal secret assignment."""