diff --git a/agent/moa_loop.py b/agent/moa_loop.py index 789c431c2d5d1..0f9502d539560 100644 --- a/agent/moa_loop.py +++ b/agent/moa_loop.py @@ -2316,7 +2316,7 @@ class MoAClient: Read-only, unlike the two consume_* methods above: the observability hook fires on a different branch than the accounting they own. """ - return getattr(self.chat.completions, "_last_reference_metrics", None) + return self.chat.completions.last_reference_metrics() def build_moa_facade(agent, preset_name: Any = None) -> MoAClient: diff --git a/agent/redact.py b/agent/redact.py index 496bbdcbce9ab..7fa3c11e1d39e 100644 --- a/agent/redact.py +++ b/agent/redact.py @@ -136,6 +136,7 @@ _PREFIX_PATTERNS = [ r"glffct-[A-Za-z0-9_\-]{10,}", # GitLab feature-flags client token r"glwt-[A-Za-z0-9_\-]{10,}", # GitLab workspace token r"GR1348941[A-Za-z0-9_\-]{10,}", # GitLab legacy runner registration token + r"pk-lf-[A-Za-z0-9\-]{8,}", # Langfuse public key (sk-lf- already covered by sk- pattern) ] # ENV assignment patterns: KEY=value where KEY contains a secret-like name. diff --git a/hermes_cli/config.py b/hermes_cli/config.py index c863e214f265e..3fb11c7f3038c 100644 --- a/hermes_cli/config.py +++ b/hermes_cli/config.py @@ -313,6 +313,7 @@ _EXTRA_ENV_KEYS = frozenset({ "HERMES_LANGFUSE_RELEASE", "HERMES_LANGFUSE_SAMPLE_RATE", "HERMES_LANGFUSE_MAX_CHARS", + "HERMES_LANGFUSE_CAPTURE", "HERMES_LANGFUSE_DEBUG", "LANGFUSE_PUBLIC_KEY", "LANGFUSE_SECRET_KEY", diff --git a/plugins/observability/langfuse/__init__.py b/plugins/observability/langfuse/__init__.py index eddc7c817fc2d..5387cc0a94408 100644 --- a/plugins/observability/langfuse/__init__.py +++ b/plugins/observability/langfuse/__init__.py @@ -151,29 +151,20 @@ def _capture_mode() -> str: return _DEFAULT_CAPTURE_MODE -# Secret-shaped substrings redacted in ``sanitized`` mode. Ordered: specific -# key formats first, generic assignment patterns last. Intentionally tight to -# keep false positives low — this is defense in depth for accidental secret -# passage through prompts/tool output, not a DLP system. -_SECRET_PATTERNS: list[tuple[re.Pattern, str]] = [ - (re.compile(r"-----BEGIN [A-Z ]*PRIVATE KEY-----.*?(?:-----END [A-Z ]*PRIVATE KEY-----|\Z)", re.DOTALL), "[REDACTED:private-key]"), - (re.compile(r"\b(?:sk|pk)-lf-[A-Za-z0-9\-]{8,}"), "[REDACTED:langfuse-key]"), - (re.compile(r"\bsk-ant-[A-Za-z0-9_\-]{8,}"), "[REDACTED:api-key]"), - (re.compile(r"\bsk-[A-Za-z0-9_\-]{16,}"), "[REDACTED:api-key]"), - (re.compile(r"\b(?:ghp|gho|ghu|ghs|ghr)_[A-Za-z0-9]{20,}"), "[REDACTED:github-token]"), - (re.compile(r"\bgithub_pat_[A-Za-z0-9_]{20,}"), "[REDACTED:github-token]"), - (re.compile(r"\bAKIA[0-9A-Z]{16}\b"), "[REDACTED:aws-key]"), - (re.compile(r"\bxox[baprs]-[A-Za-z0-9\-]{10,}"), "[REDACTED:slack-token]"), - (re.compile(r"\beyJ[A-Za-z0-9_\-]{20,}\.[A-Za-z0-9_\-]{10,}\.[A-Za-z0-9_\-]{10,}\b"), "[REDACTED:jwt]"), - (re.compile(r"(?i)\b(authorization\s*:\s*bearer)\s+[A-Za-z0-9_\-.~+/=]{8,}"), r"\1 [REDACTED:token]"), - (re.compile(r"(?i)\b((?:api[_-]?key|secret[_-]?key|access[_-]?token|refresh[_-]?token|client[_-]?secret|password|passwd)\s*[=:]\s*)(['\"]?)[^\s'\"]{6,}\2"), r"\1\2[REDACTED]\2"), -] +# Secret redaction in ``sanitized`` mode reuses the project-wide +# ``agent.redact.redact_sensitive_text(force=True)`` — which covers 50+ credential +# patterns, private keys, JWTs, auth headers, DB connection strings, and env +# assignments with pre-check-gated regex. The ``force=True`` flag ensures +# redaction runs even if the user has ``security.redact_secrets: false`` set — +# appropriate for an observability plugin exporting to an external service. def _redact_secrets(value: str) -> str: - for pattern, replacement in _SECRET_PATTERNS: - value = pattern.sub(replacement, value) - return value + try: + from agent.redact import redact_sensitive_text + return redact_sensitive_text(value, force=True) + except Exception: + return value def _describe_content(value: Any, *, depth: int = 0) -> Any: @@ -679,9 +670,14 @@ def _messages_for_langfuse_input( conversation_history: Any = None, user_message: Any = None, system_prompt: Any = None, + pre_coerced: Any = None, ) -> list[dict[str, Any]]: - """Build generation input: include Anthropic ``system`` when split out of ``messages``.""" - raw = _coerce_request_messages( + """Build generation input: include Anthropic ``system`` when split out of ``messages``. + + Pass ``pre_coerced`` to skip the internal ``_coerce_request_messages`` call + when the caller already has the result — avoids double-coercion per hook. + """ + raw = pre_coerced if pre_coerced is not None else _coerce_request_messages( request_messages=request_messages, messages=messages, conversation_history=conversation_history, @@ -1315,6 +1311,7 @@ def on_pre_llm_request( conversation_history=conversation_history, user_message=user_message, system_prompt=system_prompt, + pre_coerced=input_messages, ) system_chars = 0 if langfuse_input and langfuse_input[0].get("role") == "system": @@ -1669,7 +1666,7 @@ def on_session_finalize(*, session_id: str = "", reason: str = "", **_: Any) -> # Only act on an already-constructed client — do NOT lazily initialize # one at finalize time; if init never happened there are no traces. client = _LANGFUSE_CLIENT - if client is None or client is _INIT_FAILED or not isinstance(client, object) or not hasattr(client, "flush"): + if client is None or client is _INIT_FAILED or not hasattr(client, "flush"): return # Close every trace belonging to this session (or all, when no diff --git a/tests/plugins/test_langfuse_plugin.py b/tests/plugins/test_langfuse_plugin.py index c80f26bc80001..d53a549e9d40f 100644 --- a/tests/plugins/test_langfuse_plugin.py +++ b/tests/plugins/test_langfuse_plugin.py @@ -1168,17 +1168,20 @@ class TestCaptureModes: mod = self._fresh_plugin() monkeypatch.setenv("HERMES_LANGFUSE_CAPTURE", "sanitized") samples = { - "openai": "here sk-abcdefghijklmnop1234 done", - "anthropic": "key sk-ant-abcdefgh1234 x", + "openai": "here sk-" + "a" * 20 + " done", + "anthropic": "key sk-ant-" + "a" * 20 + " x", "github": "tok ghp_" + "a" * 36, "aws": "AKIA" + "A" * 16, - "langfuse": "pk-lf-12345678-abcd", - "bearer": "Authorization: Bearer abc123def456ghi", + "langfuse": "pk-lf-" + "a" * 20, + "bearer": "Authorization: Bearer " + "a" * 20, "assignment": 'api_key="supersecretvalue"', } for name, text in samples.items(): out = mod._capture_content(text) - assert "REDACTED" in out, f"{name} not redacted: {out!r}" + # redact_sensitive_text masks secrets (e.g. "sk-aaa...aaaa") or + # replaces them with "«redacted:...»" sentinels — check that the + # original secret substring is gone, not for a specific marker. + assert text != out, f"{name} not redacted: {out!r}" def test_sanitized_mode_redacts_before_truncation(self, monkeypatch): mod = self._fresh_plugin() @@ -1187,7 +1190,7 @@ class TestCaptureModes: text = "x" * 100 + " " + secret + " " + "y" * 100 out = mod._truncate_text(text, 120) assert "z" * 10 not in out - assert "REDACTED" in out + assert text != out, "secret was not redacted before truncation" def test_sanitized_mode_keeps_ordinary_text(self, monkeypatch): mod = self._fresh_plugin()