Skip to content
Merged
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
68 changes: 53 additions & 15 deletions src/agent_runtime_kit/events.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,15 +2,31 @@

from __future__ import annotations

import re
from collections.abc import Mapping
from datetime import datetime, timezone
from typing import Any

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(
Expand Down Expand Up @@ -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
Expand All @@ -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)
82 changes: 82 additions & 0 deletions tests/test_events.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
Loading