Skip to content

Commit be63255

Browse files
committed
fix(mcp): run free-text PII redaction before URL rewriting in feedback capture
Main's URL-credential sanitizer (#928) percent-encodes the @ the email pattern anchors on, so sanitize-then-redact let PII inside URLs through the feedback fields. Free text (summary, details, friction_points, suggested_improvement, tool_name) now uses the $mcp_intent pass (credentials -> PII -> URLs), and extras use a new sanitize_free_text_value walker that applies it per string leaf while keeping key-based redaction. sanitize_intent is renamed sanitize_free_text: the pass is no longer intent-specific. Ports posthog-js#4870 commits 9d3b3933, f4a69ea8, 270c4630. Generated-By: PostHog Desktop Task-Id: 0b5063cb-6fcf-4364-be5e-de945b1448f0
1 parent d040d7d commit be63255

3 files changed

Lines changed: 82 additions & 34 deletions

File tree

‎posthog/mcp/_sanitization.py‎

Lines changed: 22 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -443,9 +443,9 @@ def _redact_credentials(value: str) -> str:
443443
return _redact_secret_tokens(_POSTHOG_TOKEN_PATTERN.sub(_REDACTED_VALUE, value))
444444

445445

446-
def sanitize_intent(value: Any) -> Any:
447-
"""Sanitize the agent-narrated intent: the binary gate, the credential passes,
448-
structured PII, then URLs.
446+
def sanitize_free_text(value: Any) -> Any:
447+
"""Sanitize agent-narrated free text (the intent, the send_feedback fields):
448+
the binary gate, the credential passes, structured PII, then URLs.
449449
450450
Every step sits where it does for a reason. The binary gate runs first
451451
because splicing a redaction into a base64 blob stops it looking like base64,
@@ -586,12 +586,23 @@ def redact_pii(value: Any) -> Any:
586586

587587

588588
def sanitize_captured_value(value: Any) -> Any:
589+
return _sanitize_value_with(value, _sanitize_string)
590+
591+
592+
def sanitize_free_text_value(value: Any) -> Any:
593+
""":func:`sanitize_captured_value` with the free-text string pass
594+
(:func:`sanitize_free_text`) on every string leaf, so nested agent-narrated
595+
values get structured-PII redaction in the load-bearing order too."""
596+
return _sanitize_value_with(value, sanitize_free_text)
597+
598+
599+
def _sanitize_value_with(value: Any, sanitize_string_fn: Any) -> Any:
589600
if value is None:
590601
return value
591602
if isinstance(value, str):
592-
return _sanitize_string(value)
603+
return sanitize_string_fn(value)
593604
if isinstance(value, list):
594-
return [sanitize_captured_value(item) for item in value]
605+
return [_sanitize_value_with(item, sanitize_string_fn) for item in value]
595606
# bool is an int subclass; both pass through unchanged.
596607
if not isinstance(value, dict):
597608
return value
@@ -601,7 +612,7 @@ def sanitize_captured_value(value: Any) -> Any:
601612
result[key] = (
602613
_REDACTED_VALUE
603614
if _should_redact_key(str(key))
604-
else sanitize_captured_value(nested)
615+
else _sanitize_value_with(nested, sanitize_string_fn)
605616
)
606617
return result
607618

@@ -622,12 +633,13 @@ def sanitize_event(event: Dict[str, Any]) -> Dict[str, Any]:
622633

623634
# The intent comes straight from an agent-narrated `context` string, so it
624635
# can contain a secret the LLM read aloud or personal data it narrated about
625-
# the user. `sanitize_intent` redacts it like any other captured value and
636+
# the user. `sanitize_free_text` redacts it like any other captured value and
626637
# additionally strips structured PII (emails, phone numbers, IPs, cards,
627-
# SSNs). PII redaction is scoped to the intent only — structured tool
628-
# parameters and responses often hold the same shapes as legitimate data.
638+
# SSNs). PII redaction is scoped to agent-narrated free text only —
639+
# structured tool parameters and responses often hold the same shapes as
640+
# legitimate data.
629641
if result.get("user_intent") is not None:
630-
result["user_intent"] = sanitize_intent(result["user_intent"])
642+
result["user_intent"] = sanitize_free_text(result["user_intent"])
631643

632644
if result.get("llm_model") is not None:
633645
result["llm_model"] = sanitize_captured_value(result["llm_model"])

‎posthog/mcp/feedback.py‎

Lines changed: 16 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
1010
from typing import Any, Dict, Optional, Union
1111

1212
from ._internal import _maybe_await
13-
from ._sanitization import redact_pii, sanitize_captured_value
13+
from ._sanitization import sanitize_free_text, sanitize_free_text_value
1414
from .constants import PostHogMCPAnalyticsProperty
1515
from .logger import log
1616
from .types import CollectFeedbackOptions, FeedbackReport, JsonRecord
@@ -257,25 +257,26 @@ def _truncate_feedback_text(value: str, max_length: int) -> str:
257257

258258
def _capture_free_text(value: str) -> str:
259259
"""Agent-narrated free text can contain a secret the LLM read aloud or personal
260-
data it narrated, so it gets the ``$mcp_intent`` treatment: sanitize, strip
261-
structured PII, then bound the length. The event pipeline does not process
260+
data it narrated, so it gets exactly the ``$mcp_intent`` pass
261+
(``sanitize_free_text``: credentials -> structured PII -> URLs — the order is
262+
load-bearing, the URL rewrite would percent-encode the ``@`` the email pattern
263+
anchors on), then a length bound. The event pipeline does not process
262264
``event["properties"]``, so this happens here."""
263-
return _truncate_feedback_text(
264-
redact_pii(sanitize_captured_value(value)), _MAX_FEEDBACK_TEXT_LENGTH
265-
)
265+
return _truncate_feedback_text(sanitize_free_text(value), _MAX_FEEDBACK_TEXT_LENGTH)
266266

267267

268268
def _capture_extra_value(value: Any) -> Any:
269-
"""A declared extra is agent-supplied like the core free-text fields, so it
270-
gets the same treatment. Non-scalars are JSON-stringified first so the
271-
redaction sees the full text; unserializable values are dropped."""
272-
sanitized = sanitize_captured_value(value)
269+
"""A declared extra is agent-supplied like the core free-text fields, so its
270+
string leaves get the same free-text pass (with the key-based redaction
271+
``sanitize_free_text_value`` keeps for nested objects), then non-scalars are
272+
JSON-stringified and everything is bounded. Unserializable values are dropped."""
273+
sanitized = sanitize_free_text_value(value)
273274
if isinstance(sanitized, str):
274-
return _capture_free_text(sanitized)
275+
return _truncate_feedback_text(sanitized, _MAX_FEEDBACK_TEXT_LENGTH)
275276
if sanitized is None or isinstance(sanitized, (bool, int, float)):
276277
return sanitized
277278
try:
278-
return _capture_free_text(json.dumps(sanitized))
279+
return _truncate_feedback_text(json.dumps(sanitized), _MAX_FEEDBACK_TEXT_LENGTH)
279280
except Exception: # noqa: BLE001 - capture must never raise into the tool path
280281
return None
281282

@@ -306,10 +307,10 @@ def build_feedback_event_properties(report: FeedbackReport) -> JsonRecord:
306307
)
307308
if report.tool_name:
308309
# Nominally an identifier, but the schema can't stop an agent from
309-
# writing prose into it — so it gets the same PII redaction as the
310-
# other free text.
310+
# writing prose into it — so it gets the same free-text pass as the
311+
# other fields.
311312
properties[PostHogMCPAnalyticsProperty.FEEDBACK_TOOL] = _truncate_feedback_text(
312-
redact_pii(sanitize_captured_value(report.tool_name)),
313+
sanitize_free_text(report.tool_name),
313314
_MAX_FEEDBACK_TOOL_NAME_LENGTH,
314315
)
315316
if report.task_completed is not None:

‎posthog/test/mcp/test_feedback.py‎

Lines changed: 44 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -312,20 +312,55 @@ def test_tool_name_gets_pii_redaction():
312312
assert props["$mcp_feedback_tool"] == "ask [redacted]"
313313

314314

315-
def test_pii_inside_urls_is_redacted():
316-
# Guards the ordering of sanitize vs redact: a sanitizer that re-serializes
317-
# URLs (percent-encoding "@") before redaction would hide the email from the
318-
# redaction pattern (the TS SDK's veria finding on this feature).
315+
def test_pii_inside_urls_is_redacted_on_every_surface():
316+
# Guards the ordering of the free-text pass: running the URL rewrite before
317+
# PII redaction percent-encodes the "@" the email pattern anchors on, letting
318+
# the email through (the TS SDK's veria finding on this feature).
319+
url = "https://example.com/?email=jane@example.com"
320+
options = CollectFeedbackOptions(extra_properties={"area": {"type": "string"}})
319321
report = parse_feedback_report(
320322
{
321323
"feedback_type": "issue",
322-
"summary": "Login fails at https://example.com/?email=jane@example.com",
323-
}
324+
"summary": f"Login fails at {url}",
325+
"details": f"see {url} too",
326+
"friction_points": f"url {url} slow",
327+
"suggested_improvement": f"fix {url}",
328+
"tool_name": url,
329+
"area": url,
330+
},
331+
options,
332+
)
333+
props = build_feedback_event_properties(report)
334+
for key in (
335+
"$mcp_feedback_summary",
336+
"$mcp_feedback_details",
337+
"$mcp_feedback_friction_points",
338+
"$mcp_feedback_suggested_improvement",
339+
"$mcp_feedback_tool",
340+
"$mcp_feedback_area",
341+
):
342+
assert "jane@example.com" not in props[key], key
343+
assert "jane%40example.com" not in props[key], key
344+
assert "[redacted]" in props[key], key
345+
346+
347+
def test_nested_extras_keep_key_based_redaction():
348+
# The feedback path walks extras with the free-text pass; nothing else
349+
# asserts that credential-named keys inside a nested extra still redact by
350+
# key name, so a stringify-first refactor could drop that protection silently.
351+
options = CollectFeedbackOptions(extra_properties={"meta": {"type": "object"}})
352+
report = parse_feedback_report(
353+
{
354+
"feedback_type": "issue",
355+
"summary": "s",
356+
"meta": {"note": "ping jane@example.com", "password": "hunter2"},
357+
},
358+
options,
324359
)
325360
props = build_feedback_event_properties(report)
326-
assert "jane@example.com" not in props["$mcp_feedback_summary"]
327-
assert "jane%40example.com" not in props["$mcp_feedback_summary"]
328-
assert "[redacted]" in props["$mcp_feedback_summary"]
361+
assert '"password": "[redacted]"' in props["$mcp_feedback_meta"]
362+
assert "hunter2" not in props["$mcp_feedback_meta"]
363+
assert '"note": "ping [redacted]"' in props["$mcp_feedback_meta"]
329364

330365

331366
def test_intent_joins_summary_and_details():

0 commit comments

Comments
 (0)