Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 12 additions & 4 deletions backend/services/agent_registry.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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 {}
Expand Down
55 changes: 55 additions & 0 deletions backend/tests/test_agent_registry.py
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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
45 changes: 45 additions & 0 deletions docs/doctoring/agent-registry-exception-telemetry.md
Original file line number Diff line number Diff line change
@@ -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.