Skip to content

fix(mcp): bound intent and parameter capture work, redact feedback extra keys and numbers - #1217

Open
lucasheriques wants to merge 4 commits into
mainfrom
lucas/mcp-capture-bounds
Open

lucasheriques wants to merge 4 commits into
mainfrom
lucas/mcp-capture-bounds

Conversation

@lucasheriques

@lucasheriques lucasheriques commented Nov 9, 2026 •

Copy link
Copy Markdown
Contributor

💡 Motivation and Context

Three fixes to what posthog.mcp captures, ported from PostHog/posthog-js#5607. Each one is shown below as before (main) and after (this PR). The outputs come from running one scratch script against the merge base (2c35f04) and this branch. Timings are single runs on a laptop.

On main:

  1. $mcp_intent is redacted in full before truncation keeps 2048 characters, so a long intent stalls capture for seconds.
  2. Tool arguments are sanitized in full before the 100-entry and depth-10 limits cut them. A deeply nested argument raises RecursionError, and a top-level argument named password is captured whole.
  3. $mcp_feedback_<name> object extras send emails and card numbers that sit in keys or in number values.

This PR adds no personal-data redaction to $mcp_parameters, $mcp_response or error text. It does not change which identifiers redact_pii matches.

What changes, by example

1. Free text is redacted only as far as truncation can keep it

sanitize_free_text (used for $mcp_intent, the send_feedback text fields, and unknown tool names) still redacts text of up to 8192 characters (4x what truncation keeps) whole, as on main. Past that it redacts a head of at most 4096 characters and appends .... The head ends at an ASCII space no redaction pattern matches across. A space after a digit, after one of ./()+-, or after a Unicode space doesn't count, because a card, SSN or phone number can continue across it. So the head redacts exactly as it would inside the whole string. A longer text with no such space in its first 4096 characters is one long word, and the entropy check judges a word as a whole. Judging it would cost the full scan, so it is captured as [redacted].

$mcp_intent main This PR
"1-1." * 2048 (8 KB, worst case still redacted whole) not measured separately 24 ms, 1-1.1-1.1-1.… (2048 chars)
"1-1." * 250_000 (1 MB) 3.6 s, 1-1.1-1.1-1.… (2048 chars) 0.1 ms, [redacted]
"1-1." * 3_000_000 (12 MB) 41.2 s, 1-1.1-1.1-1.… (2048 chars) under 0.1 ms, [redacted]
"lorem ipsum " * 87_382 (1 MB) 218 ms, lorem ipsum lorem… (2048 chars) 0.7 ms, same output
"lorem ipsum " * 1_000_000 (12 MB) 1.9 s, lorem ipsum lorem… (2048 chars) 0.7 ms, same output
"s" * 5000 or "请帮我查询" * 900 first 2048 chars same
"s" * 9000 (one 9000-char word) sss… (2048 chars) [redacted]
Refund for jane.doe@acme.com, card 4111 1111 1111 1111 Refund for [redacted], card [redacted] same

Tests pin the cut rule. An intent whose start shrinks by 3000 characters when redacted has a card, an SSN, a phone number (one with an NBSP before a space too) or a URL with userinfo straddling character 4096. None of their digits or credentials reach the capture. Those tests fail if the cut ignores digits and separators.

2. Tool arguments are limited before they are sanitized

build_captured_mcp_parameters now strips the injected arguments (context, conversation_id, and the SDK-owned llm_model). Then it applies the 100-entry and depth-10 limits through normalize, with strings kept whole. Only after that does it sanitize. A credential-named key is written as [redacted] without its value being read. Read counts come from a dict subclass that counts entries read through items().

Tool call main This PR
100 arguments 100 reads, 100 kept 100 reads, 100 kept
100,000 arguments 100,000 reads, 24 ms, 100,000 kept before truncation 101 reads, 0.1 ms, 100 kept + "...": "[MaxProperties ~]"
one argument nested 1000 deep RecursionError {"deep": {"child": … "[Object]"}}, cut at depth 10 of $mcp_parameters
{"password": "hunter2", "q": "x", "context": "…"} {"password": "hunter2", "q": "x"} {"password": "[redacted]", "q": "x"}
{"context": …, "conversation_id": …, a0 … a99} 100 arguments kept 100 arguments kept (stripped names don't count toward the 100)

On main the credential-key rule only ran inside nested objects, so a top-level argument named password, token or api_key reached $mcp_parameters whole.

3. Feedback object extras redact identifiers in keys and numbers

A host can declare extra report fields for the send_feedback tool. An object extra is sent as JSON text, and main sanitized only its string values. Now each string and number inside it gets the same free-text pass as $mcp_intent. A number whose text that pass changes becomes the string "[redacted]", so the JSON stays valid. Keys get the token, PII and URL passes but skip the entropy detector, as _sanitize_resource_name does for names. Otherwise names like Get_Organization_Memberships read as credentials, and two such keys collapse into one [redacted] entry. The extra is also limited to 100 entries and depth 10 before it is sanitized.

Declared extras meta (object) and score (number), sent as:

{
  "meta": {"ada@example.com": "x", "card\n4111111111111111": 1, "n": 4111111111111111},
  "score": 4111111111111111
}
Property main This PR
$mcp_feedback_meta {"ada@example.com": "x", "card\n4111111111111111": 1, "n": 4111111111111111} {"[redacted]": "x", "card\n[redacted]": 1, "n": "[redacted]"}
$mcp_feedback_score 4111111111111111 4111111111111111 (top-level numbers are sent as is, per spec)
meta nested 5000 deep RecursionError, whole $mcp_feedback event dropped {"child": … "[Object]"}, cut at depth 10
meta = {"Get_Organization_Memberships": 1, "ISO_8601_timestamp_UTC": 5} same as input same as input
meta = {"n": 10**5000} extra dropped (json.dumps fails) {"n": "[redacted]"}

Keys are sanitized before the object is stringified. Sanitizing the JSON text instead would miss the first key: in card\n4111… the escape's n is a word character, so the card has no word boundary.

What does not change

Identifier matching in redact_pii stays as it is, including card and SSN overlap. Both runs printed the same output:

redact_pii input main and this PR
4111 1111 1111 1111 12/30 [redacted] 12/30
SSN 078-05-1120 SSN [redacted]
4111-1111-1111-1111 and 078-05-1120 [redacted] and [redacted]
id 4111111111111112 (fails Luhn) id 4111111111111112
ip 10.0.0.1, +44 20 7946 0958 ip [redacted], [redacted]
  • $mcp_parameters still gets no personal-data redaction. {"email": "ada@example.com", "note": "call 415-555-0142"} is captured as is on both.
  • An intent of up to 8192 characters is redacted in full, exactly as on main.
  • Large string leaves in $mcp_parameters and responses are still scanned in full, and sanitize_event sanitizes parameters a second time, as on main. A 12 MB string argument costs about 3 s across the two passes. This PR bounds entries and depth only. Bounding captured strings the way posthog-js redactCapturedHead does is a follow-up.

Tradeoffs

  • An intent longer than 8192 characters with no usable space in its first 4096 is captured as [redacted] instead of its first 2048 characters. That covers long CJK text, minified JSON, and newline- or tab-only text.
  • A non-JSON value inside an object extra (a set, bytes, a datetime) is now captured as its text, as $mcp_parameters does, where main dropped the extra.
  • When redaction shrinks the head below 2048 characters, the capture is shorter than truncation would allow. It never holds more than redacting the whole string would.
  • Inside an object extra, a number that passes as a card (for example some 13-digit millisecond timestamps) is sent as "[redacted]". Two keys that both redact to [redacted] collapse into the last one.

Where to look

File What to check
posthog/mcp/_sanitization.py _redact_bounded, _free_text_head and _CONTINUES_ACROSS_A_SPACE (the cut rule, next to the PII patterns it depends on)
posthog/mcp/_sanitization.py build_captured_mcp_parameters, _pick_captured_fields, _kept_arguments, limit_entries: pick fields, strip, limit, then sanitize
posthog/mcp/_sanitization.py _sanitize_value_with with its leaf and key functions; _sanitize_free_text_leaf (the number rule), _sanitize_free_text_key
posthog/mcp/_truncation.py normalize's optional redacts_key, and _Walk carrying it
posthog/mcp/feedback.py _capture_extra_value: scalars as is, objects limited and then walked
posthog/test/mcp/test_pipeline.py, test_feedback.py the failing-first parameterized cases

💚 How did you test it?

Each fix has a parameterized test that failed on main first. The test commit lands before the fix.

  • pytest posthog/test/mcp: 717 passed, 11 skipped.
  • ruff format --check ., ruff check ., and mypy --no-site-packages --config-file mypy.ini . | mypy-baseline filter are all clean, using the tool versions pinned in uv.lock.
  • The cut tests fail when _CONTINUES_ACROSS_A_SPACE is set to "" (4 cases).
  • The before/after tables above come from one uncommitted scratch script run against both checkouts.

📝 Checklist

  • All commits are signed
  • I reviewed the submitted code.
  • I added tests to verify the changes.
  • I updated the docs if needed.
  • No breaking change or entry added to the changelog.

If releasing new changes

  • Ran sampo add to generate a changeset file (.sampo/changesets/mcp-capture-bounds.md)

🤖 Agent context

Autonomy: Human-driven (agent-assisted)

Claude Code (Opus 5.5) wrote this from a brief that came out of the posthog-js#5607 review. Those review rounds rejected every change to card and SSN overlap handling, so redact_pii matching is left alone here.

The Go SDK pre-truncates to 2x the limit at a fixed index. I chose a space-aligned cut instead. A fixed cut can split a URL's userinfo or a multi-group card, and once redaction shrinks the head by more than 2048 characters, the split piece lands in the capture. Four Sonnet reviewers made a simplify pass. The interrogate gate ran an Opus adversary and a Sonnet adversary, each in its own checkout. Both reproduced an NBSP phone-number leak at the cut and the loss of long space-free text. The Sonnet adversary also found key collisions from the entropy detector and a 4300-digit int that dropped the whole feedback event. All four are fixed in the last fix commit, each with a test, and the adversaries' scripts now print the required output. Their differential fuzzer (head redaction against whole-string redaction) finds 0 mismatches on four seeds. Both also flagged that large string arguments are still scanned in full; that is out of scope here and listed above.

…edback extras

Each fails on main: a 1MB separated-digit intent scans for seconds, captured
parameters read every argument and recurse past the depth limit (a 1000-deep
argument raises RecursionError), a top-level credential-named argument is
captured whole, and object feedback extras keep identifiers in keys and numbers.

Tested: pytest posthog/test/mcp/test_pipeline.py -k 'intent_scan or
captured_parameters' and posthog/test/mcp/test_feedback.py -k 'extras_redact or
scalar_extras'. The new cases fail; the guard cases pass.
…tra keys and numbers

- sanitize_free_text redacts only a head of 2x the intent limit (4096 chars),
  cut at a space no pass matches across: not after a digit or a card/SSN/phone
  separator. A head with no such space is one long word whose entropy verdict
  depends on all of it, so it is captured as [redacted]. 12MB of "1-1." went
  from ~41s to under 1ms.
- build_captured_mcp_parameters strips the injected arguments, applies the
  100-entry and depth-10 limits (normalize, strings whole), then sanitizes. A
  credential-named key is redacted without reading its value. This also redacts
  a top-level argument named e.g. "password", which main captured whole, and
  fixes a RecursionError on deeply nested arguments.
- Feedback object extras: keys and numbers get the free-text pass, a number
  whose text changes becomes "[redacted]", and the extra is limited before
  sanitizing (a 5000-deep extra raised RecursionError). Top-level number and
  boolean extras are captured as sent.

Tested: pytest posthog/test/mcp (711 passed, 11 skipped); ruff format --check,
ruff check, mypy | mypy-baseline filter all clean. The straddle tests fail when
the cut ignores digits and separators.

Reviewer notes: two existing tests now use 4000-char words instead of 5000 so
they stay below the scanned head; a no-space intent over 4096 chars is
[redacted] rather than truncated. Card and SSN matching in redact_pii is
unchanged. Large string leaves in $mcp_parameters are still scanned in full.
@lucasheriques lucasheriques self-assigned this Nov 9, 2026
@github-actions

github-actions Bot commented Nov 9, 2026 •

Copy link
Copy Markdown
Contributor

posthog-python Compliance Report

Date: 2026-11-09T15:22:12.918936+00:00
Duration: 260160ms

✅ All Tests Passed!

121/121 tests passed


Capture_V1 Tests

✅ 95/95 tests passed

View Details
Test Status Duration
Endpoint And Method.Targets V1 Endpoint ✅ 519ms
Endpoint And Method.Does Not Use Legacy Endpoints ✅ 512ms
Required Headers.Has Authorization Bearer Header ✅ 512ms
Required Headers.Has Content Type Json ✅ 511ms
Required Headers.Has Posthog Sdk Info Format ✅ 511ms
Required Headers.Has Posthog Attempt Header ✅ 512ms
Required Headers.Has Posthog Request Id ✅ 511ms
Required Headers.Has Posthog Request Timestamp ✅ 512ms
Required Headers.Has User Agent ✅ 511ms
Body Format.Body Has Created At And Batch ✅ 511ms
Body Format.No Api Key In Body ✅ 511ms
Body Format.No Sent At In Body ✅ 512ms
Event Format.Event Has Required Root Fields ✅ 511ms
Event Format.Event Uuid Is Valid ✅ 511ms
Event Format.Event Timestamp Is Rfc3339 ✅ 512ms
Event Format.Non Utc Event Timestamp Is Converted To Utc ✅ 530ms
Event Format.Distinct Id Is String ✅ 511ms
Event Format.Distinct Id At Root Not Properties ✅ 511ms
Event Format.Custom Properties Preserved ✅ 511ms
Event Format.Set Properties Preserved ✅ 510ms
Event Format.Set Once Properties Preserved ✅ 511ms
Event Format.Groups Properties Preserved ✅ 512ms
Event Format.Sdk Generates Uuid If Not Provided ✅ 511ms
Event Format.Event Has Required Root Fields Batch ✅ 514ms
Event Format.Event Uuid Is Valid Batch ✅ 516ms
Event Format.Event Timestamp Is Rfc3339 Batch ✅ 515ms
Event Format.Distinct Id Is String Batch ✅ 516ms
Event Format.Distinct Id At Root Not Properties Batch ✅ 516ms
Event Format.Custom Properties Preserved Batch ✅ 514ms
Event Format.Set Properties Preserved Batch ✅ 515ms
Event Format.Set Once Properties Preserved Batch ✅ 515ms
Event Format.Groups Properties Preserved Batch ✅ 516ms
Event Format.Sdk Generates Uuid If Not Provided Batch ✅ 514ms
Batch Behavior.Multiple Events In Single Batch ✅ 519ms
Batch Behavior.Batch Envelope Smoke ✅ 516ms
Batch Behavior.Flush With No Events Sends Nothing ✅ 507ms
Batch Behavior.Flush At Triggers Batch ✅ 1013ms
Batch Behavior.Created At Reflects Batch Creation Time ✅ 511ms
Deduplication.Generates Unique Uuids ✅ 519ms
Deduplication.Different Events Same Content Different Uuids ✅ 513ms
Deduplication.Preserves Uuid On Retry ✅ 6519ms
Deduplication.Preserves Timestamp On Retry ✅ 6521ms
Deduplication.Preserves Uuid And Timestamp On Batch Retry ✅ 6520ms
Deduplication.No Duplicate Events In Batch ✅ 519ms
Header Behavior On Retry.Attempt Header Starts At One ✅ 511ms
Header Behavior On Retry.Attempt Header Increments On Retry ✅ 13519ms
Header Behavior On Retry.Request Id Preserved On Retry ✅ 6521ms
Header Behavior On Retry.Different Requests Have Different Request Ids ✅ 3020ms
Header Behavior On Retry.Request Timestamp Changes On Retry ✅ 6520ms
Response Format Validation.Success Response Has Uuid Keyed Results ✅ 512ms
Response Format Validation.Success Response Has Ok For Each Event ✅ 515ms
Response Format Validation.Success No Retry After When All Ok ✅ 513ms
Response Format Validation.Success Retry After Present When Retry Events ✅ 1517ms
Response Format Validation.Success No Retry After When Drop Only ✅ 513ms
Response Format Validation.Response Echoes Request Id ✅ 511ms
Retry Behavior.Retries On 408 ✅ 6519ms
Retry Behavior.Retries On 500 ✅ 6517ms
Retry Behavior.Retries On 503 ✅ 8524ms
Retry Behavior.Retries On 504 ✅ 6520ms
Retry Behavior.Retryable Errors Have Retry After ✅ 3518ms
Retry Behavior.Respects Retry After On Retryable Error ✅ 11522ms
Retry Behavior.Does Not Retry On 400 ✅ 2514ms
Retry Behavior.Does Not Retry On 401 ✅ 2516ms
Retry Behavior.Does Not Retry On 402 ✅ 2514ms
Retry Behavior.Does Not Retry On 413 ✅ 2513ms
Retry Behavior.Does Not Retry On 415 ✅ 2515ms
Retry Behavior.Non Retryable Errors Have No Retry After ✅ 2514ms
Retry Behavior.Implements Backoff ✅ 22532ms
Retry Behavior.Max Retries Respected ✅ 22533ms
Partial Batch Handling.Handles 200 Full Success ✅ 2513ms
Partial Batch Handling.Handles 200 With All Ok ✅ 3518ms
Partial Batch Handling.Does Not Retry Dropped Events ✅ 3518ms
Partial Batch Handling.Does Not Retry Limited Events ✅ 3517ms
Partial Batch Handling.Prunes Ok Events On Partial Retry ✅ 6522ms
Partial Batch Handling.Prunes Dropped Events On Partial Retry ✅ 6520ms
Partial Batch Handling.Retries Only Retry Events From Partial ✅ 6526ms
Partial Batch Handling.Partial Retry Preserves Uuids ✅ 6523ms
Partial Batch Handling.Partial Retry Attempt Header Increments ✅ 6522ms
Partial Batch Handling.Partial Retry Request Id Preserved ✅ 6523ms
Partial Batch Handling.Respects Retry After On Partial ✅ 8523ms
Partial Batch Handling.Unknown Result Treated As Terminal ✅ 3518ms
Partial Batch Handling.Mixed Ok Drop Limited No Retry ✅ 3519ms
Compression.Sends Gzip Content Encoding ✅ 511ms
Compression.No Content Encoding When Disabled ✅ 512ms
Compression.Compressed Body Is Decompressible ✅ 511ms
Error Handling.Does Not Retry On Unknown 4Xx ✅ 2513ms
Event Options.Cookieless Mode Override ✅ 513ms
Event Options.Disable Skew Correction Override ✅ 511ms
Event Options.Process Person Profile Override ✅ 510ms
Event Options.Product Tour Id Override ✅ 511ms
Event Options.Unset Options Omitted ✅ 511ms
Event Options.Options Override In Batch ✅ 514ms
Geoip And Historical Migration.Geoip Disable Injected Into Properties ✅ 511ms
Geoip And Historical Migration.Historical Migration Set In Body ✅ 511ms
Geoip And Historical Migration.Historical Migration Absent By Default ✅ 511ms

Capture_Ai Tests

✅ 5/5 tests passed

View Details
Test Status Duration
Routing.Capture Ai Posts To Ai Endpoint ✅ 510ms
Routing.Capture Does Not Reroute Ai Named Events ✅ 511ms
Identity.Capture Ai Event Has Uuid ✅ 512ms
Identity.Capture Ai Keeps Supplied Uuid ✅ 511ms
Timestamp Format.Non Utc Event Timestamp Is Converted To Utc ✅ 511ms

Feature_Flags Tests

✅ 17/17 tests passed

View Details
Test Status Duration
Request Payload.Request With Person Properties Device Id ✅ 12ms
Request Payload.Flags Request Uses V4 Query Param ✅ 10ms
Request Payload.Flags Request Hits Flags Path Not Decide ✅ 11ms
Request Payload.Flags Request Omits Authorization Header ✅ 10ms
Request Payload.Token In Flags Body Matches Init ✅ 10ms
Request Payload.Groups Round Trip ✅ 11ms
Request Payload.Groups Default To Empty Object ✅ 10ms
Request Payload.Disable Geoip False Propagates As Geoip Disable False ✅ 11ms
Request Payload.Disable Geoip Omitted Defaults To False ✅ 11ms
Request Payload.Flag Keys To Evaluate Contains Only Requested Key ✅ 11ms
Request Lifecycle.No Flags Request On Init Alone ✅ 4ms
Request Lifecycle.No Flags Request On Normal Capture ✅ 511ms
Request Lifecycle.Two Flag Calls Produce Two Remote Requests ✅ 16ms
Request Lifecycle.Mock Response Value Is Returned To Caller ✅ 10ms
Retry Behavior.Retries Flags On 502 ✅ 315ms
Retry Behavior.Retries Flags On 504 ✅ 315ms
Side Effect Events.Get Feature Flag Captures Feature Flag Called Event ✅ 514ms

Feature_Flags_Local_Evaluation Tests

✅ 4/4 tests passed

View Details
Test Status Duration
Versioned Boolean Matching.Matching Version Missing ✅ 74ms
Versioned Boolean Matching.Matching Version 1 ✅ 75ms
Versioned Boolean Matching.Matching Version 2 ✅ 76ms
Versioned Boolean Matching.Version Only Reload 1 2 1 2 Missing ✅ 44ms

…ck extras

- The head cut read the character before an ASCII space, but redact_pii first
  turns Unicode spaces into ASCII ones, so "+44<NBSP> 7911..." was cut mid-number
  and its digits leaked once earlier redactions shrank the head. The check now
  reads the normalized text.
- Free text up to 8192 chars (4x the limit) is redacted whole, as on main, so
  CJK, newline- or tab-separated text and short prose before a long path keep
  their content. The head cut applies only above that; worst case is bounded.
- Feedback extra keys skip the entropy detector, as resource names do: it read
  names like Get_Organization_Memberships as credentials, and two such keys
  collapsed into one "[redacted]" entry. Keys still get the token, PII and URL
  passes, bounded the same way.
- An int past Python's 4300-digit str() limit inside an extra is "[redacted]"
  instead of raising, and _capture_extra_value keeps sanitizing inside its
  try, so one extra can no longer drop the whole $mcp_feedback event.

Tested: pytest posthog/test/mcp (717 passed, 11 skipped); ruff and mypy
baseline clean; the adversaries' repro scripts print the required output and
their differential fuzzer finds 0 head-vs-whole mismatches on seeds 1, 3, 5, 7.
@lucasheriques
lucasheriques marked this pull request as ready for review November 9, 2026 15:17
@lucasheriques
lucasheriques requested a review from a team as a code owner November 9, 2026 15:17
@veria-ai

veria-ai Bot commented Nov 9, 2026 •

Copy link
Copy Markdown

PR overview

The PR bounds intent and parameter capture work in MCP and updates feedback sanitization to redact extra keys and numbers.

One issue remains open in feedback sanitization: token-shaped keys in declared object extras can pass through intact to the outbound feedback metadata property, even though the same strings are redacted as values. This creates a credential-disclosure path when credentials appear as keys; known credential-format checks should be retained while omitting only the entropy heuristic. No issues have been addressed yet.

Open issues (1)

Fixed/addressed: 0 · PR risk: 5/10

Reviews

return _redact_bounded(
key,
lambda text: _sanitize_urls(
redact_pii(_POSTHOG_TOKEN_PATTERN.sub(_REDACTED_VALUE, text))

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Medium: Preserve known credential checks for feedback keys

This path skips known credential-format checks as well as entropy detection: for a declared object extra, meta={"sk-proj-" + "Ab9z" * 8: 1} reaches the outbound $mcp_feedback_meta property with the entire token-shaped key intact, although the same string as a value is redacted. Retain the known-format credential checks for keys while omitting only the entropy heuristic that misclassifies ordinary names.

@github-actions

github-actions Bot commented Nov 9, 2026

Copy link
Copy Markdown
Contributor

SDK performance: posthog-python

Benchmarks completed. No changes flagged for review.

Flags require at least a 5% change in the median and all six pairs agreeing on direction. This is a noise filter, not proof of a performance change or a CI gate.

View all results
Test Measurement Before PR With PR Change Assessment
Capture and deliver events (32-byte payload, burst)
Shard: capture
Managed heap after settling — — — unavailable; base:​ unsupported; head:​ unsupported; base:​ CPython has no uninstrumented total managed-heap byte counter; tracing would distort this RSS pass.​; head:​ CPython has no uninstrumented total managed-heap byte counter; tracing would distort this RSS pass.​
Capture and deliver events (4,096-byte payload, burst)
Shard: capture
Managed heap after settling — — — unavailable; base:​ unsupported; head:​ unsupported; base:​ CPython has no uninstrumented total managed-heap byte counter; tracing would distort this RSS pass.​; head:​ CPython has no uninstrumented total managed-heap byte counter; tracing would distort this RSS pass.​
Capture and deliver events (32-byte payload, steady)
Shard: capture
Managed heap after settling — — — unavailable; base:​ unsupported; head:​ unsupported; base:​ CPython has no uninstrumented total managed-heap byte counter; tracing would distort this RSS pass.​; head:​ CPython has no uninstrumented total managed-heap byte counter; tracing would distort this RSS pass.​
Capture and deliver events (4,096-byte payload, steady)
Shard: capture
Managed heap after settling — — — unavailable; base:​ unsupported; head:​ unsupported; base:​ CPython has no uninstrumented total managed-heap byte counter; tracing would distort this RSS pass.​; head:​ CPython has no uninstrumented total managed-heap byte counter; tracing would distort this RSS pass.​
Queue an event (32-byte payload)
Shard: capture
Allocated memory per operation — — — unavailable; base:​ unsupported; head:​ unsupported; base:​ Background preparation has no public completion boundary before flush; see capture.​drain for full-pipeline allocations.​; head:​ Background preparation has no public completion boundary before flush; see capture.​drain for full-pipeline allocations.​
Queue an event (32-byte payload)
Shard: capture
Allocations per operation — — — unavailable; base:​ unsupported; head:​ unsupported; base:​ Background preparation has no public completion boundary before flush; see capture.​drain for full-pipeline allocations.​; head:​ Background preparation has no public completion boundary before flush; see capture.​drain for full-pipeline allocations.​
Queue an event (4,096-byte payload)
Shard: capture
Allocated memory per operation — — — unavailable; base:​ unsupported; head:​ unsupported; base:​ Background preparation has no public completion boundary before flush; see capture.​drain for full-pipeline allocations.​; head:​ Background preparation has no public completion boundary before flush; see capture.​drain for full-pipeline allocations.​
Queue an event (4,096-byte payload)
Shard: capture
Allocations per operation — — — unavailable; base:​ unsupported; head:​ unsupported; base:​ Background preparation has no public completion boundary before flush; see capture.​drain for full-pipeline allocations.​; head:​ Background preparation has no public completion boundary before flush; see capture.​drain for full-pipeline allocations.​
Evaluate 1,000 flags under concurrent load
Shard: flags-memory
Managed heap after settling — — — unavailable; base:​ unsupported; head:​ unsupported; base:​ CPython has no uninstrumented total managed-heap byte counter; tracing would distort this RSS pass.​; head:​ CPython has no uninstrumented total managed-heap byte counter; tracing would distort this RSS pass.​
Refresh 1,000 flag definitions
Shard: flags-memory
Managed heap after settling — — — unavailable; base:​ unsupported; head:​ unsupported; base:​ CPython has no uninstrumented total managed-heap byte counter; tracing would distort this RSS pass.​; head:​ CPython has no uninstrumented total managed-heap byte counter; tracing would distort this RSS pass.​
Capture and deliver events (32-byte payload, burst)
Shard: capture
Allocated memory per operation 113.54 KiB/op 113.54 KiB/op <0.1% less memory Not flagged
Capture and deliver events (32-byte payload, burst)
Shard: capture
Allocations per operation 1,096.61 count/op 1,096.6 count/op <0.1% fewer Not flagged
Capture and deliver events (32-byte payload, burst)
Shard: capture
Time per operation 70.81 µs/op 70.22 µs/op 0.8% less time Not flagged
Capture and deliver events (32-byte payload, burst)
Shard: capture
Idle process memory (RSS) 36.03 MiB 36 MiB 0.1% less memory Not flagged
Capture and deliver events (32-byte payload, burst)
Shard: capture
Observed peak process memory (RSS) 37.77 MiB 37.73 MiB <0.1% less memory Not flagged
Capture and deliver events (32-byte payload, burst)
Shard: capture
Process memory after settling (RSS) 37.77 MiB 37.73 MiB <0.1% less memory Not flagged
Capture and deliver events (32-byte payload, burst)
Shard: capture
Process CPU time per operation 71.29 µs/op 71.18 µs/op 0.2% less time Not flagged
Capture and deliver events (32-byte payload, burst)
Shard: capture
Lifetime peak process memory (RSS) 40.46 MiB 40.46 MiB No observed change Not flagged
Capture and deliver events (4,096-byte payload, burst)
Shard: capture
Allocated memory per operation 166.1 KiB/op 166.1 KiB/op <0.1% more memory Not flagged
Capture and deliver events (4,096-byte payload, burst)
Shard: capture
Allocations per operation 1,089.59 count/op 1,089.59 count/op <0.1% more Not flagged
Capture and deliver events (4,096-byte payload, burst)
Shard: capture
Time per operation 98.02 µs/op 97.74 µs/op 0.3% less time Not flagged
Capture and deliver events (4,096-byte payload, burst)
Shard: capture
Idle process memory (RSS) 40.79 MiB 40.72 MiB 0.2% less memory Not flagged
Capture and deliver events (4,096-byte payload, burst)
Shard: capture
Observed peak process memory (RSS) 45.88 MiB 45.82 MiB 0.1% less memory Not flagged
Capture and deliver events (4,096-byte payload, burst)
Shard: capture
Process memory after settling (RSS) 45.88 MiB 45.82 MiB 0.1% less memory Not flagged
Capture and deliver events (4,096-byte payload, burst)
Shard: capture
Process CPU time per operation 91.26 µs/op 90.5 µs/op 0.8% less time Not flagged
Capture and deliver events (4,096-byte payload, burst)
Shard: capture
Lifetime peak process memory (RSS) 50.8 MiB 50.7 MiB 0.2% less memory Not flagged
Capture and deliver events (32-byte payload, steady)
Shard: capture
Allocated memory per operation 117.68 KiB/op 117.68 KiB/op <0.1% less memory Not flagged
Capture and deliver events (32-byte payload, steady)
Shard: capture
Allocations per operation 1,136.59 count/op 1,136.57 count/op <0.1% fewer Not flagged
Capture and deliver events (32-byte payload, steady)
Shard: capture
Time per operation 78.7 µs/op 78.3 µs/op 0.5% less time Not flagged
Capture and deliver events (32-byte payload, steady)
Shard: capture
Idle process memory (RSS) 35.96 MiB 35.97 MiB <0.1% more memory Not flagged
Capture and deliver events (32-byte payload, steady)
Shard: capture
Observed peak process memory (RSS) 36.06 MiB 36.08 MiB <0.1% more memory Not flagged
Capture and deliver events (32-byte payload, steady)
Shard: capture
Process memory after settling (RSS) 36.06 MiB 36.08 MiB <0.1% more memory Not flagged
Capture and deliver events (32-byte payload, steady)
Shard: capture
Process CPU time per operation 77.42 µs/op 76.97 µs/op 0.6% less time Not flagged
Capture and deliver events (32-byte payload, steady)
Shard: capture
Lifetime peak process memory (RSS) 40.46 MiB 40.46 MiB No observed change Not flagged
Capture and deliver events (4,096-byte payload, steady)
Shard: capture
Allocated memory per operation 170.57 KiB/op 170.57 KiB/op <0.1% more memory Not flagged
Capture and deliver events (4,096-byte payload, steady)
Shard: capture
Allocations per operation 1,129.48 count/op 1,129.49 count/op <0.1% more Not flagged
Capture and deliver events (4,096-byte payload, steady)
Shard: capture
Time per operation 104.62 µs/op 103.59 µs/op 1% less time Not flagged
Capture and deliver events (4,096-byte payload, steady)
Shard: capture
Idle process memory (RSS) 40.74 MiB 40.75 MiB <0.1% more memory Not flagged
Capture and deliver events (4,096-byte payload, steady)
Shard: capture
Observed peak process memory (RSS) 40.74 MiB 40.76 MiB <0.1% more memory Not flagged
Capture and deliver events (4,096-byte payload, steady)
Shard: capture
Process memory after settling (RSS) 40.74 MiB 40.76 MiB <0.1% more memory Not flagged
Capture and deliver events (4,096-byte payload, steady)
Shard: capture
Process CPU time per operation 99.44 µs/op 97.13 µs/op 2.3% less time Not flagged
Capture and deliver events (4,096-byte payload, steady)
Shard: capture
Lifetime peak process memory (RSS) 41.04 MiB 41.04 MiB <0.1% more memory Not flagged
Queue an event (32-byte payload)
Shard: capture
Caller-thread CPU time per operation 47.22 µs/op 47.78 µs/op 1.2% more time Not flagged
Queue an event (32-byte payload)
Shard: capture
Time per operation 49.27 µs/op 48.38 µs/op 1.8% less time Not flagged
Queue an event (4,096-byte payload)
Shard: capture
Caller-thread CPU time per operation 48.26 µs/op 48.69 µs/op 0.9% more time Not flagged
Queue an event (4,096-byte payload)
Shard: capture
Time per operation 49 µs/op 51.32 µs/op 4.7% more time Not flagged
Evaluate all 10 flags
Shard: flags-scaling
Allocated memory per operation 200.98 KiB/op 200.98 KiB/op No observed change Not flagged
Evaluate all 10 flags
Shard: flags-scaling
Allocations per operation 2,339.33 count/op 2,339.33 count/op No observed change Not flagged
Evaluate all 10 flags
Shard: flags-scaling
Time per operation 124.02 µs/op 123.65 µs/op 0.3% less time Not flagged
Evaluate all 10 flags
Shard: flags-scaling
Process CPU time per operation 124.03 µs/op 123.65 µs/op 0.3% less time Not flagged
Evaluate all 100 flags
Shard: flags-scaling
Allocated memory per operation 1.93 MiB/op 1.93 MiB/op No observed change Not flagged
Evaluate all 100 flags
Shard: flags-scaling
Allocations per operation 23,013.56 count/op 23,013.56 count/op No observed change Not flagged
Evaluate all 100 flags
Shard: flags-scaling
Time per operation 1.16 ms/op 1.16 ms/op <0.1% more time Not flagged
Evaluate all 100 flags
Shard: flags-scaling
Process CPU time per operation 1.16 ms/op 1.16 ms/op <0.1% more time Not flagged
Evaluate all 1,000 flags
Shard: flags-scaling
Allocated memory per operation 19.24 MiB/op 19.24 MiB/op No observed change Not flagged
Evaluate all 1,000 flags
Shard: flags-scaling
Allocations per operation 229,654.94 count/op 229,654.94 count/op No observed change Not flagged
Evaluate all 1,000 flags
Shard: flags-scaling
Time per operation 11.92 ms/op 11.89 ms/op 0.2% less time Not flagged
Evaluate all 1,000 flags
Shard: flags-scaling
Process CPU time per operation 11.92 ms/op 11.89 ms/op 0.2% less time Not flagged
Evaluate 1,000 flags under concurrent load
Shard: flags-memory
Idle process memory (RSS) 42.31 MiB 42.34 MiB <0.1% more memory Not flagged
Evaluate 1,000 flags under concurrent load
Shard: flags-memory
Observed peak process memory (RSS) 42.64 MiB 42.66 MiB <0.1% more memory Not flagged
Evaluate 1,000 flags under concurrent load
Shard: flags-memory
Process memory after settling (RSS) 42.64 MiB 42.66 MiB <0.1% more memory Not flagged
Evaluate 1,000 flags under concurrent load
Shard: flags-memory
Lifetime peak process memory (RSS) 42.46 MiB 42.57 MiB 0.2% more memory Not flagged
Refresh 1,000 flag definitions
Shard: flags-memory
Idle process memory (RSS) 41.81 MiB 42.06 MiB 0.6% more memory Not flagged
Refresh 1,000 flag definitions
Shard: flags-memory
Observed peak process memory (RSS) 42.21 MiB 42.16 MiB 0.1% less memory Not flagged
Refresh 1,000 flag definitions
Shard: flags-memory
Process memory after settling (RSS) 41.89 MiB 42.13 MiB 0.6% more memory Not flagged
Refresh 1,000 flag definitions
Shard: flags-memory
Lifetime peak process memory (RSS) 42.55 MiB 42.53 MiB <0.1% less memory Not flagged
Evaluate one boolean flag
Shard: flags-single
Allocated memory per operation 15.05 KiB/op 15.05 KiB/op No observed change Not flagged
Evaluate one boolean flag
Shard: flags-single
Allocations per operation 173.76 count/op 173.76 count/op No observed change Not flagged
Evaluate one boolean flag
Shard: flags-single
Time per operation 6.89 µs/op 6.89 µs/op 0.1% more time Not flagged
Evaluate one boolean flag
Shard: flags-single
Process CPU time per operation 6.89 µs/op 6.89 µs/op 0.1% more time Not flagged
Evaluate a cohort-filtered flag
Shard: flags-single
Allocated memory per operation 23.12 KiB/op 23.12 KiB/op No observed change Not flagged
Evaluate a cohort-filtered flag
Shard: flags-single
Allocations per operation 270.27 count/op 270.27 count/op No observed change Not flagged
Evaluate a cohort-filtered flag
Shard: flags-single
Time per operation 8.97 µs/op 9.02 µs/op 0.6% more time Not flagged
Evaluate a cohort-filtered flag
Shard: flags-single
Process CPU time per operation 8.97 µs/op 9.02 µs/op 0.6% more time Not flagged
Evaluate a flag (early match)
Shard: flags-single
Allocated memory per operation 35.71 KiB/op 35.71 KiB/op No observed change Not flagged
Evaluate a flag (early match)
Shard: flags-single
Allocations per operation 387.27 count/op 387.27 count/op No observed change Not flagged
Evaluate a flag (early match)
Shard: flags-single
Time per operation 13.17 µs/op 13.28 µs/op 0.8% more time Not flagged
Evaluate a flag (early match)
Shard: flags-single
Process CPU time per operation 13.17 µs/op 13.28 µs/op 0.8% more time Not flagged
Evaluate a flag (late match)
Shard: flags-single
Allocated memory per operation 53.59 KiB/op 53.59 KiB/op No observed change Not flagged
Evaluate a flag (late match)
Shard: flags-single
Allocations per operation 576.27 count/op 576.27 count/op No observed change Not flagged
Evaluate a flag (late match)
Shard: flags-single
Time per operation 18.83 µs/op 18.85 µs/op 0.1% more time Not flagged
Evaluate a flag (late match)
Shard: flags-single
Process CPU time per operation 18.83 µs/op 18.85 µs/op 0.1% more time Not flagged
Evaluate a flag (no match)
Shard: flags-single
Allocated memory per operation 52.14 KiB/op 52.14 KiB/op No observed change Not flagged
Evaluate a flag (no match)
Shard: flags-single
Allocations per operation 556.77 count/op 556.77 count/op No observed change Not flagged
Evaluate a flag (no match)
Shard: flags-single
Time per operation 17.82 µs/op 17.8 µs/op <0.1% less time Not flagged
Evaluate a flag (no match)
Shard: flags-single
Process CPU time per operation 17.82 µs/op 17.8 µs/op <0.1% less time Not flagged
Evaluate a property-filtered flag
Shard: flags-single
Allocated memory per operation 17.84 KiB/op 17.84 KiB/op No observed change Not flagged
Evaluate a property-filtered flag
Shard: flags-single
Allocations per operation 198.27 count/op 198.27 count/op No observed change Not flagged
Evaluate a property-filtered flag
Shard: flags-single
Time per operation 7.6 µs/op 7.66 µs/op 0.7% more time Not flagged
Evaluate a property-filtered flag
Shard: flags-single
Process CPU time per operation 7.6 µs/op 7.66 µs/op 0.7% more time Not flagged
Evaluate a regex-filtered flag
Shard: flags-single
Allocated memory per operation 19.23 KiB/op 19.23 KiB/op No observed change Not flagged
Evaluate a regex-filtered flag
Shard: flags-single
Allocations per operation 210.77 count/op 210.77 count/op No observed change Not flagged
Evaluate a regex-filtered flag
Shard: flags-single
Time per operation 8.12 µs/op 8.13 µs/op 0.2% more time Not flagged
Evaluate a regex-filtered flag
Shard: flags-single
Process CPU time per operation 8.12 µs/op 8.13 µs/op 0.2% more time Not flagged
Evaluate a rollout flag
Shard: flags-single
Allocated memory per operation 14.04 KiB/op 14.04 KiB/op No observed change Not flagged
Evaluate a rollout flag
Shard: flags-single
Allocations per operation 160.76 count/op 160.76 count/op No observed change Not flagged
Evaluate a rollout flag
Shard: flags-single
Time per operation 6.47 µs/op 6.44 µs/op 0.4% less time Not flagged
Evaluate a rollout flag
Shard: flags-single
Process CPU time per operation 6.47 µs/op 6.44 µs/op 0.4% less time Not flagged
Evaluate a multivariate flag
Shard: flags-single
Allocated memory per operation 15.21 KiB/op 15.21 KiB/op No observed change Not flagged
Evaluate a multivariate flag
Shard: flags-single
Allocations per operation 177.77 count/op 177.77 count/op No observed change Not flagged
Evaluate a multivariate flag
Shard: flags-single
Time per operation 7.23 µs/op 7.17 µs/op 0.8% less time Not flagged
Evaluate a multivariate flag
Shard: flags-single
Process CPU time per operation 7.23 µs/op 7.17 µs/op 0.8% less time Not flagged

Values are medians of paired measurements. Not flagged does not mean unchanged. Unavailable means a measurement could not be provided, not zero. Estimated allocation measurements retain their instrumentation uncertainty. RSS measures the whole process, not just SDK allocations; observed peaks are sampled checkpoints. Paired ranges in the diagnostics describe variability, not confidence intervals.

Each shard uses its own runner; compare Before PR against With PR within a row, not across shards.

Compared 2c35f0472a45b809e7b024a31edf1c85c60a391a → 311d11bc8f455e0df47c4361e4095fe3147c0c5b.

Full report and diagnostics

102 metric rows; 102 rows shown in all results. Omitted or shortened data remain in comparison.json, including per-runner environments, resource limits, measurement methods, samples and checkpoints.

@lucasheriques lucasheriques added the stamphog Request stamphog review label Nov 9, 2026

@stamphog stamphog Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not approved — this change needs a human reviewer.

Re-add the stamphog label to request another review once you have addressed this.

The credential-disclosure concern from @veria-ai remains unaddressed despite the reported no-findings review. Retain known credential-format checks for feedback keys, add regression coverage, and request review from Georgis Andonis or a security-aware teammate before re-requesting.

  • Author wrote 69% of the modified lines and has 9 merged PRs in these paths (familiarity MODERATE).
  • PostHog Review (Standard) reviewed the current head: no findings.
  • @veria-ai on posthog/mcp/_sanitization.py: the feedback-key sanitizer still applies only the PostHog-specific token pattern, personal-data redaction, and URL sanitization, bypassing the broader credential checks used for string values. Non-PostHog credential-shaped keys can therefore remain in outbound feedback metadata. The discussion from @veria-ai[bot] repeats this still-unfixed concern.
Gate mechanics and policy version
Gate Result
prerequisites ✓ all clear
deny-list ✓ no deny categories matched
size ✓ 272L, 3F substantive, 469L/7F incl. docs/generated/snapshots — within ceiling
tier ✓ T1-agent / T1d-complex (469L, 7F, two-areas, fix)
stamphog 2.7.0 .stamphog/policy.yml @ 311d11b · reviewed head 311d11b

@stamphog stamphog Bot removed the stamphog Request stamphog review label Nov 9, 2026

@dustinbyrne dustinbyrne left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The bounded traversal and feedback key/number redaction fixes are verified. One intentional long-text retention change still needs reconciliation with the current MCP Payload privacy contract; details inline.

AI-assisted review.

if len(value) <= _MAX_WHOLE_FREE_TEXT_LENGTH:
return redact(value)
head = _free_text_head(value)
return redact(head) + _TRUNCATION_SUFFIX if head else _REDACTED_VALUE

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P2/design] Reconcile blanket long-text replacement with the canonical bounded-prefix contract

Disposition: REQUIRED acceptance decision.
Relationship: Intentional, introduced behavior change.

When benign intent exceeds 8,192 characters and has no qualifying ASCII-space boundary in its first 4,096 characters, this fallback replaces all its content with [redacted]. For example:

client.capture_tool_call(
    "search_docs",
    intent="请帮我查询" * 1800,  # 9,000 characters
    duration_ms=1,
)
client.flush()

Base delivered a 2,048-character prefix; this head delivers the ten-character placeholder. The same sanitizer also serves feedback text and unknown-tool names.

The current canonical MCP specification requires keeping the start and prescribes a bounded limit-plus-1,024 / longest-common-prefix algorithm. A missing ASCII-space boundary does not itself make CJK prose a credential.

Both inspected independent peers retain text here: JS falls back to whole-string sanitation, while Go retains a bounded UTF-8 prefix. Their existing algorithm differences do not override the specification.

This is an atypically long input, and the replacement is privacy-conservative: no new disclosure, tool failure or broad event loss is demonstrated. Nevertheless, affected users lose all useful intent content. Backend intent clustering and session display consume the supplied string and cannot restore it.

Consider aligning this changed free-text path with the canonical bounded-prefix contract while retaining the existing straddle/privacy tests. Alternatively, explicitly resolve the policy divergence against that specification before accepting it. The PR and changeset disclose the tradeoff, but disclosure alone does not establish acceptance. This does not require expanding the PR to unrelated sanitizer debt.

Executed public regression:

def test_public_cjk_intent_retains_prefix():
    from unittest.mock import patch
    from posthog.mcp import PostHogMCP

    text = "请帮我查询" * 1800
    client = PostHogMCP("phc_test", enable_exception_autocapture=False)
    try:
        with patch.object(client, "capture") as captured:
            client.capture_tool_call("search_docs", intent=text, duration_ms=1)
            client.flush()
            value = captured.call_args.kwargs["properties"]["$mcp_intent"]
            assert value.startswith(text[:1000])
            assert len(value) <= 2048
    finally:
        client.shutdown()

Run through the isolated pytest runner with:

run_pytest.py <snapshot> --rootdir=<validation> \
  <validation>/test_regressions.py \
  -k public_cjk_intent_retains_prefix -q --tb=short --timeout=30

It passes on merge base and fails on reviewed head specifically because the value is [redacted]. A separate real-HTTP smoke confirms the same delivered-content change; its ordinary short-intent control is unchanged. No fix was applied or retested.

@dustinbyrne
dustinbyrne requested a review from a team November 9, 2026 19:08

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants