diff --git a/agent_assembly/client/dispatch.py b/agent_assembly/client/dispatch.py index e238797..8ce4458 100644 --- a/agent_assembly/client/dispatch.py +++ b/agent_assembly/client/dispatch.py @@ -6,7 +6,10 @@ from typing import Any -@dataclass(frozen=True) +# ``repr=False``: ``resolved_args`` holds resolved secret values, so the +# dataclass-generated ``__repr__`` (which renders every field verbatim) would +# leak them into logs/tracebacks. We supply a redacting ``__repr__`` instead. +@dataclass(frozen=True, repr=False) class DispatchToolResult: """ Outcome of a successful ``dispatch_tool`` call. @@ -17,7 +20,9 @@ class DispatchToolResult: Attributes: resolved_args: Post-substitution args. Carries the *resolved* - credential values; do not log this or pass it to the LLM. + credential values; do not log this or pass it to the LLM. The + ``repr``/``str`` of this object deliberately masks these values + (key names and count only) so they cannot leak via logging. names_substituted: The placeholder names that were resolved during this call. Names only — never the resolved values. Echoes the audit-log shape so callers can correlate dispatches with audit @@ -26,3 +31,19 @@ class DispatchToolResult: resolved_args: dict[str, Any] names_substituted: list[str] = field(default_factory=list) + + def __repr__(self) -> str: + """Render without exposing resolved secret values. + + Shows the argument *keys* and their count in place of the resolved + values, so the result can be safely logged or appear in tracebacks. + ``names_substituted`` is names-only by contract and is shown verbatim. + """ + keys = sorted(self.resolved_args) + return ( + "DispatchToolResult(" + f"resolved_args=, " + f"names_substituted={self.names_substituted!r})" + ) + + __str__ = __repr__ diff --git a/test/unit/client/test_dispatch_tool.py b/test/unit/client/test_dispatch_tool.py index 204b3d0..1705999 100644 --- a/test/unit/client/test_dispatch_tool.py +++ b/test/unit/client/test_dispatch_tool.py @@ -97,3 +97,21 @@ async def test_dispatch_tool_defaults_empty_result_fields_when_server_omits_them assert result.resolved_args == {} assert result.names_substituted == [] + + +def test_dispatch_tool_result_repr_does_not_leak_resolved_secret_values() -> None: + """repr/str must mask resolved_args values, exposing only keys (CWE-532).""" + secret = "sk-live-super-secret-token-9f8e7d" + result = DispatchToolResult( + resolved_args={"api_key": secret, "endpoint": "https://api.example.com"}, + names_substituted=["api_key"], + ) + + for rendered in (repr(result), str(result)): + assert secret not in rendered + # Key names and a count are shown so the object stays debuggable. + assert "api_key" in rendered + assert "endpoint" in rendered + assert "2 value(s)" in rendered + # names_substituted is names-only by contract; safe to render verbatim. + assert "names_substituted=['api_key']" in rendered