diff --git a/docs/public-private-boundary.md b/docs/public-private-boundary.md index bf162e123..ae9e00bc3 100644 --- a/docs/public-private-boundary.md +++ b/docs/public-private-boundary.md @@ -145,6 +145,12 @@ raw uploaded files, screenshots with private data, unredacted logs, hidden provider payloads, or a public artifact that points back to private storage ``` +Structured public-output mappings use string field names, as JSON does. +`validate_public_safe_value` rejects a non-string key before classifying the +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. + ## 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 845f88e92..5697d8e5a 100644 --- a/loopx/control_plane/runtime/public_safety.py +++ b/loopx/control_plane/runtime/public_safety.py @@ -92,14 +92,19 @@ def validate_public_safe_value( ) -> None: """Fail closed for private material in a typed public-output payload. - 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. + 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. """ if isinstance(value, Mapping): for key, item in value.items(): - key_text = str(key) + if not isinstance(key, str): + raise ValueError( + f"{path} contains a non-string field name ({type(key).__name__})" + ) + key_text = key if LOCAL_PATH_SURFACE_PATTERN.search( key_text ) or SECRET_LIKE_SURFACE_PATTERN.search(key_text): diff --git a/tests/control_plane/test_public_safety_field_name_spelling.py b/tests/control_plane/test_public_safety_field_name_spelling.py index 6a49799b9..ca204b495 100644 --- a/tests/control_plane/test_public_safety_field_name_spelling.py +++ b/tests/control_plane/test_public_safety_field_name_spelling.py @@ -92,3 +92,18 @@ def test_nested_and_listed_payloads_keep_naming_the_callers_key() -> None: with pytest.raises(ValueError) as listed: validate_public_safe_value([{"accessToken": "synthetic"}], path="p") assert str(listed.value) == "p[0].accessToken is a credential-bearing field" + + +@pytest.mark.parametrize("key", [b"raw", b"api_key", b"safe", 0]) +def test_non_string_field_names_fail_closed_before_classification(key: object) -> None: + with pytest.raises(ValueError) as raised: + validate_public_safe_value({key: "synthetic"}, path="p") + assert str(raised.value) == ( + f"p contains a non-string field name ({type(key).__name__})" + ) + + +def test_nested_non_string_field_name_reports_its_container() -> None: + with pytest.raises(ValueError) as raised: + validate_public_safe_value({"outer": [{b"raw": "synthetic"}]}, path="p") + assert str(raised.value) == "p.outer[0] contains a non-string field name (bytes)"