computer-agent-py 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,127 @@
1
+ """``GuardrailFilter`` — generic transform/drop middleware.
2
+
3
+ Capabilities:
4
+
5
+ * truncate over-long string values in payload (``max_attribute_length``)
6
+ * drop tool-related events for tool names outside an allowlist (glob-aware)
7
+ * enforce a running per-session cost ceiling
8
+ * invoke a user-supplied async content filter
9
+
10
+ The cost ceiling is enforced *across events* by tracking per-session running
11
+ cost in a small in-memory map. The map shrinks on each ``session_ended`` so it
12
+ doesn't grow without bound.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import fnmatch
18
+ import logging
19
+ from collections.abc import Awaitable, Callable
20
+ from typing import TYPE_CHECKING
21
+
22
+ if TYPE_CHECKING:
23
+ from ..event import TelemetryEvent
24
+
25
+ logger = logging.getLogger("computeragent.telemetry")
26
+
27
+ ContentFilter = Callable[["TelemetryEvent"], Awaitable["TelemetryEvent | None"]]
28
+
29
+
30
+ class GuardrailFilter:
31
+ """Apply size, scope, and cost guardrails before events reach sinks."""
32
+
33
+ def __init__(
34
+ self,
35
+ *,
36
+ max_attribute_length: int | None = 8192,
37
+ tool_name_allowlist: set[str] | None = None,
38
+ cost_ceiling_usd: float | None = None,
39
+ content_filter: ContentFilter | None = None,
40
+ ) -> None:
41
+ self.max_attribute_length = max_attribute_length
42
+ self.tool_name_allowlist = tool_name_allowlist
43
+ self.cost_ceiling_usd = cost_ceiling_usd
44
+ self.content_filter = content_filter
45
+ # session_id → running cost. Cleared on session_ended.
46
+ self._cost_by_session: dict[str, float] = {}
47
+ # Sessions that have already tripped the ceiling — drop further events
48
+ # from them until session_ended.
49
+ self._tripped: set[str] = set()
50
+
51
+ async def __call__(self, event: TelemetryEvent) -> TelemetryEvent | None:
52
+ # Remember the original kind so cleanup logic at the end can tell a
53
+ # real session_ended apart from a synthetic one we minted here.
54
+ original_kind = event.kind
55
+ synthesized_session_ended = False
56
+
57
+ # 1. Drop entirely if this session has already tripped the budget.
58
+ if event.session_id in self._tripped and event.kind != "session_ended":
59
+ return None
60
+
61
+ # 2. Tool-name allowlist: only applies to tool_use / tool_result.
62
+ if self.tool_name_allowlist is not None and event.kind in {"tool_use", "tool_result"}:
63
+ name = event.payload.get("tool_name") or event.payload.get("tool_use_id") or ""
64
+ if not _matches_any(name, self.tool_name_allowlist):
65
+ return None
66
+
67
+ # 3. Cost ceiling. usage_snapshot carries the running total cost.
68
+ if self.cost_ceiling_usd is not None and event.kind == "usage_snapshot":
69
+ cost = float(event.payload.get("total_cost_usd", 0.0) or 0.0)
70
+ self._cost_by_session[event.session_id] = cost
71
+ if cost > self.cost_ceiling_usd:
72
+ self._tripped.add(event.session_id)
73
+ # Transform this event into a synthetic session_ended so a
74
+ # sink consumer still sees a terminal event, then forward it.
75
+ event.kind = "session_ended"
76
+ event.payload = {
77
+ "is_error": True,
78
+ "subtype": "budget_exceeded",
79
+ "total_cost_usd": cost,
80
+ "cost_ceiling_usd": self.cost_ceiling_usd,
81
+ "result": "",
82
+ "num_turns": 0,
83
+ "duration_ms": 0.0,
84
+ "usage": event.payload.get("usage", {}),
85
+ }
86
+ synthesized_session_ended = True
87
+
88
+ # 4. Truncate long strings in payload (cheap; modifies in place).
89
+ if self.max_attribute_length is not None:
90
+ truncated = _truncate(event.payload, self.max_attribute_length)
91
+ assert isinstance(truncated, dict) # _truncate preserves shape for dict in → dict out
92
+ event.payload = truncated
93
+
94
+ # 5. User-supplied filter — last call so it sees the final payload.
95
+ if self.content_filter is not None:
96
+ try:
97
+ filtered: TelemetryEvent | None = await self.content_filter(event)
98
+ if filtered is None:
99
+ return None
100
+ event = filtered
101
+ except Exception: # noqa: BLE001
102
+ logger.warning("content_filter raised; dropping event", exc_info=True)
103
+ return None
104
+
105
+ # 6. session_ended bookkeeping — only on a REAL terminal event.
106
+ # A synthetic session_ended we minted from the trip must keep
107
+ # _tripped state set, so further events from that session are dropped.
108
+ if original_kind == "session_ended" and not synthesized_session_ended:
109
+ self._cost_by_session.pop(event.session_id, None)
110
+ self._tripped.discard(event.session_id)
111
+
112
+ return event
113
+
114
+
115
+ def _matches_any(name: str, allowlist: set[str]) -> bool:
116
+ """Glob match — ``mcp__*`` and ``Read``-style literals both supported."""
117
+ return any(fnmatch.fnmatchcase(name, pat) for pat in allowlist)
118
+
119
+
120
+ def _truncate(value: object, limit: int) -> object:
121
+ if isinstance(value, dict):
122
+ return {k: _truncate(v, limit) for k, v in value.items()}
123
+ if isinstance(value, list):
124
+ return [_truncate(v, limit) for v in value]
125
+ if isinstance(value, str) and len(value) > limit:
126
+ return value[:limit] + f"…(+{len(value) - limit} chars)"
127
+ return value
@@ -0,0 +1,158 @@
1
+ """``PiiRedactor`` — pattern-based redaction over ``event.payload``.
2
+
3
+ Built-in patterns cover the most common shapes we don't want shipping to a
4
+ remote observability backend by default: email, US phone, SSN, generic
5
+ credit-card-shaped digit runs, and AWS access key IDs / secret keys. The user
6
+ appends their own patterns via ``extra_patterns=`` and steers redaction by
7
+ path with ``path_allowlist=`` / ``path_denylist=``.
8
+
9
+ Fail-closed: a bad pattern or an exception inside the substitution drops the
10
+ event rather than forwarding possibly-unredacted content.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import hashlib
16
+ import logging
17
+ import re
18
+ from typing import TYPE_CHECKING, Any, Literal
19
+
20
+ if TYPE_CHECKING:
21
+ from ..event import TelemetryEvent
22
+
23
+ logger = logging.getLogger("computeragent.telemetry")
24
+
25
+
26
+ # Conservative built-in patterns — high precision, low recall. Users add their
27
+ # own (badge IDs, internal account numbers, …) via extra_patterns.
28
+ BUILTIN_PATTERNS: tuple[re.Pattern[str], ...] = (
29
+ # email — RFC 5322 simplified
30
+ re.compile(r"[A-Za-z0-9._%+\-]+@[A-Za-z0-9.\-]+\.[A-Za-z]{2,}"),
31
+ # US phone with optional country code; matches "+1 555-123-4567",
32
+ # "(555) 123-4567", "555.123.4567", etc.
33
+ re.compile(r"(?:\+?1[\s\-.])?\(?\d{3}\)?[\s\-.]\d{3}[\s\-.]\d{4}"),
34
+ # US SSN
35
+ re.compile(r"\b\d{3}-\d{2}-\d{4}\b"),
36
+ # Credit card shaped — 13–19 digit runs with optional separators.
37
+ re.compile(r"\b(?:\d[ \-]?){13,19}\b"),
38
+ # AWS access key ID
39
+ re.compile(r"\bAKIA[0-9A-Z]{16}\b"),
40
+ )
41
+
42
+
43
+ Strategy = Literal["mask", "hash", "drop"]
44
+
45
+
46
+ class PiiRedactor:
47
+ """Walks ``event.payload`` and redacts matches in string values.
48
+
49
+ The walk descends into nested dicts and lists; tuples/sets are converted
50
+ to lists during processing then put back. Non-string leaves are left
51
+ alone. ``raw`` is never touched — sinks that need verbatim data can still
52
+ grab it, but content-shipping sinks (OTel, AgentOS) only read ``payload``.
53
+ """
54
+
55
+ def __init__(
56
+ self,
57
+ *,
58
+ strategy: Strategy = "mask",
59
+ extra_patterns: list[re.Pattern[str] | str] | None = None,
60
+ path_allowlist: list[str] | None = None,
61
+ path_denylist: list[str] | None = None,
62
+ include_builtins: bool = True,
63
+ ) -> None:
64
+ self.strategy = strategy
65
+ self._patterns: list[re.Pattern[str]] = []
66
+ if include_builtins:
67
+ self._patterns.extend(BUILTIN_PATTERNS)
68
+ for p in extra_patterns or []:
69
+ self._patterns.append(p if isinstance(p, re.Pattern) else re.compile(p))
70
+ self._allowlist = [p.strip(".") for p in (path_allowlist or [])]
71
+ self._denylist = [p.strip(".") for p in (path_denylist or [])]
72
+
73
+ async def __call__(self, event: TelemetryEvent) -> TelemetryEvent | None:
74
+ try:
75
+ event.payload = self._walk(event.payload, path="payload")
76
+ except Exception: # noqa: BLE001
77
+ logger.warning("PiiRedactor failed; dropping event (fail-closed)", exc_info=True)
78
+ return None
79
+ return event
80
+
81
+ # ── walk ----------------------------------------------------------------
82
+
83
+ def _walk(self, value: Any, *, path: str) -> Any:
84
+ if isinstance(value, dict):
85
+ return {k: self._walk(v, path=f"{path}.{k}") for k, v in value.items()}
86
+ if isinstance(value, list):
87
+ return [self._walk(v, path=f"{path}[{i}]") for i, v in enumerate(value)]
88
+ if isinstance(value, tuple):
89
+ return tuple(self._walk(v, path=f"{path}[{i}]") for i, v in enumerate(value))
90
+ if isinstance(value, str):
91
+ return self._redact_if_applicable(value, path=path)
92
+ return value
93
+
94
+ # ── policy --------------------------------------------------------------
95
+
96
+ def _redact_if_applicable(self, value: str, *, path: str) -> str:
97
+ bare_path = path.removeprefix("payload.").removeprefix("payload")
98
+ # Denylist wins outright — force redaction (or drop the field entirely
99
+ # when strategy=drop, but at this layer we operate on the string value
100
+ # so 'drop' on a string is equivalent to empty string).
101
+ if any(_path_matches(bare_path, p) for p in self._denylist):
102
+ return self._apply_strategy(value, force=True)
103
+ # Allowlist short-circuits redaction — useful for whitelisted fields
104
+ # like a known-safe URL or filename.
105
+ if self._allowlist and any(_path_matches(bare_path, p) for p in self._allowlist):
106
+ return value
107
+ return self._apply_strategy(value, force=False)
108
+
109
+ def _apply_strategy(self, value: str, *, force: bool) -> str:
110
+ if force and self.strategy == "drop":
111
+ return ""
112
+
113
+ if self.strategy == "drop":
114
+ # In drop mode, if any pattern matches anywhere, return empty.
115
+ for pat in self._patterns:
116
+ if pat.search(value):
117
+ return ""
118
+ return value
119
+
120
+ def replace(match: re.Match[str]) -> str:
121
+ captured = match.group(0)
122
+ if self.strategy == "hash":
123
+ digest = hashlib.sha256(captured.encode("utf-8")).hexdigest()[:12]
124
+ return f"<redacted:{digest}>"
125
+ return "****"
126
+
127
+ out = value
128
+ for pat in self._patterns:
129
+ try:
130
+ out = pat.sub(replace, out)
131
+ except re.error:
132
+ logger.warning("regex sub failed for pattern %r; skipping", pat.pattern)
133
+ # If the path is force-redacted but no pattern matched, return a
134
+ # blanket replacement so the field's content never leaks.
135
+ if force and out == value:
136
+ if self.strategy == "hash":
137
+ digest = hashlib.sha256(value.encode("utf-8")).hexdigest()[:12]
138
+ return f"<redacted:{digest}>"
139
+ return "****"
140
+ return out
141
+
142
+
143
+ def _path_matches(path: str, pattern: str) -> bool:
144
+ """Naive glob-ish path matcher: supports literal segments and ``*``.
145
+
146
+ Matches ``payload.foo.bar`` against ``foo.bar``, ``foo.*``, ``*.bar``.
147
+ Not a full glob — intentionally simple so the rule semantics are obvious.
148
+ """
149
+ if pattern == "*":
150
+ return True
151
+ path_parts = path.split(".")
152
+ pat_parts = pattern.split(".")
153
+ if len(pat_parts) > len(path_parts):
154
+ return False
155
+ for pat, seg in zip(pat_parts, path_parts, strict=False):
156
+ if pat != "*" and pat != seg:
157
+ return False
158
+ return True
@@ -0,0 +1,160 @@
1
+ """The telemetry pipeline — middleware then fan-out to sinks.
2
+
3
+ Design properties:
4
+
5
+ * **Never breaks an agent run.** Every middleware and every sink is wrapped
6
+ with broad exception handling; failures are logged and absorbed.
7
+ * **Fail-closed for PII.** A middleware that raises drops the event (rather
8
+ than forwarding a possibly-unredacted one). Switchable via
9
+ ``fail_open=True`` for debug builds.
10
+ * **Non-blocking sink emission.** Sinks run as background tasks so a slow
11
+ exporter never stalls the message hot path. ``flush()`` waits for them at
12
+ teardown.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import asyncio
18
+ import logging
19
+ import weakref
20
+ from typing import TYPE_CHECKING, Protocol
21
+
22
+ if TYPE_CHECKING:
23
+ from collections.abc import Awaitable
24
+
25
+ from .event import TelemetryEvent
26
+
27
+ logger = logging.getLogger("computeragent.telemetry")
28
+
29
+
30
+ class TelemetryMiddleware(Protocol):
31
+ """Transform or drop an event. Return None to drop."""
32
+
33
+ async def __call__(self, event: TelemetryEvent) -> TelemetryEvent | None: ...
34
+
35
+
36
+ class TelemetrySink(Protocol):
37
+ """Write an event to an external system."""
38
+
39
+ async def emit(self, event: TelemetryEvent) -> None: ...
40
+
41
+ async def flush(self, timeout: float = 5.0) -> None: ...
42
+
43
+ async def aclose(self) -> None: ...
44
+
45
+
46
+ class Pipeline:
47
+ """Owns the middleware chain and sink fan-out.
48
+
49
+ A single ``Pipeline`` is normally enough per process; ``configure()`` builds
50
+ the module-level default. Tests construct their own.
51
+ """
52
+
53
+ def __init__(
54
+ self,
55
+ *,
56
+ middleware: list[TelemetryMiddleware] | None = None,
57
+ sinks: list[TelemetrySink] | None = None,
58
+ fail_open: bool = False,
59
+ ) -> None:
60
+ self._middleware: list[TelemetryMiddleware] = list(middleware or [])
61
+ self._sinks: list[TelemetrySink] = list(sinks or [])
62
+ self._fail_open = fail_open
63
+ # Track scheduled sink tasks so flush() can await them.
64
+ self._pending: weakref.WeakSet[asyncio.Task[None]] = weakref.WeakSet()
65
+
66
+ # ── configuration -------------------------------------------------------
67
+
68
+ def add_middleware(self, mw: TelemetryMiddleware) -> None:
69
+ self._middleware.append(mw)
70
+
71
+ def add_sink(self, sink: TelemetrySink) -> None:
72
+ self._sinks.append(sink)
73
+
74
+ def replace_middleware(self, mws: list[TelemetryMiddleware]) -> None:
75
+ self._middleware = list(mws)
76
+
77
+ def replace_sinks(self, sinks: list[TelemetrySink]) -> None:
78
+ self._sinks = list(sinks)
79
+
80
+ @property
81
+ def middleware(self) -> list[TelemetryMiddleware]:
82
+ return list(self._middleware)
83
+
84
+ @property
85
+ def sinks(self) -> list[TelemetrySink]:
86
+ return list(self._sinks)
87
+
88
+ # ── runtime -------------------------------------------------------------
89
+
90
+ async def emit(self, event: TelemetryEvent) -> None:
91
+ """Run middleware, then schedule sink emissions concurrently.
92
+
93
+ Returns as soon as scheduling is done; sink writes happen in the
94
+ background. Use :meth:`flush` to wait for them.
95
+ """
96
+ for mw in self._middleware:
97
+ try:
98
+ event_or_none = await mw(event)
99
+ except Exception: # noqa: BLE001 - middleware must never break the agent
100
+ logger.warning(
101
+ "telemetry middleware raised; %s",
102
+ "forwarding event (fail_open=True)" if self._fail_open else "dropping event",
103
+ exc_info=True,
104
+ )
105
+ if not self._fail_open:
106
+ return
107
+ event_or_none = event
108
+ if event_or_none is None:
109
+ return
110
+ event = event_or_none
111
+
112
+ for sink in self._sinks:
113
+ task = asyncio.create_task(self._safe_emit(sink, event))
114
+ self._pending.add(task)
115
+
116
+ async def _safe_emit(self, sink: TelemetrySink, event: TelemetryEvent) -> None:
117
+ try:
118
+ await sink.emit(event)
119
+ except Exception: # noqa: BLE001 - sinks must never break the agent
120
+ logger.warning("telemetry sink raised on emit (%s)", type(sink).__name__, exc_info=True)
121
+
122
+ async def flush(self, timeout: float = 5.0) -> None:
123
+ """Wait for outstanding sink writes, then flush each sink."""
124
+ # Snapshot the pending tasks; new ones added during await are picked up
125
+ # by sinks' own flush().
126
+ tasks: list[asyncio.Task[None]] = [t for t in self._pending if not t.done()]
127
+ if tasks:
128
+ try:
129
+ await asyncio.wait_for(
130
+ asyncio.gather(*tasks, return_exceptions=True), timeout=timeout
131
+ )
132
+ except TimeoutError:
133
+ logger.warning(
134
+ "telemetry flush timed out after %.1fs; %d sink tasks still pending",
135
+ timeout,
136
+ len([t for t in tasks if not t.done()]),
137
+ )
138
+
139
+ for sink in self._sinks:
140
+ try:
141
+ await sink.flush(timeout=timeout)
142
+ except Exception: # noqa: BLE001
143
+ logger.warning(
144
+ "telemetry sink raised on flush (%s)", type(sink).__name__, exc_info=True
145
+ )
146
+
147
+ async def aclose(self) -> None:
148
+ await self.flush()
149
+ for sink in self._sinks:
150
+ try:
151
+ await sink.aclose()
152
+ except Exception: # noqa: BLE001
153
+ logger.warning(
154
+ "telemetry sink raised on aclose (%s)", type(sink).__name__, exc_info=True
155
+ )
156
+
157
+
158
+ def _await(awaitable: Awaitable[None]) -> Awaitable[None]:
159
+ """Type narrower for the linter (kept for future use)."""
160
+ return awaitable
@@ -0,0 +1,28 @@
1
+ """Telemetry sinks — OTel collector and AgentOS Mongo registry.
2
+
3
+ Each sink is conditional on its extra. Import the sink class only after
4
+ installing the corresponding extra; otherwise the import raises ``ImportError``
5
+ with a clean install hint.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ __all__ = ["AgentRegistrySink", "MongoMessageSink", "OtelSink"]
11
+
12
+
13
+ def __getattr__(name: str): # type: ignore[no-untyped-def]
14
+ """Lazy import so ``from .sinks import OtelSink`` raises a clean ImportError
15
+ only when the optional dep is actually missing."""
16
+ if name == "OtelSink":
17
+ from .otel import OtelSink as _OtelSink
18
+
19
+ return _OtelSink
20
+ if name == "AgentRegistrySink":
21
+ from .agentos import AgentRegistrySink as _AgentRegistrySink
22
+
23
+ return _AgentRegistrySink
24
+ if name == "MongoMessageSink":
25
+ from .message_archive import MongoMessageSink as _MongoMessageSink
26
+
27
+ return _MongoMessageSink
28
+ raise AttributeError(f"module {__name__!r} has no attribute {name!r}")