diff --git a/src/fi/alk/providers/agent.py b/src/fi/alk/providers/agent.py index 18cf6dbdd..44ecb7657 100644 --- a/src/fi/alk/providers/agent.py +++ b/src/fi/alk/providers/agent.py @@ -233,6 +233,15 @@ def _authored_tool( def _redact(value: Any, key: str = "") -> Any: + refused: list[str] = [] + redacted = _redact_in(value, key, refused) + if refused: + # Once per document, not per string: a crafted contract can hold half a million of them. + logger.warning("a URL was redacted whole: class=%s", refused[0]) + return redacted + + +def _redact_in(value: Any, key: str, refused: list[str]) -> Any: lowered = key.lower() if any(marker in lowered for marker in _REDACTION_MARKERS): return _REDACTED @@ -242,18 +251,19 @@ def _redact(value: Any, key: str = "") -> Any: return _REDACTED if isinstance(value, Mapping): return { - str(item_key): _redact(item, str(item_key)) + str(item_key): _redact_in(item, str(item_key), refused) for item_key, item in value.items() } if isinstance(value, list): - return [_redact(item, key) for item in value] + return [_redact_in(item, key, refused) for item in value] if isinstance(value, str) and "://" in value: try: parsed = urlsplit(value) - except ValueError as refused: + except ValueError as error: # A netloc urlsplit refuses may still hold credentials, so none of it is kept, and # only the error's class is logged: its text repeats the netloc. - logger.warning("a URL was redacted whole: class=%s", type(refused).__name__) + if not refused: + refused.append(type(error).__name__) return _REDACTED if parsed.scheme and parsed.netloc: hostname = parsed.hostname or "" diff --git a/tests/harness/test_agent_reading.py b/tests/harness/test_agent_reading.py index 6cb89b154..a3ef8c60b 100644 --- a/tests/harness/test_agent_reading.py +++ b/tests/harness/test_agent_reading.py @@ -861,6 +861,11 @@ def test_the_longest_stop_leaves_room_for_the_document_inside_the_margin(): ) assert bounds == (10.0, 60.0, 20.0) assert sum(bounds) == 90.0 + # And the reading the platform runs waits after the interrupt for exactly that bound. + assert agent_reading.READ_AGENT.work.func is agent_reading._read + assert agent_reading.READ_AGENT.work.keywords == { + "interrupt_wait": agent_reading._INTERRUPT_WAIT_SECONDS + } # Then the saved contract's document, whose framework scan can take over a minute on a # crafted repository: at least 150 s of the margin stays for it. assert sum(bounds) + 150.0 < agent_reading._HARD_LIMIT_MARGIN_SECONDS diff --git a/tests/providers/test_providers_agent_build.py b/tests/providers/test_providers_agent_build.py index dbc5a92b6..393c6f7eb 100644 --- a/tests/providers/test_providers_agent_build.py +++ b/tests/providers/test_providers_agent_build.py @@ -1,11 +1,12 @@ from __future__ import annotations +import logging from typing import Any import pytest from fi.alk.harness.provider_import import _safe_profile_value -from fi.alk.providers.agent import _redact +from fi.alk.providers.agent import _redact, build_authored_agent PLAIN_DOCUMENTS: list[Any] = [ {"name": "Support line", "voice": {"voiceId": "marissa", "speed": 1.0}}, @@ -55,3 +56,28 @@ def test_added_markers_headers_and_parameters() -> None: "headers": "[redacted]", "url": "https://example.com/x", } + + +def test_many_refused_urls_in_one_document_log_one_line( + caplog: pytest.LogCaptureFixture, +) -> None: + # A full-width number sign NFKC-normalises to "#", and "[" opens an IPv6 host that never + # closes: urlsplit refuses both. + leaky = "https://admin:hunter2\uff03x@db.example/" + refused = [leaky, *(["a://["] * 1000)] + document = { + "agent": "support", + "modality": "chat", + "system_prompt_excerpt": leaky, + "tools": [{"name": "add", "args": ["k"], "arg_values": {"k": refused}}], + } + + with caplog.at_level(logging.DEBUG, logger="fi.alk.providers.agent"): + agent = build_authored_agent(document) + + assert agent["behaviour"][0]["instructions"] == "[redacted]" + assert agent["tools"][0]["parameters"]["properties"]["k"]["enum"] == ["[redacted]"] * 1001 + [logged] = [record for record in caplog.records if record.name == "fi.alk.providers.agent"] + assert logged.getMessage() == "a URL was redacted whole: class=ValueError" + assert logged.exc_info is None + assert "hunter2" not in caplog.text