From 5fa1c7ddd7eb4b96589a0b1ea012859702ab2769 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 17 Sep 2026 00:45:44 +0900 Subject: [PATCH 1/3] fix(logging): redact agent registry exception telemetry --- backend/services/agent_registry.py | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/backend/services/agent_registry.py b/backend/services/agent_registry.py index bf8d94a72..04312fb58 100644 --- a/backend/services/agent_registry.py +++ b/backend/services/agent_registry.py @@ -20,6 +20,8 @@ from pathlib import Path from typing import Any +from core.safe_logging import redacted_exception_info + logger = logging.getLogger(__name__) # backend/services/agent_registry.py -> parents[2] is the repository root. @@ -89,14 +91,20 @@ def _load_json_object(path: Path) -> dict[str, Any]: except FileNotFoundError: logger.debug("Registration file not found: %s", path) return {} - except OSError: - logger.debug("Could not read registration file: %s", path, exc_info=True) + except OSError as exc: + logger.debug( + "Could not read registration file", + exc_info=redacted_exception_info(exc), + ) return {} try: parsed = json.loads(text or "{}") - except json.JSONDecodeError: - logger.debug("Malformed registration file: %s", path, exc_info=True) + except json.JSONDecodeError as exc: + logger.debug( + "Malformed registration file", + exc_info=redacted_exception_info(exc), + ) return {} return parsed if isinstance(parsed, dict) else {} From 858f87ee665fbf7e97fe56ddc5595102a9a33918 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 17 Sep 2026 00:46:03 +0900 Subject: [PATCH 2/3] test(logging): prove agent registry redaction --- backend/tests/test_agent_registry.py | 55 ++++++++++++++++++++++++++++ 1 file changed, 55 insertions(+) diff --git a/backend/tests/test_agent_registry.py b/backend/tests/test_agent_registry.py index 0f451444d..53938e909 100644 --- a/backend/tests/test_agent_registry.py +++ b/backend/tests/test_agent_registry.py @@ -1,5 +1,9 @@ """Tests for the workspace agent registry loader.""" +import logging +from pathlib import Path + +from services import agent_registry from services.agent_registry import ( clear_registry_cache, get_registered_agent, @@ -45,3 +49,54 @@ def test_task_mapping_resolves_to_noema_agent(): def test_unknown_task_type_resolves_to_none(): assert resolve_agent_for_task("does-not-exist") is None + + +def test_registry_read_failure_redacts_exception_value_and_path( + caplog, monkeypatch, tmp_path +): + secret_path = tmp_path / "OPENAI_API_KEY=sk-test-secret" / "registered_agents.json" + secret_error = ( + "postgresql://operator:password@db.internal/naruon " + "OPENAI_API_KEY=sk-test-secret" + ) + + def raise_read_error(self: Path, *args, **kwargs) -> str: + raise OSError(secret_error) + + monkeypatch.setattr(Path, "read_text", raise_read_error) + + with caplog.at_level(logging.DEBUG, logger=agent_registry.__name__): + assert agent_registry._load_json_object(secret_path) == {} + + formatter = logging.Formatter("%(levelname)s %(name)s %(message)s") + rendered = "\n".join(formatter.format(record) for record in caplog.records) + + assert "Could not read registration file" in rendered + assert "OSError" in rendered + assert "exception_fingerprint=" in rendered + assert secret_error not in rendered + assert "sk-test-secret" not in rendered + assert "operator:password" not in rendered + assert str(secret_path) not in rendered + + +def test_registry_parse_failure_redacts_payload_and_path(caplog, monkeypatch, tmp_path): + secret_path = tmp_path / "provider-token-sk-test-secret" / "task_agent_mapping.json" + malformed_payload = '{"provider_token":"sk-test-secret",' + + def return_malformed_payload(self: Path, *args, **kwargs) -> str: + return malformed_payload + + monkeypatch.setattr(Path, "read_text", return_malformed_payload) + + with caplog.at_level(logging.DEBUG, logger=agent_registry.__name__): + assert agent_registry._load_json_object(secret_path) == {} + + formatter = logging.Formatter("%(levelname)s %(name)s %(message)s") + rendered = "\n".join(formatter.format(record) for record in caplog.records) + + assert "Malformed registration file" in rendered + assert "JSONDecodeError" in rendered + assert "exception_fingerprint=" in rendered + assert "sk-test-secret" not in rendered + assert str(secret_path) not in rendered From fc56d609045e47093fdf455234121eb7cfe31715 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 17 Sep 2026 00:46:24 +0900 Subject: [PATCH 3/3] docs(logging): trace agent registry sink migration --- .../agent-registry-exception-telemetry.md | 45 +++++++++++++++++++ 1 file changed, 45 insertions(+) create mode 100644 docs/doctoring/agent-registry-exception-telemetry.md diff --git a/docs/doctoring/agent-registry-exception-telemetry.md b/docs/doctoring/agent-registry-exception-telemetry.md new file mode 100644 index 000000000..db404f1d3 --- /dev/null +++ b/docs/doctoring/agent-registry-exception-telemetry.md @@ -0,0 +1,45 @@ +# Agent registry exception telemetry boundary + +## Scope + +This document records the sink-specific follow-up to #1698 on the `backend/services/agent_registry.py` registry loader. The shared confidentiality contract is owned by #1700 on top of #1612; this lane only migrates the agent-registry read and JSON-parse failure sinks to that contract. + +## Finding + +The registry loader previously used ordinary `exc_info=True` for `OSError` and `json.JSONDecodeError`. Python logging can render the original exception value and traceback from that flag. At the same time, the log message included the registry path. A filesystem/provider failure can therefore disclose exception-carried credentials, connection strings, internal paths, or parser context into a normal production log sink. + +The `FileNotFoundError` branch is intentionally unchanged. It does not attach exception information and identifies a deterministic missing registration file for operator diagnosis. This repair does not broaden into unrelated registry behavior. + +## Decision + +The two exception-bearing sinks now call `core.safe_logging.redacted_exception_info(error)`, inherited from #1700. The normal log record preserves: + +- a bounded operation label (`Could not read registration file` or `Malformed registration file`); +- the exception class; +- the shared one-way failure-site fingerprint. + +The record does not carry `str(error)`, `repr(error)`, the raw traceback, or the registry path in these failure cases. The fingerprint remains build-scoped rather than a cross-version durable identifier, matching #1700. + +Rejected alternatives: + +- keeping `exc_info=True`: retains the original exception value and traceback; +- interpolating the path while redacting only the exception: still exposes internal path material unnecessarily at this sink; +- dropping exception correlation entirely: weakens operational RCA without a confidentiality benefit over the shared structured contract; +- copying or forking the helper: would create a second telemetry contract instead of consuming the #1700 owner path. + +## Verification + +Focused tests drive the real `logging.Formatter` rather than checking only `LogRecord.getMessage()`. The read-failure case injects an API-key-shaped value, a PostgreSQL connection string, and a secret-bearing path into an `OSError`. The malformed-JSON case injects token-shaped content and a secret-bearing path. Both require the operation label, exception class, and `exception_fingerprint=` while rejecting the injected values and paths from rendered output. + +No public/API response contract changes in this slice. + +## Traceability + +- Naruon issue #1698 — repository exception-log sink inventory and migration acceptance. +- Naruon PR #1700 — canonical structured exception telemetry helper and confidentiality contract. +- MITRE. (n.d.). *CWE-532: Insertion of Sensitive Information into Log File*. https://cwe.mitre.org/data/definitions/532.html +- OWASP Foundation. (n.d.). *Logging Cheat Sheet*. https://cheatsheetseries.owasp.org/cheatsheets/Logging_Cheat_Sheet.html + +## Integration gate + +This lane remains stacked on #1700. It is not merge authority until the parent security lineage reaches protected ancestry and the final unchanged head obtains every then-live required hosted check plus qualifying independent review. No temporary retarget, copied workflow, synthetic status, dummy commit, force push, destructive rebase, self-approval, or gate weakening is acceptable evidence.