diff --git a/src/agent_runtime_kit/events.py b/src/agent_runtime_kit/events.py index 71f4135..49b5feb 100644 --- a/src/agent_runtime_kit/events.py +++ b/src/agent_runtime_kit/events.py @@ -2,6 +2,7 @@ from __future__ import annotations +import re from collections.abc import Mapping from datetime import datetime, timezone from typing import Any @@ -9,8 +10,23 @@ from agent_runtime_kit._types import AgentResult, AgentRuntimeKind, AgentTask, ToolCallAudit DEFAULT_PREVIEW_CHARS = 1000 -SENSITIVE_KEY_SUBSTRINGS = ("api_key", "apikey", "authorization", "password", "secret") +SENSITIVE_KEY_SUBSTRINGS = ( + "api_key", + "apikey", + "authorization", + "password", + "passwd", + "secret", + "credential", + "private_key", +) SENSITIVE_KEY_SEGMENTS = ("token",) +# Bound recursion so pathological metadata (cycles, extreme nesting) can never turn +# event construction into a RecursionError that aborts run(). +_MAX_SANITIZE_DEPTH = 8 +# Split camelCase so "accessToken"/"refreshToken" normalize to the same segments as +# "access_token", i.e. reach the "token" segment rule below. +_CAMEL_BOUNDARY = re.compile(r"(?<=[a-z0-9])(?=[A-Z])") def task_started_event( @@ -205,18 +221,32 @@ def _event(name: str, summary: str, attributes: Mapping[str, Any]) -> dict[str, } -def _sanitize(value: Any, *, key: str | None = None) -> Any: +def _sanitize( + value: Any, + *, + key: str | None = None, + _depth: int = 0, + _seen: frozenset[int] = frozenset(), +) -> Any: if key is not None and _is_sensitive_key(key): return "[redacted]" - if isinstance(value, Mapping): - return { - str(item_key): _sanitize(item_value, key=str(item_key)) - for item_key, item_value in value.items() - } - if isinstance(value, tuple): - return tuple(_sanitize(item) for item in value) - if isinstance(value, list): - return [_sanitize(item) for item in value] + if _depth >= _MAX_SANITIZE_DEPTH: + return "[truncated: max depth]" + if isinstance(value, Mapping | list | tuple): + marker = id(value) + if marker in _seen: + return "[truncated: cycle]" + seen = _seen | {marker} + if isinstance(value, Mapping): + return { + str(item_key): _sanitize( + item_value, key=str(item_key), _depth=_depth + 1, _seen=seen + ) + for item_key, item_value in value.items() + } + if isinstance(value, tuple): + return tuple(_sanitize(item, _depth=_depth + 1, _seen=seen) for item in value) + return [_sanitize(item, _depth=_depth + 1, _seen=seen) for item in value] if isinstance(value, str): preview, truncated = _preview(value) return f"{preview}...[truncated]" if truncated else preview @@ -230,8 +260,16 @@ def _preview(text: str) -> tuple[str, bool]: def _is_sensitive_key(key: str) -> bool: - lowered = key.lower().replace("-", "_") - if any(part in lowered for part in SENSITIVE_KEY_SUBSTRINGS): + # Normalize camelCase and dashes to underscores so "accessToken", "access-token", + # and "access_token" are treated identically. + normalized = _CAMEL_BOUNDARY.sub("_", key).lower().replace("-", "_") + if any(part in normalized for part in SENSITIVE_KEY_SUBSTRINGS): + return True + segments = normalized.split("_") + if any(segment in segments for segment in SENSITIVE_KEY_SEGMENTS): return True - segments = lowered.split("_") - return any(segment in segments for segment in SENSITIVE_KEY_SEGMENTS) + # Smashed-case keys with no separator (e.g. "accesstoken", "ACCESSTOKEN") never + # split into a bare segment, so also match a sensitive segment as a trailing + # suffix. This still keeps plural usage counters visible ("inputtokens" ends with + # "tokens", not "token"). + return any(normalized.endswith(segment) for segment in SENSITIVE_KEY_SEGMENTS) diff --git a/tests/test_events.py b/tests/test_events.py index 134de8f..9b4a9ae 100644 --- a/tests/test_events.py +++ b/tests/test_events.py @@ -82,6 +82,88 @@ def test_events_redact_sensitive_metadata_and_truncate_text() -> None: assert attrs["task_goal"].endswith("...[truncated]") +def test_events_redact_camelcase_secret_keys() -> None: + event = task_started_event( + AgentTask( + goal="x", + metadata={ + "accessToken": "a", + "refreshToken": "b", + "authToken": "c", + "apiKey": "d", + # camelCase "token" plural is a count, not a secret -> keep it. + "inputTokens": 7, + # deliberately-emitted non-secret label must survive. + "auth_source": "anthropic-api-key", + "session_id": "sess-1", + }, + ), + "fake", + ) + + metadata = event["attributes"]["metadata"] + assert metadata["accessToken"] == "[redacted]" + assert metadata["refreshToken"] == "[redacted]" + assert metadata["authToken"] == "[redacted]" + assert metadata["apiKey"] == "[redacted]" + assert metadata["inputTokens"] == 7 + assert metadata["auth_source"] == "anthropic-api-key" + assert metadata["session_id"] == "sess-1" + + +def test_events_redact_smashed_case_token_keys() -> None: + event = task_started_event( + AgentTask( + goal="x", + metadata={ + # No separator and no camelCase boundary: these never split into a + # bare "token" segment, so they slipped through before the suffix rule. + "accesstoken": "a", + "ACCESSTOKEN": "b", + "sessiontoken": "c", + "token": "d", + # Smashed plural counts stay visible ("...tokens", not "token"). + "inputtokens": 11, + "totaltokens": 22, + }, + ), + "fake", + ) + + metadata = event["attributes"]["metadata"] + assert metadata["accesstoken"] == "[redacted]" + assert metadata["ACCESSTOKEN"] == "[redacted]" + assert metadata["sessiontoken"] == "[redacted]" + assert metadata["token"] == "[redacted]" + assert metadata["inputtokens"] == 11 + assert metadata["totaltokens"] == 22 + + +def test_event_construction_survives_cyclic_metadata() -> None: + cyclic: dict[str, object] = {"self": None} + cyclic["self"] = cyclic + + # A cycle in user metadata must not raise (RecursionError) out of the builder, + # which runs before safe_emit's guard and would otherwise abort run(). + event = task_started_event(AgentTask(goal="x", metadata={"loop": cyclic}), "fake") + + assert event["attributes"]["metadata"]["loop"]["self"] == "[truncated: cycle]" + + +def test_event_construction_bounds_deep_metadata() -> None: + node: dict[str, object] = {} + root = node + for _ in range(50): + child: dict[str, object] = {} + node["child"] = child + node = child + + event = task_started_event(AgentTask(goal="x", metadata={"deep": root}), "fake") + + # Somewhere within the depth bound the recursion is cut off cleanly. + assert "truncated: max depth" in repr(event["attributes"]["metadata"]) + + @pytest.mark.asyncio async def test_fake_sdk_harness_simulates_success_with_tools() -> None: sink = RecordingEventSink()