Skip to content

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

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

lucasheriques wants to merge 8 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 keys, and unknown tool names) follows the sdk-specs bounded scan. Text up to 3072 characters (the 2048 limit plus a 1024 margin) is redacted whole. Longer text is redacted twice, once cut at 3072 and once cut at 2048. Capture keeps the common prefix of the two results and appends .... A match split by the 2048 cut differs between the two results. A match split by the 3072 cut lies past the end of the second result. So neither reaches the capture, and the cost no longer grows with the input.

| $mcp_intent | main | This PR |
| "1-1." * 2048 (8 KB) | not measured separately | 14 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) | 14 ms, same output |
| "1-1." * 3_000_000 (12 MB) | 41.2 s, 1-1.1-1.1-1.… (2048 chars) | 14 ms, same output |
| "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 |
| "s" * 5000, "s" * 9000 or "请帮我查询" * 1800 | first 2048 chars | same |
| "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 bounded scan. A card, an SSN, a phone number (one with an NBSP before a space too) or a URL with userinfo straddles the 2048 cut or the 3072 cut, after a start that shrinks by 1500 characters when redacted. None of their digits or credentials reach the capture. Dropping either of the two redactions fails three of those cases.

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 pass, the known-format credential check (sk-proj-…, PEM and the other vendor formats), and the PII and URL passes. They skip only the entropy detector. 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 3072 characters is redacted in full, as on main. Longer intents keep their start, 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

  • 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 and _SCAN_MARGIN (the bounded scan), and _sanitize_free_text_key with _is_known_secret (keys)
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 posthog/test/test_exception_capture.py: 756 passed, 11 skipped, after merging main.
  • 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 straddle tests fail when either of the two redactions in _redact_bounded is dropped (3 cases each).
  • 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 first version cut at an ASCII space that no pattern matches across. Review pointed to the sdk-specs bounded scan (cut at limit + 1024 and at the limit, keep the common prefix), and this PR now follows it. The space rule turned long space-free text into [redacted], and it depended on a hand-kept list of characters a number continues across, which the NBSP leak showed is easy to get wrong. 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-13T23:18:30.201647+00:00
Duration: 253547ms

✅ All Tests Passed!

271/271 tests passed


Capture_V1 Tests

✅ 95/95 tests passed

View Details
Test Status Duration
Endpoint And Method.Targets V1 Endpoint ✅ 514ms
Endpoint And Method.Does Not Use Legacy Endpoints ✅ 509ms
Required Headers.Has Authorization Bearer Header ✅ 508ms
Required Headers.Has Content Type Json ✅ 509ms
Required Headers.Has Posthog Sdk Info Format ✅ 509ms
Required Headers.Has Posthog Attempt Header ✅ 508ms
Required Headers.Has Posthog Request Id ✅ 508ms
Required Headers.Has Posthog Request Timestamp ✅ 508ms
Required Headers.Has User Agent ✅ 509ms
Body Format.Body Has Created At And Batch ✅ 509ms
Body Format.No Api Key In Body ✅ 509ms
Body Format.No Sent At In Body ✅ 509ms
Event Format.Event Has Required Root Fields ✅ 508ms
Event Format.Event Uuid Is Valid ✅ 509ms
Event Format.Event Timestamp Is Rfc3339 ✅ 508ms
Event Format.Non Utc Event Timestamp Is Converted To Utc ✅ 521ms
Event Format.Distinct Id Is String ✅ 508ms
Event Format.Distinct Id At Root Not Properties ✅ 510ms
Event Format.Custom Properties Preserved ✅ 508ms
Event Format.Set Properties Preserved ✅ 510ms
Event Format.Set Once Properties Preserved ✅ 508ms
Event Format.Groups Properties Preserved ✅ 509ms
Event Format.Sdk Generates Uuid If Not Provided ✅ 508ms
Event Format.Event Has Required Root Fields Batch ✅ 512ms
Event Format.Event Uuid Is Valid Batch ✅ 512ms
Event Format.Event Timestamp Is Rfc3339 Batch ✅ 511ms
Event Format.Distinct Id Is String Batch ✅ 512ms
Event Format.Distinct Id At Root Not Properties Batch ✅ 511ms
Event Format.Custom Properties Preserved Batch ✅ 511ms
Event Format.Set Properties Preserved Batch ✅ 511ms
Event Format.Set Once Properties Preserved Batch ✅ 510ms
Event Format.Groups Properties Preserved Batch ✅ 511ms
Event Format.Sdk Generates Uuid If Not Provided Batch ✅ 511ms
Batch Behavior.Multiple Events In Single Batch ✅ 514ms
Batch Behavior.Batch Envelope Smoke ✅ 513ms
Batch Behavior.Flush With No Events Sends Nothing ✅ 505ms
Batch Behavior.Flush At Triggers Batch ✅ 1009ms
Batch Behavior.Created At Reflects Batch Creation Time ✅ 509ms
Deduplication.Generates Unique Uuids ✅ 514ms
Deduplication.Different Events Same Content Different Uuids ✅ 510ms
Deduplication.Preserves Uuid On Retry ✅ 6518ms
Deduplication.Preserves Timestamp On Retry ✅ 6516ms
Deduplication.Preserves Uuid And Timestamp On Batch Retry ✅ 6516ms
Deduplication.No Duplicate Events In Batch ✅ 514ms
Header Behavior On Retry.Attempt Header Starts At One ✅ 509ms
Header Behavior On Retry.Attempt Header Increments On Retry ✅ 13526ms
Header Behavior On Retry.Request Id Preserved On Retry ✅ 6514ms
Header Behavior On Retry.Different Requests Have Different Request Ids ✅ 3017ms
Header Behavior On Retry.Request Timestamp Changes On Retry ✅ 6518ms
Response Format Validation.Success Response Has Uuid Keyed Results ✅ 509ms
Response Format Validation.Success Response Has Ok For Each Event ✅ 512ms
Response Format Validation.Success No Retry After When All Ok ✅ 510ms
Response Format Validation.Success Retry After Present When Retry Events ✅ 1514ms
Response Format Validation.Success No Retry After When Drop Only ✅ 510ms
Response Format Validation.Response Echoes Request Id ✅ 509ms
Retry Behavior.Retries On 408 ✅ 6517ms
Retry Behavior.Retries On 500 ✅ 6513ms
Retry Behavior.Retries On 503 ✅ 8518ms
Retry Behavior.Retries On 504 ✅ 6518ms
Retry Behavior.Retries On 404 ✅ 6518ms
Retry Behavior.Retries On 429 ✅ 6522ms
Retry Behavior.Retries On 502 ✅ 6514ms
Retry Behavior.Retryable Errors Have Retry After ✅ 3515ms
Retry Behavior.Respects Retry After On Retryable Error ✅ 11519ms
Retry Behavior.Does Not Retry On 400 ✅ 2512ms
Retry Behavior.Does Not Retry On 401 ✅ 2512ms
Retry Behavior.Does Not Retry On 402 ✅ 2511ms
Retry Behavior.Does Not Retry On 413 ✅ 2513ms
Retry Behavior.Does Not Retry On 415 ✅ 2511ms
Retry Behavior.Non Retryable Errors Have No Retry After ✅ 2512ms
Retry Behavior.Implements Backoff ✅ 19529ms
Partial Batch Handling.Handles 200 Full Success ✅ 2510ms
Partial Batch Handling.Handles 200 With All Ok ✅ 3516ms
Partial Batch Handling.Does Not Retry Dropped Events ✅ 3514ms
Partial Batch Handling.Does Not Retry Warning Events ✅ 3513ms
Partial Batch Handling.Prunes Ok Events On Partial Retry ✅ 6519ms
Partial Batch Handling.Prunes Dropped Events On Partial Retry ✅ 6516ms
Partial Batch Handling.Retries Only Retry Events From Partial ✅ 6521ms
Partial Batch Handling.Partial Retry Preserves Uuids ✅ 6517ms
Partial Batch Handling.Partial Retry Attempt Header Increments ✅ 6519ms
Partial Batch Handling.Partial Retry Request Id Preserved ✅ 6519ms
Partial Batch Handling.Respects Retry After On Partial ✅ 8520ms
Partial Batch Handling.Unknown Result Treated As Terminal ✅ 3515ms
Partial Batch Handling.Mixed Ok Drop Warning No Retry ✅ 3517ms
Compression.Sends Gzip Content Encoding ✅ 509ms
Compression.No Content Encoding When Disabled ✅ 509ms
Compression.Compressed Body Is Decompressible ✅ 509ms
Error Handling.Does Not Retry On Unknown 4Xx ✅ 2511ms
Event Options.Cookieless Mode Override ✅ 509ms
Event Options.Disable Skew Correction Override ✅ 509ms
Event Options.Process Person Profile Override ✅ 509ms
Event Options.Product Tour Id Override ✅ 509ms
Event Options.Unset Options Omitted ✅ 509ms
Event Options.Options Override In Batch ✅ 511ms
Geoip And Historical Migration.Historical Migration Absent By Default ✅ 510ms

Capture_Ai Tests

✅ 5/5 tests passed

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

Feature_Flags Tests

✅ 17/17 tests passed

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

Feature_Flags_V4 Tests

✅ 150/150 tests passed

View Details
Test Status Duration
Legacy Getters.Legacy Getter Reads Reader Fixture[Readers.Complete Split] ✅ 10ms
Legacy Getters.Legacy Getter Reads Reader Fixture[Readers.Partial Split Missing Config Version] ✅ 8ms
Legacy Getters.Legacy Getter Reads Reader Fixture[Readers.Partial Split Missing Rule Type] ✅ 7ms
Legacy Getters.Legacy Getter Reads Reader Fixture[Readers.Partial Split Missing Rule Id] ✅ 8ms
Legacy Getters.Legacy Getter Reads Reader Fixture[Readers.Partial Split Missing Variant Key] ✅ 8ms
Legacy Getters.Legacy Getter Reads Reader Fixture[Readers.Split Wrong Type Config Version] ✅ 8ms
Legacy Getters.Legacy Getter Reads Reader Fixture[Readers.Split Wrong Type Rule Type] ✅ 8ms
Legacy Getters.Legacy Getter Reads Reader Fixture[Readers.Split Wrong Type Rule Id] ✅ 7ms
Legacy Getters.Legacy Getter Reads Reader Fixture[Readers.Split Wrong Type Experiment Id] ✅ 7ms
Legacy Getters.Legacy Getter Reads Reader Fixture[Readers.Split Extra Experiment Id] ✅ 8ms
Legacy Getters.Legacy Getter Reads Reader Fixture[Readers.Split Wrong Type Variant Key] ✅ 7ms
Legacy Getters.Legacy Getter Reads Reader Fixture[Readers.Wrong Rule Type Literal] ✅ 8ms
Legacy Getters.Legacy Getter Reads Reader Fixture[Readers.Unknown Fields] ✅ 8ms
Legacy Getters.Legacy Getter Reads Reader Fixture[Readers.Old Server Variant] ✅ 7ms
Legacy Getters.Legacy Getter Reads Reader Fixture[Readers.Old Server Boolean] ✅ 7ms
Legacy Getters.Legacy Getter Reads Reader Fixture[Readers.Old Server Null Variant] ✅ 7ms
Legacy Getters.Legacy Getter Reads Reader Fixture[Readers.Old Server False] ✅ 7ms
Legacy Getters.Legacy Getter Reads Reader Fixture[Readers.V4 Value Takes Precedence] ✅ 8ms
Legacy Getters.Legacy Getter Reads Reader Fixture[Readers.Missing Flag] ✅ 6ms
Legacy Getters.Legacy Getter Reads Reader Fixture[Readers.Targeting Match Targeted Release] ✅ 7ms
Legacy Getters.Legacy Getter Reads Reader Fixture[Readers.Targeting Match Percentage Rollout] ✅ 8ms
Legacy Getters.Legacy Getter Reads Reader Fixture[Readers.Rollout Miss Percentage Rollout] ✅ 8ms
Legacy Getters.Legacy Getter Reads Reader Fixture[Readers.Rollout Miss Experiment] ✅ 7ms
Legacy Getters.Legacy Getter Reads Reader Fixture[Readers.Experiment Paused] ✅ 8ms
Legacy Getters.Legacy Getter Reads Reader Fixture[Readers.Holdout] ✅ 7ms
Legacy Getters.Legacy Getter Reads Reader Fixture[Readers.Holdout Variant Key] ✅ 7ms
Legacy Getters.Legacy Getter Reads Reader Fixture[Readers.No Rule Match] ✅ 8ms
Legacy Getters.Legacy Getter Reads Reader Fixture[Readers.Missing Group Key] ✅ 8ms
Legacy Getters.Legacy Getter Reads Reader Fixture[Readers.Dependency Error] ✅ 9ms
Legacy Getters.Legacy Getter Reads Reader Fixture[Readers.Error] ✅ 8ms
Legacy Getters.Legacy Getter Reads Reader Fixture[Readers.Error With Value] ✅ 7ms
Legacy Getters.Legacy Getter Reads Reader Fixture[Readers.Malformed Sibling Isolation] ✅ 7ms
Legacy Getters.Legacy Getter Reads Reader Fixture[Readers.Partial Split String Value] ✅ 7ms
Legacy Getters.Legacy Getter Reads Reader Fixture[Readers.V1 At V4] ✅ 7ms
Legacy Getters.Legacy Getter Reads Reader Fixture[Readers.V1 Failed] ✅ 7ms
Legacy Getters.Legacy Getter Reads Reader Fixture[Readers.Integral Json Number Context] ✅ 8ms
Legacy Getters.Legacy Getter Reads Reader Fixture[Readers.Flag Key Seed] ✅ 7ms
Legacy Getters.Legacy Getter Reads Typed Value[V2 Value.String.Target] ✅ 6ms
Legacy Getters.Legacy Getter Reads Typed Value[V2 Value.String.Rollout Hit] ✅ 7ms
Legacy Getters.Legacy Getter Reads Typed Value[V2 Value.String.Miss Return Default] ✅ 7ms
Legacy Getters.Legacy Getter Reads Typed Value[V2 Value.String.Miss Return Default Null] ✅ 7ms
Legacy Getters.Legacy Getter Reads Typed Value[V2 Value.String.Miss Continue] ✅ 7ms
Legacy Getters.Legacy Getter Reads Typed Value[V2 Value.String.No Match Default] ✅ 7ms
Legacy Getters.Legacy Getter Reads Typed Value[V2 Value.String.No Match Null] ✅ 7ms
Legacy Getters.Legacy Getter Reads Typed Value[V2 Value.String.Order First Match] ✅ 7ms
Legacy Getters.Legacy Getter Reads Typed Value[V2 Value.String.Order Targeting Miss] ✅ 7ms
Legacy Getters.Legacy Getter Reads Typed Value[V2 Value.Number.Target] ✅ 8ms
Legacy Getters.Legacy Getter Reads Typed Value[V2 Value.Number.Rollout Hit] ✅ 7ms
Legacy Getters.Legacy Getter Reads Typed Value[V2 Value.Number.Miss Return Default] ✅ 7ms
Legacy Getters.Legacy Getter Reads Typed Value[V2 Value.Number.Miss Return Default Null] ✅ 7ms
Legacy Getters.Legacy Getter Reads Typed Value[V2 Value.Number.Miss Continue] ✅ 8ms
Legacy Getters.Legacy Getter Reads Typed Value[V2 Value.Number.No Match Default] ✅ 8ms
Legacy Getters.Legacy Getter Reads Typed Value[V2 Value.Number.No Match Null] ✅ 29ms
Legacy Getters.Legacy Getter Reads Typed Value[V2 Value.Number.Order First Match] ✅ 8ms
Legacy Getters.Legacy Getter Reads Typed Value[V2 Value.Number.Order Targeting Miss] ✅ 7ms
Legacy Getters.Legacy Getter Reads Typed Value[V2 Value.Object.Target] ✅ 7ms
Legacy Getters.Legacy Getter Reads Typed Value[V2 Value.Object.Rollout Hit] ✅ 7ms
Legacy Getters.Legacy Getter Reads Typed Value[V2 Value.Object.Miss Return Default] ✅ 7ms
Legacy Getters.Legacy Getter Reads Typed Value[V2 Value.Object.Miss Return Default Null] ✅ 7ms
Legacy Getters.Legacy Getter Reads Typed Value[V2 Value.Object.Miss Continue] ✅ 8ms
Legacy Getters.Legacy Getter Reads Typed Value[V2 Value.Object.No Match Default] ✅ 7ms
Legacy Getters.Legacy Getter Reads Typed Value[V2 Value.Object.No Match Null] ✅ 7ms
Legacy Getters.Legacy Getter Reads Typed Value[V2 Value.Object.Order First Match] ✅ 7ms
Legacy Getters.Legacy Getter Reads Typed Value[V2 Value.Object.Order Targeting Miss] ✅ 7ms
Legacy Getters.Legacy Getter Reads Typed Value[V2 Value.String.Unicode] ✅ 7ms
Legacy Getters.Legacy Getter Reads Typed Value[V2 Value.String.Looks Boolean] ✅ 8ms
Legacy Getters.Legacy Getter Reads Typed Value[V2 Value.Number.Zero] ✅ 8ms
Legacy Getters.Legacy Getter Reads Typed Value[V2 Value.Number.Negative Decimal] ✅ 9ms
Legacy Getters.Legacy Getter Reads Typed Value[V2 Value.Number.Short Decimal] ✅ 9ms
Legacy Getters.Legacy Getter Reads Typed Value[V2 Value.Number.Exponent] ✅ 8ms
Legacy Getters.Legacy Getter Reads Typed Value[V2 Value.Number.Max Safe Integer] ✅ 9ms
Legacy Getters.Legacy Getter Reads Typed Value[V2 Value.Number.Min Safe Integer] ✅ 8ms
Legacy Getters.Legacy Getter Reads Typed Value[V2 Value.Object.Empty] ✅ 9ms
Legacy Getters.Legacy Getter Reads Typed Value[V2 Value.Object.Nested] ✅ 9ms
Legacy Getters.Legacy Getter Reads Typed Value[V2 Value.Object.Max Depth] ✅ 8ms
Legacy Payloads.Legacy Payload Reads Reader Fixture[Readers.Complete Split] ✅ 7ms
Legacy Payloads.Legacy Payload Reads Reader Fixture[Readers.Partial Split Missing Config Version] ✅ 6ms
Legacy Payloads.Legacy Payload Reads Reader Fixture[Readers.Partial Split Missing Rule Type] ✅ 6ms
Legacy Payloads.Legacy Payload Reads Reader Fixture[Readers.Partial Split Missing Rule Id] ✅ 7ms
Legacy Payloads.Legacy Payload Reads Reader Fixture[Readers.Partial Split Missing Variant Key] ✅ 6ms
Legacy Payloads.Legacy Payload Reads Reader Fixture[Readers.Split Wrong Type Config Version] ✅ 6ms
Legacy Payloads.Legacy Payload Reads Reader Fixture[Readers.Split Wrong Type Rule Type] ✅ 6ms
Legacy Payloads.Legacy Payload Reads Reader Fixture[Readers.Split Wrong Type Rule Id] ✅ 6ms
Legacy Payloads.Legacy Payload Reads Reader Fixture[Readers.Split Wrong Type Experiment Id] ✅ 6ms
Legacy Payloads.Legacy Payload Reads Reader Fixture[Readers.Split Extra Experiment Id] ✅ 6ms
Legacy Payloads.Legacy Payload Reads Reader Fixture[Readers.Split Wrong Type Variant Key] ✅ 6ms
Legacy Payloads.Legacy Payload Reads Reader Fixture[Readers.Wrong Rule Type Literal] ✅ 6ms
Legacy Payloads.Legacy Payload Reads Reader Fixture[Readers.Unknown Fields] ✅ 5ms
Legacy Payloads.Legacy Payload Reads Reader Fixture[Readers.Old Server Variant] ✅ 6ms
Legacy Payloads.Legacy Payload Reads Reader Fixture[Readers.Old Server Boolean] ✅ 5ms
Legacy Payloads.Legacy Payload Reads Reader Fixture[Readers.Old Server Null Variant] ✅ 6ms
Legacy Payloads.Legacy Payload Reads Reader Fixture[Readers.Old Server False] ✅ 5ms
Legacy Payloads.Legacy Payload Reads Reader Fixture[Readers.V4 Value Takes Precedence] ✅ 6ms
Legacy Payloads.Legacy Payload Reads Reader Fixture[Readers.Missing Flag] ✅ 6ms
Legacy Payloads.Legacy Payload Reads Reader Fixture[Readers.Targeting Match Targeted Release] ✅ 7ms
Legacy Payloads.Legacy Payload Reads Reader Fixture[Readers.Targeting Match Percentage Rollout] ✅ 6ms
Legacy Payloads.Legacy Payload Reads Reader Fixture[Readers.Rollout Miss Percentage Rollout] ✅ 6ms
Legacy Payloads.Legacy Payload Reads Reader Fixture[Readers.Rollout Miss Experiment] ✅ 7ms
Legacy Payloads.Legacy Payload Reads Reader Fixture[Readers.Experiment Paused] ✅ 6ms
Legacy Payloads.Legacy Payload Reads Reader Fixture[Readers.Holdout] ✅ 6ms
Legacy Payloads.Legacy Payload Reads Reader Fixture[Readers.Holdout Variant Key] ✅ 6ms
Legacy Payloads.Legacy Payload Reads Reader Fixture[Readers.No Rule Match] ✅ 7ms
Legacy Payloads.Legacy Payload Reads Reader Fixture[Readers.Missing Group Key] ✅ 6ms
Legacy Payloads.Legacy Payload Reads Reader Fixture[Readers.Dependency Error] ✅ 6ms
Legacy Payloads.Legacy Payload Reads Reader Fixture[Readers.Error] ✅ 6ms
Legacy Payloads.Legacy Payload Reads Reader Fixture[Readers.Error With Value] ✅ 6ms
Legacy Payloads.Legacy Payload Reads Reader Fixture[Readers.Malformed Sibling Isolation] ✅ 6ms
Legacy Payloads.Legacy Payload Reads Reader Fixture[Readers.Partial Split String Value] ✅ 5ms
Legacy Payloads.Legacy Payload Reads Reader Fixture[Readers.V1 At V4] ✅ 6ms
Legacy Payloads.Legacy Payload Reads Reader Fixture[Readers.V1 Failed] ✅ 6ms
Legacy Payloads.Legacy Payload Reads Reader Fixture[Readers.Integral Json Number Context] ✅ 5ms
Legacy Payloads.Legacy Payload Reads Reader Fixture[Readers.Flag Key Seed] ✅ 5ms
Legacy Payloads.Legacy Payload Is The Typed Value[V2 Value.String.Target] ✅ 6ms
Legacy Payloads.Legacy Payload Is The Typed Value[V2 Value.String.Rollout Hit] ✅ 5ms
Legacy Payloads.Legacy Payload Is The Typed Value[V2 Value.String.Miss Return Default] ✅ 5ms
Legacy Payloads.Legacy Payload Is The Typed Value[V2 Value.String.Miss Return Default Null] ✅ 5ms
Legacy Payloads.Legacy Payload Is The Typed Value[V2 Value.String.Miss Continue] ✅ 5ms
Legacy Payloads.Legacy Payload Is The Typed Value[V2 Value.String.No Match Default] ✅ 5ms
Legacy Payloads.Legacy Payload Is The Typed Value[V2 Value.String.No Match Null] ✅ 5ms
Legacy Payloads.Legacy Payload Is The Typed Value[V2 Value.String.Order First Match] ✅ 6ms
Legacy Payloads.Legacy Payload Is The Typed Value[V2 Value.String.Order Targeting Miss] ✅ 5ms
Legacy Payloads.Legacy Payload Is The Typed Value[V2 Value.Number.Target] ✅ 6ms
Legacy Payloads.Legacy Payload Is The Typed Value[V2 Value.Number.Rollout Hit] ✅ 6ms
Legacy Payloads.Legacy Payload Is The Typed Value[V2 Value.Number.Miss Return Default] ✅ 5ms
Legacy Payloads.Legacy Payload Is The Typed Value[V2 Value.Number.Miss Return Default Null] ✅ 6ms
Legacy Payloads.Legacy Payload Is The Typed Value[V2 Value.Number.Miss Continue] ✅ 5ms
Legacy Payloads.Legacy Payload Is The Typed Value[V2 Value.Number.No Match Default] ✅ 6ms
Legacy Payloads.Legacy Payload Is The Typed Value[V2 Value.Number.No Match Null] ✅ 5ms
Legacy Payloads.Legacy Payload Is The Typed Value[V2 Value.Number.Order First Match] ✅ 5ms
Legacy Payloads.Legacy Payload Is The Typed Value[V2 Value.Number.Order Targeting Miss] ✅ 6ms
Legacy Payloads.Legacy Payload Is The Typed Value[V2 Value.Object.Target] ✅ 5ms
Legacy Payloads.Legacy Payload Is The Typed Value[V2 Value.Object.Rollout Hit] ✅ 6ms
Legacy Payloads.Legacy Payload Is The Typed Value[V2 Value.Object.Miss Return Default] ✅ 5ms
Legacy Payloads.Legacy Payload Is The Typed Value[V2 Value.Object.Miss Return Default Null] ✅ 5ms
Legacy Payloads.Legacy Payload Is The Typed Value[V2 Value.Object.Miss Continue] ✅ 6ms
Legacy Payloads.Legacy Payload Is The Typed Value[V2 Value.Object.No Match Default] ✅ 6ms
Legacy Payloads.Legacy Payload Is The Typed Value[V2 Value.Object.No Match Null] ✅ 5ms
Legacy Payloads.Legacy Payload Is The Typed Value[V2 Value.Object.Order First Match] ✅ 6ms
Legacy Payloads.Legacy Payload Is The Typed Value[V2 Value.Object.Order Targeting Miss] ✅ 5ms
Legacy Payloads.Legacy Payload Is The Typed Value[V2 Value.String.Unicode] ✅ 5ms
Legacy Payloads.Legacy Payload Is The Typed Value[V2 Value.String.Looks Boolean] ✅ 6ms
Legacy Payloads.Legacy Payload Is The Typed Value[V2 Value.Number.Zero] ✅ 5ms
Legacy Payloads.Legacy Payload Is The Typed Value[V2 Value.Number.Negative Decimal] ✅ 6ms
Legacy Payloads.Legacy Payload Is The Typed Value[V2 Value.Number.Short Decimal] ✅ 5ms
Legacy Payloads.Legacy Payload Is The Typed Value[V2 Value.Number.Exponent] ✅ 6ms
Legacy Payloads.Legacy Payload Is The Typed Value[V2 Value.Number.Max Safe Integer] ✅ 6ms
Legacy Payloads.Legacy Payload Is The Typed Value[V2 Value.Number.Min Safe Integer] ✅ 5ms
Legacy Payloads.Legacy Payload Is The Typed Value[V2 Value.Object.Empty] ✅ 6ms
Legacy Payloads.Legacy Payload Is The Typed Value[V2 Value.Object.Nested] ✅ 5ms
Legacy Payloads.Legacy Payload Is The Typed Value[V2 Value.Object.Max Depth] ✅ 6ms

Feature_Flags_Local_Evaluation Tests

✅ 4/4 tests passed

View Details
Test Status Duration
Versioned Boolean Matching.Matching Version Missing ✅ 48ms
Versioned Boolean Matching.Matching Version 1 ✅ 48ms
Versioned Boolean Matching.Matching Version 2 ✅ 44ms
Versioned Boolean Matching.Version Only Reload 1 2 1 2 Missing ✅ 27ms

…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

All previously flagged issues have been addressed. No open security concerns remain on this pull request.

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

Reviews

Comment thread posthog/mcp/_sanitization.py Resolved
@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 115.72 KiB/op 115.72 KiB/op <0.1% more memory Not flagged
Capture and deliver events (32-byte payload, burst)
Shard: capture
Allocations per operation 1,107.59 count/op 1,107.6 count/op <0.1% more Not flagged
Capture and deliver events (32-byte payload, burst)
Shard: capture
Time per operation 64.58 µs/op 64.02 µs/op 0.9% less time Not flagged
Capture and deliver events (32-byte payload, burst)
Shard: capture
Idle process memory (RSS) 36.19 MiB 36.23 MiB 0.1% more memory Not flagged
Capture and deliver events (32-byte payload, burst)
Shard: capture
Observed peak process memory (RSS) 37.93 MiB 37.96 MiB <0.1% more memory Not flagged
Capture and deliver events (32-byte payload, burst)
Shard: capture
Process memory after settling (RSS) 37.93 MiB 37.96 MiB <0.1% more memory Not flagged
Capture and deliver events (32-byte payload, burst)
Shard: capture
Process CPU time per operation 67.17 µs/op 65.88 µs/op 1.9% less time Not flagged
Capture and deliver events (32-byte payload, burst)
Shard: capture
Lifetime peak process memory (RSS) 40.12 MiB 40.12 MiB No observed change Not flagged
Capture and deliver events (4,096-byte payload, burst)
Shard: capture
Allocated memory per operation 168.28 KiB/op 168.28 KiB/op <0.1% less memory Not flagged
Capture and deliver events (4,096-byte payload, burst)
Shard: capture
Allocations per operation 1,100.59 count/op 1,100.59 count/op <0.1% fewer Not flagged
Capture and deliver events (4,096-byte payload, burst)
Shard: capture
Time per operation 88 µs/op 88.01 µs/op <0.1% more time Not flagged
Capture and deliver events (4,096-byte payload, burst)
Shard: capture
Idle process memory (RSS) 41.12 MiB 41.11 MiB <0.1% less memory Not flagged
Capture and deliver events (4,096-byte payload, burst)
Shard: capture
Observed peak process memory (RSS) 46.18 MiB 46.18 MiB <0.1% less memory Not flagged
Capture and deliver events (4,096-byte payload, burst)
Shard: capture
Process memory after settling (RSS) 46.18 MiB 46.18 MiB <0.1% less memory Not flagged
Capture and deliver events (4,096-byte payload, burst)
Shard: capture
Process CPU time per operation 85.37 µs/op 85.69 µs/op 0.4% more time Not flagged
Capture and deliver events (4,096-byte payload, burst)
Shard: capture
Lifetime peak process memory (RSS) 51.02 MiB 50.98 MiB <0.1% less memory Not flagged
Capture and deliver events (32-byte payload, steady)
Shard: capture
Allocated memory per operation 119.86 KiB/op 119.85 KiB/op <0.1% less memory Not flagged
Capture and deliver events (32-byte payload, steady)
Shard: capture
Allocations per operation 1,147.5 count/op 1,147.48 count/op <0.1% fewer Not flagged
Capture and deliver events (32-byte payload, steady)
Shard: capture
Time per operation 74.07 µs/op 74.5 µs/op 0.6% more time Not flagged
Capture and deliver events (32-byte payload, steady)
Shard: capture
Idle process memory (RSS) 36.13 MiB 36.12 MiB <0.1% less memory Not flagged
Capture and deliver events (32-byte payload, steady)
Shard: capture
Observed peak process memory (RSS) 36.22 MiB 36.21 MiB <0.1% less memory Not flagged
Capture and deliver events (32-byte payload, steady)
Shard: capture
Process memory after settling (RSS) 36.22 MiB 36.21 MiB <0.1% less memory Not flagged
Capture and deliver events (32-byte payload, steady)
Shard: capture
Process CPU time per operation 76.76 µs/op 76.98 µs/op 0.3% more time Not flagged
Capture and deliver events (32-byte payload, steady)
Shard: capture
Lifetime peak process memory (RSS) 40.12 MiB 40.12 MiB No observed change Not flagged
Capture and deliver events (4,096-byte payload, steady)
Shard: capture
Allocated memory per operation 172.74 KiB/op 172.74 KiB/op <0.1% more memory Not flagged
Capture and deliver events (4,096-byte payload, steady)
Shard: capture
Allocations per operation 1,140.33 count/op 1,140.33 count/op <0.1% more Not flagged
Capture and deliver events (4,096-byte payload, steady)
Shard: capture
Time per operation 92.6 µs/op 92.26 µs/op 0.4% less time Not flagged
Capture and deliver events (4,096-byte payload, steady)
Shard: capture
Idle process memory (RSS) 40.99 MiB 40.98 MiB <0.1% less memory Not flagged
Capture and deliver events (4,096-byte payload, steady)
Shard: capture
Observed peak process memory (RSS) 40.99 MiB 40.98 MiB <0.1% less memory Not flagged
Capture and deliver events (4,096-byte payload, steady)
Shard: capture
Process memory after settling (RSS) 40.98 MiB 40.98 MiB <0.1% less memory Not flagged
Capture and deliver events (4,096-byte payload, steady)
Shard: capture
Process CPU time per operation 90.51 µs/op 88.53 µs/op 2.2% less time Not flagged
Capture and deliver events (4,096-byte payload, steady)
Shard: capture
Lifetime peak process memory (RSS) 41.32 MiB 41.38 MiB 0.1% more memory Not flagged
Queue an event (32-byte payload)
Shard: capture
Caller-thread CPU time per operation 43.31 µs/op 43.46 µs/op 0.3% more time Not flagged
Queue an event (32-byte payload)
Shard: capture
Time per operation 43.31 µs/op 43.46 µs/op 0.3% more time Not flagged
Queue an event (4,096-byte payload)
Shard: capture
Caller-thread CPU time per operation 43.44 µs/op 43.25 µs/op 0.4% less time Not flagged
Queue an event (4,096-byte payload)
Shard: capture
Time per operation 43.44 µs/op 43.24 µs/op 0.4% less time Not flagged
Evaluate all 10 flags
Shard: flags-scaling
Allocated memory per operation 226.56 KiB/op 226.56 KiB/op No observed change Not flagged
Evaluate all 10 flags
Shard: flags-scaling
Allocations per operation 2,240.44 count/op 2,240.44 count/op No observed change Not flagged
Evaluate all 10 flags
Shard: flags-scaling
Time per operation 84.41 µs/op 84.32 µs/op 0.1% less time Not flagged
Evaluate all 10 flags
Shard: flags-scaling
Process CPU time per operation 84.42 µs/op 84.32 µs/op 0.1% less time Not flagged
Evaluate all 100 flags
Shard: flags-scaling
Allocated memory per operation 2.17 MiB/op 2.17 MiB/op No observed change Not flagged
Evaluate all 100 flags
Shard: flags-scaling
Allocations per operation 22,014.88 count/op 22,014.88 count/op No observed change Not flagged
Evaluate all 100 flags
Shard: flags-scaling
Time per operation 784.72 µs/op 782.04 µs/op 0.3% less time Not flagged
Evaluate all 100 flags
Shard: flags-scaling
Process CPU time per operation 784.75 µs/op 782.08 µs/op 0.3% less time Not flagged
Evaluate all 1,000 flags
Shard: flags-scaling
Allocated memory per operation 21.69 MiB/op 21.69 MiB/op No observed change Not flagged
Evaluate all 1,000 flags
Shard: flags-scaling
Allocations per operation 219,656.62 count/op 219,656.62 count/op No observed change Not flagged
Evaluate all 1,000 flags
Shard: flags-scaling
Time per operation 7.82 ms/op 7.82 ms/op <0.1% less time Not flagged
Evaluate all 1,000 flags
Shard: flags-scaling
Process CPU time per operation 7.82 ms/op 7.82 ms/op <0.1% less time Not flagged
Evaluate 1,000 flags under concurrent load
Shard: flags-memory
Idle process memory (RSS) 42.82 MiB 42.86 MiB 0.1% more memory Not flagged
Evaluate 1,000 flags under concurrent load
Shard: flags-memory
Observed peak process memory (RSS) 43.15 MiB 43.22 MiB 0.2% more memory Not flagged
Evaluate 1,000 flags under concurrent load
Shard: flags-memory
Process memory after settling (RSS) 43.15 MiB 43.22 MiB 0.2% more memory Not flagged
Evaluate 1,000 flags under concurrent load
Shard: flags-memory
Lifetime peak process memory (RSS) 42.98 MiB 43.1 MiB 0.3% more memory Not flagged
Refresh 1,000 flag definitions
Shard: flags-memory
Idle process memory (RSS) 42.12 MiB 41.35 MiB 1.8% less memory Not flagged
Refresh 1,000 flag definitions
Shard: flags-memory
Observed peak process memory (RSS) 42.19 MiB 42.12 MiB 0.2% less memory Not flagged
Refresh 1,000 flag definitions
Shard: flags-memory
Process memory after settling (RSS) 42.17 MiB 42.09 MiB 0.2% less memory Not flagged
Refresh 1,000 flag definitions
Shard: flags-memory
Lifetime peak process memory (RSS) 42.5 MiB 42.51 MiB <0.1% more memory Not flagged
Evaluate one boolean flag
Shard: flags-single
Allocated memory per operation 16.63 KiB/op 16.63 KiB/op No observed change Not flagged
Evaluate one boolean flag
Shard: flags-single
Allocations per operation 164.77 count/op 164.77 count/op No observed change Not flagged
Evaluate one boolean flag
Shard: flags-single
Time per operation 15.39 µs/op 15.38 µs/op 0.1% less time Not flagged
Evaluate one boolean flag
Shard: flags-single
Process CPU time per operation 15.4 µs/op 15.38 µs/op 0.1% less time Not flagged
Evaluate a cohort-filtered flag
Shard: flags-single
Allocated memory per operation 25.49 KiB/op 25.49 KiB/op No observed change Not flagged
Evaluate a cohort-filtered flag
Shard: flags-single
Allocations per operation 241.28 count/op 241.28 count/op No observed change Not flagged
Evaluate a cohort-filtered flag
Shard: flags-single
Time per operation 24.35 µs/op 23.83 µs/op 2.1% less time Not flagged
Evaluate a cohort-filtered flag
Shard: flags-single
Process CPU time per operation 24.35 µs/op 23.83 µs/op 2.1% less time Not flagged
Evaluate a flag (early match)
Shard: flags-single
Allocated memory per operation 40.7 KiB/op 40.7 KiB/op No observed change Not flagged
Evaluate a flag (early match)
Shard: flags-single
Allocations per operation 390.27 count/op 390.27 count/op No observed change Not flagged
Evaluate a flag (early match)
Shard: flags-single
Time per operation 29.64 µs/op 29.63 µs/op <0.1% less time Not flagged
Evaluate a flag (early match)
Shard: flags-single
Process CPU time per operation 29.64 µs/op 29.63 µs/op <0.1% less time Not flagged
Evaluate a flag (late match)
Shard: flags-single
Allocated memory per operation 61.48 KiB/op 61.48 KiB/op No observed change Not flagged
Evaluate a flag (late match)
Shard: flags-single
Allocations per operation 586.27 count/op 586.27 count/op No observed change Not flagged
Evaluate a flag (late match)
Shard: flags-single
Time per operation 40.88 µs/op 40.73 µs/op 0.4% less time Not flagged
Evaluate a flag (late match)
Shard: flags-single
Process CPU time per operation 40.88 µs/op 40.73 µs/op 0.4% less time Not flagged
Evaluate a flag (no match)
Shard: flags-single
Allocated memory per operation 60.11 KiB/op 60.11 KiB/op No observed change Not flagged
Evaluate a flag (no match)
Shard: flags-single
Allocations per operation 569.77 count/op 569.77 count/op No observed change Not flagged
Evaluate a flag (no match)
Shard: flags-single
Time per operation 37.33 µs/op 37.38 µs/op 0.1% more time Not flagged
Evaluate a flag (no match)
Shard: flags-single
Process CPU time per operation 37.33 µs/op 37.38 µs/op 0.1% more time Not flagged
Evaluate a property-filtered flag
Shard: flags-single
Allocated memory per operation 19.93 KiB/op 19.93 KiB/op No observed change Not flagged
Evaluate a property-filtered flag
Shard: flags-single
Allocations per operation 194.28 count/op 194.28 count/op No observed change Not flagged
Evaluate a property-filtered flag
Shard: flags-single
Time per operation 18.4 µs/op 18.36 µs/op 0.2% less time Not flagged
Evaluate a property-filtered flag
Shard: flags-single
Process CPU time per operation 18.4 µs/op 18.36 µs/op 0.2% less time Not flagged
Evaluate a regex-filtered flag
Shard: flags-single
Allocated memory per operation 21.56 KiB/op 21.56 KiB/op No observed change Not flagged
Evaluate a regex-filtered flag
Shard: flags-single
Allocations per operation 199.78 count/op 199.78 count/op No observed change Not flagged
Evaluate a regex-filtered flag
Shard: flags-single
Time per operation 19.61 µs/op 19.4 µs/op 1.1% less time Not flagged
Evaluate a regex-filtered flag
Shard: flags-single
Process CPU time per operation 19.61 µs/op 19.4 µs/op 1.1% less time Not flagged
Evaluate a rollout flag
Shard: flags-single
Allocated memory per operation 15.79 KiB/op 15.79 KiB/op No observed change Not flagged
Evaluate a rollout flag
Shard: flags-single
Allocations per operation 154.77 count/op 154.77 count/op No observed change Not flagged
Evaluate a rollout flag
Shard: flags-single
Time per operation 14.54 µs/op 14.58 µs/op 0.2% more time Not flagged
Evaluate a rollout flag
Shard: flags-single
Process CPU time per operation 14.54 µs/op 14.58 µs/op 0.2% more time Not flagged
Evaluate a multivariate flag
Shard: flags-single
Allocated memory per operation 16.79 KiB/op 16.79 KiB/op No observed change Not flagged
Evaluate a multivariate flag
Shard: flags-single
Allocations per operation 168.77 count/op 168.77 count/op No observed change Not flagged
Evaluate a multivariate flag
Shard: flags-single
Time per operation 16.19 µs/op 15.95 µs/op 1.5% less time Not flagged
Evaluate a multivariate flag
Shard: flags-single
Process CPU time per operation 16.19 µs/op 15.95 µs/op 1.5% 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 ec112a6201569e1cf5e9b8827a01793de523f643 → 6b49022ef5e1a7b4e740fe269253bb77a393b978.

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.

Comment thread posthog/mcp/_sanitization.py Resolved
@dustinbyrne
dustinbyrne requested a review from a team November 9, 2026 19:08
…scan; keep known credential formats in feedback keys

Long free text (intent, feedback fields, feedback keys) now follows the
sdk-specs bounded scan: redact the text cut at limit+1024 and at the limit,
keep their common prefix. Text with no ASCII-space boundary (CJK, one long
word) keeps its start instead of becoming [redacted]. Feedback keys keep the
known-format credential checks (sk-proj-..., PEM) and still skip entropy.

Tested: pytest posthog/test/mcp + test_exception_capture (756 passed,
11 skipped); straddle tests fail under either one-scan mutation; ruff, mypy
baseline clean.
@lucasheriques lucasheriques added the stamphog Request stamphog review label Nov 12, 2026

@posthog posthog Bot 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.

PostHog Review · Standard · reviewed d526a20: 1 Must fix.

See this review and its findings in PostHog Review. Comment @posthog review --deep for a Deep review. A Deep review takes longer and looks deeper than a Standard review.

Comment thread posthog/mcp/_sanitization.py Outdated
Comment on lines +433 to +437
kept = os.path.commonprefix(
[
redact(value[: _MAX_USER_INTENT_LENGTH + _SCAN_MARGIN]),
redact(value[:_MAX_USER_INTENT_LENGTH]),
]

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.

P1 · Redact URL credentials that extend past both scan cuts

With intent "https://admin:" + "p" * 4000 + "@localhost/x", both slices end before @, so neither URL pass recognizes userinfo. _redact_bounded retains their raw common prefix and captures roughly 2 KB of the password in $mcp_intent; the previous full pass removes it. Preserve the privacy contract (AGENTS.md:21) by redacting incomplete URLs at the scan boundary.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed in 6b49022. When the word crossing the longer scan cut starts inside what truncation keeps and opens a credential whose end neither scan reached — an unterminated URL authority (no /, ?, # after the last ://) or a JWT (eyJ) — _redact_bounded now replaces everything from that word on with [redacted] instead of trusting the two prefixes' agreement (_unfinished_credential_start). Those are the two detectors that only match once they see the far end of the span (userinfo's @, the JWT's second .); query/JSON keys are seen before their values and the vendor formats match on a prefix, so they were already safe. CJK, long words and long paths keep their start.

New parameterized rows in test_intent_scan_fails_closed_on_a_credential_unfinished_at_both_cuts: https://admin:" + "p"*4000 + "@localhost/x, a password with an @ before the cuts, a nested ?next=https://admin:... URL, and a 1631-char signed JWT at offset 1550 (last . at 3154). All four failed on d526a20 and are redacted on origin/main; all pass now. pytest posthog/test/mcp: 815 passed.

@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 current-head review raises a security concern that the code still exhibits; the discussion bot's all-clear claim does not resolve it. Fix @PostHog's inline finding in posthog/mcp/_sanitization.py, add a regression test for credentials extending beyond both scan cuts, and request fresh review from a teammate or agent; Georgis Andonis or Aleksander Błaszkiewicz are suggested reviewers.

  • Author wrote 61% of the modified lines and has 9 merged PRs in these paths (familiarity MODERATE).
  • PostHog Review (Standard) reviewed the current head: 1 finding, posted on the PR.
  • posthog[bot] reviewed the current head.
  • @PostHog, posthog/mcp/_sanitization.py: The URL-credential exposure remains unfixed. _redact_bounded sanitizes only the first 2048 and 3072 characters and retains their common prefix. For an HTTPS URL with username admin and a 4000-character repeated-letter password, both cuts precede the userinfo delimiter; the URL sanitizer leaves these incomplete authorities unchanged, retaining password text in captured intent. The full URL would have its userinfo removed.
Gate mechanics and policy version
Gate Result
prerequisites ✓ all clear
deny-list ✓ no deny categories matched
size ✓ 309L, 4F substantive, 500L/8F incl. docs/generated/snapshots — within ceiling
tier ✓ T1-agent / T1d-complex (500L, 8F, two-areas, fix)
stamphog 2.8.0 .stamphog/policy.yml @ d526a20 · reviewed head d526a20

@stamphog stamphog Bot removed the stamphog Request stamphog review label Nov 12, 2026
Comment thread posthog/mcp/_sanitization.py Resolved

@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 earlier long-text retention issue and known-format feedback-key cases are addressed. One introduced bounded-scan privacy regression remains when both cuts end inside URL userinfo; details inline.

AI-assisted review.

"""``redact`` over ``value``, at a cost bounded by what truncation keeps."""
if len(value) <= _MAX_USER_INTENT_LENGTH + _SCAN_MARGIN:
return redact(value)
kept = os.path.commonprefix(

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.

F1 — [P1] Fail closed when both cuts leave URL userinfo incomplete

Severity: IMPORTANT. Disposition: REQUIRED. Relationship: Introduced.
Location: posthog/mcp/_sanitization.py:433, RIGHT.

A valid URL whose userinfo continues past both scan cuts can now leave password text in $mcp_intent, whereas base redacted it:

text = "https://admin:" + "p" * 4000 + "@localhost/x"
client.capture_tool_call("search_docs", intent=text, duration_ms=1)
client.flush()

This result is established by source analysis, not an executed reproduction. The @ is at offset 4014, after both cuts at 2048 and 3072. Both prefixes lack the delimiter _redact_userinfo requires. The entropy check excludes URLs containing ://; none of the known-format/PII passes matches this fixture; URL sanitation returns each prefix unchanged. Their common prefix is therefore the first 2048 raw characters. Final truncation retains https://admin:, 2031 password characters, and ....

On merge base 7eabca81234311dfd86e516c4354c5322f73bfc5, the complete URL reaches the same userinfo sanitizer before truncation, so its credentials are removed. This final URL is valid and below the existing 8192-character URL bound. PostHogMCP.capture_tool_call(intent=...) accepts it; instrumented intent reaches the same sanitizer. Default later capture/backend processing does not repair the value.

The package promises URL username/password redaction, and the canonical privacy requirement expressly includes URL userinfo. The implementation follows the prescribed two-cut method, but its safety explanation only covers matches no longer than the margin. Agreement between two unfinished scans does not establish credential safety for longer spans.

The trigger is unusually long narrated credential data. The source-determined impact is disclosure of a password prefix, not a complete reusable credential or account takeover. The same helper handles feedback free text; ordinary structured parameter/response/error sanitation is not newly affected here.

Consider failing closed on credential-bearing authorities left unfinished at a scan boundary while preserving bounded processing. Keep the repaired CJK/prose prefix behavior and existing short-straddle tests; add a case whose userinfo terminator lies beyond both cuts. Qualify the helper’s safety explanation and add equivalent specification coverage rather than restoring unbounded scanning or blanket replacement of space-free text.

Test sketch — not executed:

from unittest.mock import patch
from posthog.mcp import PostHogMCP


def captured_intent(text):
    client = PostHogMCP(
        "phc_test", send=False, 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()
            assert captured.call_count == 1
            return captured.call_args.kwargs["properties"]["$mcp_intent"]
    finally:
        client.shutdown()


def test_url_userinfo_spanning_both_cuts_is_not_captured():
    value = captured_intent("https://admin:" + "p" * 4000 + "@localhost/x")
    assert "p" * 32 not in value
    assert len(value) <= 2048


def test_short_url_userinfo_is_redacted():
    value = captured_intent("https://admin:review-only-password@localhost/x")
    assert "review-only-password" not in value


def test_long_cjk_retains_prefix():
    text = "请帮我查询" * 1800
    value = captured_intent(text)
    assert value.startswith(text[:1000])
    assert len(value) <= 2048

Run equivalent inputs on base 7eabca81234311dfd86e516c4354c5322f73bfc5 and head d526a20de727f45a596e808cb0672ee124021168:

pytest -q test_followup_regressions.py \
  -k 'url_userinfo or long_cjk or ordinary_short_intent' --timeout=30

Expected: all assertions pass. Code-inferred: base passes; head fails the long-URL assertion, while short-URL/CJK controls pass. No actual local base/head result is claimed. Mandatory memory-limit setup failed before any SDK import (RLIMIT_AS, with DATA/RSS alternatives also rejected); a suitable container runtime was unavailable. A memory-enforced, offline, credential-free sandbox is needed to execute the sketch. Existing CI passes short URL-boundary and CJK cases but does not cover this both-cuts-inside-one-authority case. No fix was applied or retested.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed in 6b49022. When the word crossing the longer scan cut starts inside what truncation keeps and opens a credential whose end neither scan reached — an unterminated URL authority (no /, ?, # after the last ://) or a JWT (eyJ) — _redact_bounded now replaces everything from that word on with [redacted] instead of trusting the two prefixes' agreement (_unfinished_credential_start). Those are the two detectors that only match once they see the far end of the span (userinfo's @, the JWT's second .); query/JSON keys are seen before their values and the vendor formats match on a prefix, so they were already safe. CJK, long words and long paths keep their start.

New parameterized rows in test_intent_scan_fails_closed_on_a_credential_unfinished_at_both_cuts: https://admin:" + "p"*4000 + "@localhost/x, a password with an @ before the cuts, a nested ?next=https://admin:... URL, and a 1631-char signed JWT at offset 1550 (last . at 3154). All four failed on d526a20 and are redacted on origin/main; all pass now. pytest posthog/test/mcp: 815 passed.

lucasheriques and others added 2 commits November 13, 2026 20:11
Conflicts resolved:
- _truncation.py: keep main's _visit_container extraction, threading this
  branch's _Walk (redacts_key + memo) through it.
- _sanitization.py: keep main's _redact_json_members pass ahead of the token
  passes, with this branch's is_secret parameter; keep main's extracted
  _sanitize_url_fragment alongside this branch's limit_entries rewrite of the
  captured-parameters builders.
- test_pipeline.py: union of imports.

Tested: uv run pytest posthog/test/mcp -> 810 passed, 11 skipped.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…h scan cuts

The bounded intent scan keeps the common prefix of two redactions, which is
only safe for matches shorter than the margin. URL userinfo (needs its `@`)
and a JWT (needs its second `.`) can run past both cuts, so neither scan
recognized them and their raw start landed in $mcp_intent, where main
redacted it. When the word crossing the longer cut starts inside what
truncation keeps and opens an unterminated URL authority or a JWT, everything
from that word on is now replaced by [redacted]. Long CJK, long words and
long paths keep their start as before.

Tested (TDD): four parameterized rows (url password past both cuts, an `@`
inside the password, a nested url, veria's 1631-char JWT at offset 1550)
failed on the previous head and are confirmed redacted by origin/main; plus a
long-url-path control. uv run pytest posthog/test/mcp -> 815 passed,
11 skipped; ruff check/format clean; mypy --config-file mypy.ini posthog/mcp
| mypy-baseline filter -> clean.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

@posthog posthog Bot 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.

PostHog Review · Standard · reviewed 6b49022: 1 Should fix.

Comment on lines +438 to +440
kept = os.path.commonprefix(
[redact(value[:scan_end]), redact(value[:_MAX_USER_INTENT_LENGTH])]
)

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 · Fail closed on credential-shaped tokens spanning both cuts

For an intent containing "Ab1_" * 600 + "Xy2Z_9mK4jV5qR7tS1wE6uI0oP3lC8dF-" * 160, the previous full pass returns [redacted], but _redact_bounded captures 2,045 raw characters. Both slices have insufficient entropy to trigger _is_secret, although the complete token does. The common-prefix check therefore exposes a previously protected credential; handle unfinished credential-shaped tokens conservatively.

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