blob: b0032a070cecbe25833bab51494fbb855692a79a [file]
"""Casbin Gateway's behaviour observer for Hermes Agent.
This module intentionally never serializes prompts, responses, reasoning,
tool results, or subagent text. Tool arguments are redacted and bounded before
hook callbacks enqueue records without waiting for the Gateway HTTP endpoint.
"""
from __future__ import annotations
import atexit
import json
import logging
import queue
import re
import threading
import urllib.request
from pathlib import Path
from typing import Any
_LOGGER = logging.getLogger(__name__)
_CONFIG_PATH = Path(__file__).with_name("gateway.json")
_QUEUE: queue.Queue[dict[str, Any]] = queue.Queue(maxsize=1024)
_STOP = threading.Event()
_SENDER: threading.Thread | None = None
_REGISTER_LOCK = threading.Lock()
_MAX_OBJECT_BYTES = 64 * 1024
_CREDENTIAL_PATTERN = re.compile(
r"\b(?:sk-(?:ant-|proj-)?[a-z0-9_-]{12,}|gh[pousr]_[a-z0-9]{20,}|"
r"github_pat_[a-z0-9_]{20,}|AKIA[0-9A-Z]{16}|AIza[0-9a-z_-]{30,}|"
r"xox[baprs]-[0-9a-z-]{12,}|eyJ[a-z0-9_-]{10,}\.[a-z0-9_-]{10,}\."
r"[a-z0-9_-]{10,})\b",
re.IGNORECASE,
)
_BEARER_PATTERN = re.compile(r"(bearer\s+)[a-z0-9._~+/=-]{12,}", re.IGNORECASE)
_PRIVATE_KEY_PATTERN = re.compile(
r"-----BEGIN [^-\n]*PRIVATE KEY-----.*?-----END [^-\n]*PRIVATE KEY-----",
re.DOTALL,
)
_SENSITIVE_KEY_MARKERS = (
"secret",
"token",
"password",
"passwd",
"credential",
"privatekey",
"apikey",
"accesskey",
"authorization",
"cookie",
)
_TOKEN_COUNT_KEYS = {
"maxtokens",
"maxcompletiontokens",
"maxtokenstosample",
"maxoutputtokens",
"prompttokens",
"completiontokens",
"totaltokens",
"reasoningtokens",
"inputtokens",
"outputtokens",
"tokencount",
"numtokens",
"tokens",
"cachecreationinputtokens",
"cachereadinputtokens",
"cachedtokens",
}
_USAGE_KEYS = {
"input_tokens",
"output_tokens",
"total_tokens",
"prompt_tokens",
"completion_tokens",
"cache_read_input_tokens",
"cache_creation_input_tokens",
"reasoning_tokens",
"inputTokens",
"outputTokens",
"totalTokens",
"promptTokens",
"completionTokens",
"cacheReadInputTokens",
"cacheCreationInputTokens",
"reasoningTokens",
}
def _load_config() -> dict[str, Any]:
try:
value = json.loads(_CONFIG_PATH.read_text(encoding="utf-8"))
return value if isinstance(value, dict) else {}
except Exception:
return {}
_CONFIG = _load_config()
_RECORDS_URL = str(_CONFIG.get("recordsUrl") or "")
_AGENT_PATH = str(_CONFIG.get("agentPath") or "")
_USER = str(_CONFIG.get("user") or "")
_INGEST_TOKEN = str(_CONFIG.get("ingestToken") or "")
_INGEST_TOKEN_HEADER = str(_CONFIG.get("ingestTokenHeader") or "")
def _text(value: Any, limit: int = 512) -> str:
if value is None:
return ""
if isinstance(value, (str, int, float, bool)):
return str(value)[:limit]
return ""
def _integer(value: Any) -> int | None:
if isinstance(value, bool):
return None
if isinstance(value, (int, float)):
return int(value)
return None
def _duration_ms(value: Any, *, seconds: bool = False) -> int:
try:
number = float(value)
except (TypeError, ValueError):
return 0
if seconds:
number *= 1000
return max(0, int(number))
def _length(value: Any) -> int:
if value is None:
return 0
try:
return len(value)
except (TypeError, ValueError):
return len(str(value))
def _count(value: Any) -> int:
if isinstance(value, (list, tuple, set, dict)):
return len(value)
return 0
def _metadata(**values: Any) -> dict[str, Any]:
return {key: value for key, value in values.items() if value not in ("", None)}
def _sanitize_value(key: str, value: Any) -> Any:
normalized_key = key.lower().replace("_", "").replace("-", "").replace(".", "")
if normalized_key not in _TOKEN_COUNT_KEYS and any(
marker in normalized_key for marker in _SENSITIVE_KEY_MARKERS
):
return "[REDACTED]"
if isinstance(value, dict):
return {child_key: _sanitize_value(child_key, child) for child_key, child in value.items()}
if isinstance(value, list):
return [_sanitize_value("", child) for child in value]
if isinstance(value, str):
value = _PRIVATE_KEY_PATTERN.sub("[REDACTED PRIVATE KEY]", value)
value = _BEARER_PATTERN.sub(r"\1[REDACTED]", value)
return _CREDENTIAL_PATTERN.sub("[REDACTED]", value)
return value
def _sensitive_path(file_path: Any) -> bool:
if not isinstance(file_path, str):
return False
normalized = file_path.lower().replace("\\", "/")
base = normalized.rsplit("/", 1)[-1]
if base.startswith(".env") and base not in {".env.example", ".env.sample", ".env.template"}:
return True
if normalized.startswith(".ssh/") or "/.ssh/" in normalized:
return True
if normalized == ".aws/credentials" or normalized.endswith("/.aws/credentials"):
return True
if base in {".npmrc", ".pypirc", "credentials", "id_rsa", "id_ed25519"}:
return True
return base.endswith((".pem", ".key"))
def _sanitize_tool_arguments(tool_name: str, arguments: Any) -> Any:
sanitized = _sanitize_value("", arguments)
if not isinstance(arguments, dict):
return sanitized
normalized_tool = tool_name.lower()
if (normalized_tool == "patch" or normalized_tool.endswith("__patch")) and "patch" in sanitized:
sanitized["patch"] = "[OMITTED: patch content]"
sensitive_write = normalized_tool in {"write", "edit", "write_file", "edit_file", "patch"} or normalized_tool.endswith(
("__write_file", "__edit_file", "__patch")
)
file_path = arguments.get("path", "") or arguments.get("file_path", "")
if not sensitive_write or not _sensitive_path(file_path):
return sanitized
for key in ("content", "old_string", "new_string"):
if key in sanitized:
sanitized[key] = "[REDACTED: sensitive file content]"
return sanitized
def _usage(value: Any) -> dict[str, int]:
if not isinstance(value, dict):
return {}
result: dict[str, int] = {}
for key in _USAGE_KEYS:
number = _integer(value.get(key))
if number is not None:
result[key] = number
return result
def _mcp_identity(tool_name: str) -> tuple[str, str]:
parts = tool_name.split("__", 2)
if len(parts) == 3 and parts[0].lower() == "mcp" and parts[1] and parts[2]:
return parts[1], parts[2]
return "", ""
def _base_record(
event_type: str,
action: str,
outcome: str,
values: dict[str, Any],
metadata: dict[str, Any] | None = None,
) -> dict[str, Any]:
record: dict[str, Any] = {
"agent": "hermes-agent",
"agentPath": _AGENT_PATH,
"user": _USER,
"eventType": event_type,
"action": action,
"sessionKey": _text(
values.get("session_id")
or values.get("parent_session_id")
or values.get("child_session_id")
),
"model": _text(values.get("model")),
"promptId": _text(
values.get("api_request_id")
or values.get("turn_id")
or values.get("task_id")
),
}
if outcome:
record["outcome"] = outcome
if metadata:
encoded = json.dumps(
metadata, ensure_ascii=False, separators=(",", ":"), sort_keys=True
)
encoded_bytes = encoded.encode("utf-8")
if len(encoded_bytes) > _MAX_OBJECT_BYTES:
encoded = json.dumps(
{
"truncated": True,
"originalBytes": len(encoded_bytes),
"preview": encoded_bytes[: _MAX_OBJECT_BYTES // 3].decode(
"utf-8", errors="ignore"
),
},
ensure_ascii=False,
separators=(",", ":"),
sort_keys=True,
)
record["object"] = encoded
return {key: value for key, value in record.items() if value not in ("", None)}
def _enqueue(record: dict[str, Any]) -> None:
if not _RECORDS_URL:
return
try:
_QUEUE.put_nowait(record)
except queue.Full:
_LOGGER.debug("Gateway observer queue is full; dropping one audit record")
def _send(record: dict[str, Any]) -> None:
body = json.dumps(record, ensure_ascii=False, separators=(",", ":")).encode("utf-8")
headers = {"Content-Type": "application/json"}
if _INGEST_TOKEN_HEADER and _INGEST_TOKEN:
headers[_INGEST_TOKEN_HEADER] = _INGEST_TOKEN
request = urllib.request.Request(
_RECORDS_URL,
data=body,
headers=headers,
method="POST",
)
with urllib.request.urlopen(request, timeout=2.0) as response:
response.read(1)
def _sender_loop() -> None:
while not _STOP.is_set() or not _QUEUE.empty():
try:
record = _QUEUE.get(timeout=0.2)
except queue.Empty:
continue
try:
_send(record)
except Exception as exc:
_LOGGER.debug("Gateway record delivery failed: %s", type(exc).__name__)
finally:
_QUEUE.task_done()
def _start_sender() -> None:
global _SENDER
if not _RECORDS_URL:
return
with _REGISTER_LOCK:
if _SENDER is not None and _SENDER.is_alive():
return
_SENDER = threading.Thread(
target=_sender_loop,
name="casbin-gateway-agent-monitor",
daemon=True,
)
_SENDER.start()
def _shutdown() -> None:
_STOP.set()
sender = _SENDER
if sender is not None and sender.is_alive():
sender.join(timeout=1.0)
atexit.register(_shutdown)
def _on_pre_api_request(**values: Any) -> None:
data = _metadata(
provider=_text(values.get("provider")),
apiMode=_text(values.get("api_mode")),
apiCallCount=_integer(values.get("api_call_count")),
retryCount=_integer(values.get("retry_count")),
messageCount=_integer(values.get("message_count")),
toolCount=_integer(values.get("tool_count")),
approxInputTokens=_integer(values.get("approx_input_tokens")),
requestCharCount=_integer(values.get("request_char_count")),
maxTokens=_integer(values.get("max_tokens")),
)
_enqueue(_base_record("llm", "request", "attempted", values, data))
def _on_post_api_request(**values: Any) -> None:
data = _metadata(
provider=_text(values.get("provider")),
apiMode=_text(values.get("api_mode")),
apiCallCount=_integer(values.get("api_call_count")),
retryCount=_integer(values.get("retry_count")),
finishReason=_text(values.get("finish_reason")),
responseModel=_text(values.get("response_model")),
messageCount=_integer(values.get("message_count")),
assistantContentChars=_integer(values.get("assistant_content_chars")),
assistantToolCallCount=_integer(values.get("assistant_tool_call_count")),
usage=_usage(values.get("usage")),
)
record = _base_record("llm", "response", "success", values, data)
record["durationMs"] = _duration_ms(values.get("api_duration"), seconds=True)
_enqueue(record)
def _error_identity(values: dict[str, Any]) -> tuple[str, Any]:
"""Split Hermes' error report into a type name and the message itself.
``api_request_error`` reports a structured ``error = {"type", "message"}``;
the tool hooks report flat ``error_type`` / ``error_message`` instead. Only
the type name and the message's length ever leave this module.
"""
error = values.get("error")
error_type = _text(values.get("error_type"))
message = values.get("error_message")
if isinstance(error, dict):
error_type = error_type or _text(error.get("type"))
if message is None:
message = error.get("message")
elif message is None and error is not None:
message = error
if not error_type and message is not None:
error_type = type(message).__name__
return error_type, message
def _on_api_request_error(**values: Any) -> None:
error_type, error_message = _error_identity(values)
data = _metadata(
provider=_text(values.get("provider")),
apiMode=_text(values.get("api_mode")),
apiCallCount=_integer(values.get("api_call_count")),
retryCount=_integer(values.get("retry_count")),
maxRetries=_integer(values.get("max_retries")),
retryable=values.get("retryable") if isinstance(values.get("retryable"), bool) else None,
statusCode=_integer(values.get("status_code")),
reason=_text(values.get("reason")),
errorType=error_type,
errorMessageLength=_length(error_message) if error_message is not None else None,
)
record = _base_record("llm", "error", "failure", values, data)
if values.get("api_duration") is not None:
record["durationMs"] = _duration_ms(values.get("api_duration"), seconds=True)
_enqueue(record)
def _tool_record(action: str, outcome: str, values: dict[str, Any]) -> dict[str, Any]:
tool_name = _text(values.get("tool_name"))
server, tool = _mcp_identity(tool_name)
event_type = "mcp" if server else "tool"
error_type, error_message = _error_identity(values)
data = _metadata(
taskId=_text(values.get("task_id")),
turnId=_text(values.get("turn_id")),
errorType=error_type,
errorMessageLength=_length(error_message) if error_message is not None else None,
middlewareStageCount=_count(values.get("middleware_trace")),
)
if action == "call" and values.get("args") is not None:
data["arguments"] = _sanitize_tool_arguments(tool_name, values.get("args"))
record = _base_record(event_type, action, outcome, values, data)
record["toolName"] = tool_name
record["toolUseId"] = _text(values.get("tool_call_id"))
if server:
record["mcpServer"] = server
record["mcpTool"] = tool
duration = _duration_ms(values.get("duration_ms"))
if duration:
record["durationMs"] = duration
return {key: value for key, value in record.items() if value not in ("", None)}
def _on_pre_tool_call(**values: Any) -> None:
_enqueue(_tool_record("call", "attempted", values))
def _on_post_tool_call(**values: Any) -> None:
status = _text(values.get("status")).lower()
outcome = {"ok": "success", "blocked": "denied", "error": "failure"}.get(
status, "failure"
)
_enqueue(_tool_record("result", outcome, values))
def _on_session_start(**values: Any) -> None:
data = _metadata(taskId=_text(values.get("task_id")))
_enqueue(_base_record("session", "start", "", values, data))
def _on_session_reset(**values: Any) -> None:
normalized = dict(values)
normalized["session_id"] = values.get("new_session_id") or values.get("session_id")
data = _metadata(
oldSessionId=_text(values.get("old_session_id")),
reason=_text(values.get("reason")),
)
_enqueue(_base_record("session", "reset", "success", normalized, data))
def _on_session_end(**values: Any) -> None:
failed = bool(values.get("failed"))
interrupted = bool(values.get("interrupted"))
outcome = "failure" if failed or interrupted else "success"
data = _metadata(
taskId=_text(values.get("task_id")),
turnId=_text(values.get("turn_id")),
completed=bool(values.get("completed")),
failed=failed,
interrupted=interrupted,
exitReason=_text(values.get("turn_exit_reason")),
)
_enqueue(_base_record("session", "end", outcome, values, data))
def _on_subagent_start(**values: Any) -> None:
normalized = dict(values)
normalized["session_id"] = values.get("parent_session_id") or values.get("session_id")
normalized["turn_id"] = values.get("parent_turn_id") or values.get("turn_id")
data = _metadata(
subagentId=_text(values.get("child_subagent_id") or values.get("subagent_id")),
childSessionId=_text(values.get("child_session_id")),
childRole=_text(values.get("child_role")),
goalLength=_length(values.get("child_goal")),
)
_enqueue(_base_record("subagent", "start", "attempted", normalized, data))
def _on_subagent_stop(**values: Any) -> None:
normalized = dict(values)
normalized["session_id"] = values.get("parent_session_id") or values.get("session_id")
normalized["turn_id"] = values.get("parent_turn_id") or values.get("turn_id")
status = _text(values.get("child_status") or values.get("status")).lower()
outcome = "success" if status in {"ok", "success", "completed"} else "failure"
data = _metadata(
subagentId=_text(values.get("child_subagent_id") or values.get("subagent_id")),
childSessionId=_text(values.get("child_session_id")),
childRole=_text(values.get("child_role")),
status=status,
summaryLength=_length(values.get("child_summary")),
toolCallCount=_count(values.get("tool_call_history")),
)
# "stop", not "end": the rest of Gateway's record vocabulary already spells
# the end of a subagent that way (see agenthook's SubagentStop).
record = _base_record("subagent", "stop", outcome, normalized, data)
duration = values.get("duration_ms")
if duration is None and values.get("duration") is not None:
record["durationMs"] = _duration_ms(values.get("duration"), seconds=True)
elif duration is not None:
record["durationMs"] = _duration_ms(duration)
_enqueue(record)
_HOOKS = {
"pre_api_request": _on_pre_api_request,
"post_api_request": _on_post_api_request,
"api_request_error": _on_api_request_error,
"pre_tool_call": _on_pre_tool_call,
"post_tool_call": _on_post_tool_call,
"on_session_start": _on_session_start,
"on_session_end": _on_session_end,
"on_session_reset": _on_session_reset,
"subagent_start": _on_subagent_start,
"subagent_stop": _on_subagent_stop,
}
def register(ctx: Any) -> None:
"""Register observational hooks. Return values are deliberately ignored."""
_start_sender()
for name, callback in _HOOKS.items():
ctx.register_hook(name, callback)