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
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -145,6 +145,8 @@ agentcat.track(server, "proj_0000000", AgentCatOptions(redact_sensitive_informat

For redaction decisions that need more context than a single string — such as which tool was called or what type of event is being published — use the event-level `redact_event` hook. It receives the full event and returns a modified event, or `None` to drop the event entirely. It may be sync or async, runs before `redact_sensitive_information` (so it sees raw values), and can be combined with it.

Mutate the event you're given and return it, as in the example below — don't construct or return a different `Event`. The return value replaces the event's fields wholesale, not as a diff against the original, so returning a partial or freshly-built event will silently drop every field you didn't set.

```python
from agentcat import AgentCatOptions

Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[project]
name = "agentcat"
version = "2.0.1"
version = "2.0.2"
description = "Analytics tool for MCP (Model Context Protocol) servers, Claude Connectors, and ChatGPT Plugins - tracks tool usage patterns and provides insights"
authors = [
{ name = "AgentCat, Inc.", email = "support@agentcat.com" },
Expand Down
14 changes: 9 additions & 5 deletions src/agentcat/modules/event_queue.py
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,12 @@ class _Stop:
_STOP = _Stop()


def _ensure_event_id(event: UnredactedEvent) -> None:
"""Assign a ksuid if the event doesn't already have one."""
if not event.id:
event.id = generate_prefixed_ksuid(EVENT_ID_PREFIX)


class EventQueue:
"""Manages event queue and sending to AgentCat API."""

Expand Down Expand Up @@ -159,8 +165,7 @@ def _process_event(self, event: UnredactedEvent) -> None:
# Event-level redaction hook runs first, on raw values, and may
# drop the event entirely.
try:
if not event.id:
event.id = generate_prefixed_ksuid(EVENT_ID_PREFIX)
_ensure_event_id(event)
redacted_event = apply_event_redaction(event, event.event_redaction_fn)
if redacted_event is None:
write_to_log(f"Event {event.id} dropped by redact_event hook")
Expand All @@ -179,8 +184,7 @@ def _process_event(self, event: UnredactedEvent) -> None:
if event and event.redaction_fn:
# Redact sensitive information if a redaction function is provided
try:
if not event.id:
event.id = generate_prefixed_ksuid(EVENT_ID_PREFIX)
_ensure_event_id(event)
redacted_event = redact_event(event, event.redaction_fn)
# The redacted event is already the full event object, not a dict
event = redacted_event
Expand All @@ -207,7 +211,7 @@ def _process_event(self, event: UnredactedEvent) -> None:
)

if event:
event.id = event.id or generate_prefixed_ksuid("evt")
_ensure_event_id(event)

# Send to AgentCat API only if project_id exists
if event.project_id:
Expand Down
50 changes: 42 additions & 8 deletions src/agentcat/modules/redaction.py
Original file line number Diff line number Diff line change
@@ -1,11 +1,12 @@
"""PII redaction for AgentCat logs."""

import copy
from typing import Any, TYPE_CHECKING, Callable, Set

from agentcat.modules.hooks import drive_hook_result

if TYPE_CHECKING:
from agentcat.types import Event, UnredactedEvent
from agentcat.types import Event, RedactEventFunction, UnredactedEvent


# Set of field names that should be protected from redaction.
Expand Down Expand Up @@ -158,37 +159,50 @@ def redact_event(event: "UnredactedEvent", redact_fn: Callable[[str], str]) -> "


def _sync_event_redactor(
redact_event_fn: Callable[["Event"], Any],
) -> Callable[["Event"], Any]:
redact_event_fn: "RedactEventFunction",
) -> Callable[["Event"], "Event | None"]:
"""A synchronous view of the customer's event-level redaction hook.

Same rationale as `_sync_redactor`: the publish worker is a thread with no
event loop of its own, so an async hook's result has to be driven to
completion here rather than assigned straight into the pipeline.
"""

def run(event: "Event") -> Any:
def run(event: "Event") -> "Event | None":
return drive_hook_result(redact_event_fn(event), "redact_event")

return run


# Fields restored from the original event after the event-level redaction
# hook runs, regardless of what the hook returns. These are system-managed —
# not consumer-settable — mirroring RESTORED_FIELDS in the TypeScript SDK and
# the equivalent snapshot/restore around ApplyEventRedaction in the Go SDK.
# not consumer-settable. Broader than the equivalent RESTORED_FIELDS in the
# TypeScript SDK and the snapshot/restore around ApplyEventRedaction in the Go
# SDK, which cover only id/session_id/project_id/event_type/timestamp and so
# still let a hook forge or erase actor identity, client/server identity,
# tags, and properties.
RESTORED_FIELDS: Set[str] = {
"id",
"session_id",
"project_id",
"event_type",
"timestamp",
"actor_id",
"identify_actor_given_id",
"identify_actor_name",
"identify_data",
"tags",
"properties",
"client_name",
"client_version",
"server_name",
"server_version",
}


def apply_event_redaction(
event: "UnredactedEvent",
redact_event_fn: Callable[["Event"], Any],
redact_event_fn: "RedactEventFunction",
) -> "UnredactedEvent | None":
"""
Applies the customer's event-level redaction hook to an event.
Expand All @@ -199,6 +213,20 @@ def apply_event_redaction(
force-restored from the original event afterward, so a hook cannot forge
or erase what AgentCat itself assigned.

`RedactEventFunction` (see types.py) declares the hook returns a pydantic
`Event | None` — mutate and return the `Event` you were handed, not a
different object. That contract is enforced by type hints only, not at
runtime: `redact_event_fn` is called and its result is trusted to be
`Event`-shaped, so a hook that ignores its type hints and returns
something else (e.g. a plain dict) raises `AttributeError` here rather
than degrading gracefully. Type-check hooks against `RedactEventFunction`
(mypy/pyright) to catch this before it ships.

The return value is also applied as a wholesale replacement of the
event's fields (`result.model_dump()`, not a diff against the original),
so a freshly-built or partial `Event` — even though it satisfies the type
hint — will silently null out every field the hook didn't set.

The hook is handed a plain `Event` built from the dump, excluding
`redaction_fn`/`event_redaction_fn` — they are machinery, not event data,
and the customer's hook must not be handed its own function objects.
Expand All @@ -224,7 +252,13 @@ def apply_event_redaction(
if result is None:
return None

restored = {field: getattr(event, field) for field in RESTORED_FIELDS}
# Deep-copied, not aliased: tags/properties/identify_data are mutable, and
# a customer callback commonly hands back the same cached dict across
# events — aliasing here would let mutating one event's dict silently
# corrupt another event that shares the reference.
restored = {
field: copy.deepcopy(getattr(event, field)) for field in RESTORED_FIELDS
}
updated = result.model_dump(warnings=False)
updated.update(restored)
redacted: UnredactedEvent = event.model_copy(update=updated)
Expand Down
27 changes: 20 additions & 7 deletions src/agentcat/types.py
Original file line number Diff line number Diff line change
Expand Up @@ -39,9 +39,17 @@
# Type alias for redaction function
RedactionFunction = Callable[[str], str | Awaitable[str]]
# Type alias for the event-level redaction hook. Receives the full event
# (raw, unredacted values — it runs before RedactionFunction) and returns a
# modified event, or None to drop the event entirely. Accepts sync or async
# callables (mirrors RedactionFunction).
# (raw, unredacted values — it runs before RedactionFunction) and MUST return
# a pydantic Event or None to drop the event entirely — never a dict or other
# object, even though one might satisfy a looser hand-written signature. This
# is enforced by type checking only: type-check hooks against
# RedactEventFunction (mypy/pyright), since a hook that returns something
# else at runtime fails inside apply_event_redaction rather than degrading
# gracefully. Accepts sync or async callables (mirrors RedactionFunction).
# Mutate and return the event you're handed — the return value replaces the
# event's fields wholesale, not a diff, so a freshly-built or partial event
# silently loses every field it didn't set. See the redact_event option on
# AgentCatOptions for the full contract.
RedactEventFunction = Callable[
["Event"], Optional["Event"] | Awaitable[Optional["Event"]]
]
Expand Down Expand Up @@ -213,10 +221,15 @@ class AgentCatOptions:
# Event-level redaction hook, invoked with the full event (raw, unredacted
# values) before redact_sensitive_information runs. May return a modified
# event, or None to drop the event entirely. May be sync or async. The
# system-managed fields id, session_id, project_id, event_type, and
# timestamp cannot be changed by this hook — they are restored from the
# original event afterward. If the hook raises (or times out), the event
# is dropped rather than published unredacted.
# system-managed fields id, session_id, project_id, event_type,
# timestamp, actor_id, identify_actor_given_id, identify_actor_name,
# identify_data, tags, and properties cannot be changed by this hook —
# they are restored from the original event afterward. If the hook
# raises, the event is dropped rather than published unredacted.
# Mutate and return the event you're given rather than constructing a new
# one: the return value replaces the event's fields wholesale (it is not
# diffed against the original), so a freshly-built or partial event
# silently drops everything you didn't set.
redact_event: RedactEventFunction | None = None
exporters: dict[str, ExporterConfig] | None = None
# Debug logging to ~/agentcat.log. Tri-state: None (the default) defers to
Expand Down
32 changes: 31 additions & 1 deletion tests/test_event_queue.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
"""Test event queue functionality."""

import queue
import sys
import threading
import time
from datetime import datetime, timezone
Expand Down Expand Up @@ -287,7 +288,13 @@ def test_process_event_with_event_redaction(self, mock_apply):
session_id="session-123",
timestamp=datetime.now(timezone.utc),
resource_name="dropped-and-replaced",
event_redaction_fn=None, # This should be cleared after redaction
# A real (unmocked) apply_event_redaction preserves the original
# hook on its returned copy — model_copy(update=...) never
# touches event_redaction_fn, since it's excluded from the dump
# and isn't in RESTORED_FIELDS. Mirroring that here means the
# `is None` assertion below actually exercises _process_event's
# own clearing line rather than just echoing the fixture.
event_redaction_fn=mock_event_redaction_fn,
)
mock_apply.return_value = redacted_event

Expand Down Expand Up @@ -357,6 +364,29 @@ def test_process_event_event_redaction_failure(self, mock_log, mock_apply):
assert "test-id" in log_message
mock_send.assert_not_called()

def test_process_event_event_redaction_hook_sys_exit_does_not_kill_worker(self):
"""A redact_event hook calling sys.exit() must not propagate out of
_process_event — SystemExit doesn't subclass Exception, so it needs
its own except clause, and _worker's own `except Exception` wrapper
would not have caught it either."""
eq = EventQueue()

def exits(event):
sys.exit(1)

event = UnredactedEvent(
id="test-id",
event_type="mcp:tools/call",
project_id="project-123",
session_id="session-123",
timestamp=datetime.now(timezone.utc),
event_redaction_fn=exits,
)

with patch.object(eq, "_send_event") as mock_send:
eq._process_event(event) # must not raise SystemExit
mock_send.assert_not_called()

def test_process_event_event_hook_runs_before_string_hook(self):
"""Ordering: the event hook must see raw values, before the string
hook ever runs — nothing mocked, both hooks wired for real."""
Expand Down
Loading
Loading