Skip to content

Keep rules: wallet tokens ship as sent (0.17.0, #159487) - #71

Merged
jab3z merged 16 commits into
mainfrom
feat/keep-rules
Oct 1, 2026
Merged

jab3z merged 16 commits into
mainfrom
feat/keep-rules

Conversation

@jab3z

@jab3z jab3z commented Oct 1, 2026 •

Copy link
Copy Markdown
Contributor

Dacian's decision on #159487 (2026-10-01): wallet tokens (Apple Pay, Google Pay) ship in logs exactly as sent. They are single-use, and debugging needs them. Saved-card tokens stay masked.

In 0.16.0, ECSCTX_MASK_VALUE_RULES could only replace a shape with its label. Turning Ottu's WALLET_RULES off would not have shipped the tokens either:

  • the token container rule masks every leaf of a token held under token;
  • signature and ephemeralPublicKey read as secrets;
  • transactionId reads as a payment id;
  • Connect's extra secret keys mask signature in bodies.

What this adds

  • Keep rules: KeepRule(matches, hints), listed in the same ECSCTX_MASK_VALUE_RULES. The first rule to match wins. A kept value is copied as written, and the walk does not descend into it.

  • Floor: nothing is kept under a CVV or SAD key, or inside a CVV/SAD container (in the key walk, and the key right before an object in text).

  • Guard: a match is not kept when it holds, at any depth:

    • a key or {name, value} pair that reads as card, CVV or SAD;
    • a whole leaf that is a card number;
    • a key written twice;
    • a Python repr that does not re-render as written.

    So even KeepRule(lambda v: True) cannot ship card data. One exemption: 13-digit epoch milliseconds (from a 1 or a 2, so until 2065), which is Google Pay's keyExpiration.

  • Text and bodies: a kept span is set aside behind a [KEPT-…-MASKED] placeholder before any content rule runs, then restored. This applies in mask_by_patterns and redact_body, entity-encoded bodies included.

  • Helpers for a service's own masking pass: is_kept(value, key=) and mask_outside_kept(text, mask).

  • Ottu contrib, breaking (feat(contrib)!):

    • WALLET_RULES are now strict keep rules: the Apple Pay token and its paymentData, and Google Pay. Exact key sets, version values, and text leaves only.
    • WALLET_SAD_RULES keep 0.16.0's labels, for any service that wants them.
    • Connect and Ottu PG list [*WALLET_RULES], so they flip on upgrade, as intended.
  • Fix: configuring value rules clears the strings cached as clean.

  • Docs: README "Keep rules", the Ottu paragraph, the redact_body and text-rules paragraphs, CLAUDE.md files and docstrings.

Verification

  • Tests: 5765 passed on Python 3.12 and on 3.10 (4744 at 0.16.0). Each group's tests failed first.
  • Golden file: golden/masking_0156.json is byte-identical under WALLET_SAD_RULES. Under WALLET_RULES, every golden input without a wallet hint still masks as the file says.
  • Review: a task review requested changes (C1 duplicate keys and repr comments, I1, I2, M1), and all were fixed test-first. The scoped re-review approved, after 129,600 mutation-fuzz checks with 0 leaks.
  • Bot review (two runs):
    • The split-cache churn is fixed (perf, 21774f4): 100 wallet lines split the rules 305 times, and now once.
    • Two README notes added (docs, a403054): a keep match the guard refuses asks no later rule (a test pins it), and the guard reads card data only, so PII or a credential inside a kept value ships with it.
  • Bench (µs per call, v0.16.0 / this branch):
    • Without value rules: within noise.
    • With WALLET_RULES, on lines without a wallet: 1.5–4.6% slower.
    • On a line with a wallet: 651 µs, against 0.16.0's label rules at 555 (before the cache fix).
    • The cache fix alone, on wallet lines (an Apple Pay body, a KPay <udf9>): about 88 µs down to 82.

Parked (follow-up ticket, none blocking)

  • M2: in text, a wallet in a list or pair under a CVV key is kept (the key walk floors it).
  • M3: distinct kept values inside a hashed credential share one token.
  • M4: a placeholder can be forged through an entity-encoded leaf; restore then copies the wallet. No card data or credential ships.
    • Fix it by naming placeholders from a hash of the text: deterministic, so tokens stay stable, and impossible to forge from inside the text.
    • Don't fix it by skipping a leaf that decodes to [KEPT-. A probe shows that ships the leaf's entity-encoded password in clear.
  • M5: about 10% of entity-encoded Google Pay tokens are altered under pci. This fails closed.
  • M6: a rule with both field_type and keep loads as a keep rule.
  • M7 and N4: README wording on cost, and on text-only leaves.
  • M8: nothing inside a kept value is labelled.
  • N2: a repr inside JSON-encoded text is not kept. This fails closed.
  • N3: free text in a wallet's text field ships (the guard is key-, pair- and leaf-based). The README now says so beside the guard; the follow-up is an alphabet check on opaque slots.
  • Samsung Pay: its JWE needs a real captured token before a matcher can be pinned.

Release

Minor: 0.17.0. The release note must name the breaking change: Connect and Ottu PG ship wallet tokens as sent on upgrade. The feat(contrib)! commit subject carries it into the changelog. Connect and Ottu PG adopt it next, pinning >=0.17.0. Connect also adopts is_kept / mask_outside_kept in its own pass, and raises its body log cap so a whole Apple Pay token fits.

Refs #159487

🤖 Generated with Claude Code

jab3z and others added 14 commits October 1, 2026 15:25
The known-clean set is keyed on the content rules alone, and what is
clean depends on the value rules too: a string masked clean with no value
rule configured came back unmasked once one was. configure_masking_value_rules
and _reset_masking_config now empty it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…as sent (ECSCTX_MASK_VALUE_RULES)

A keep rule (`KeepRule`, or anything whose `keep` is True with a callable
`matches`) goes in the same ECSCTX_MASK_VALUE_RULES list as the label
rules; rules are asked in list order and the first match wins. A value a
keep rule matches -- a mapping the key walk meets, or JSON text -- is
left as sent under any key, a credential's or a card's included, and the
walk does not descend into it. Two things it cannot override:

- the floor: nothing is kept under a CVV or SAD key, inside a CVV or SAD
  container (a key the service lists there included), or as the value of
  a pair labelled as one; such a value is masked as it would be without
  the rule;
- the guard: a match holding a card, CVV or SAD key, or a card number, at
  any depth, JSON text inside it included, is walked as if nothing
  matched. A string leaf is a card number when pan_shaped reads it as one
  and its digits pass int_is_pan: pan_shaped alone reads Google Pay's
  13-digit keyExpiration as a card number.

A keep matcher that raises counts as no match; a label rule keeps
0.16.0's contract and leaves the marker. A rule's hints must be a
collection of strings, or configuring it raises: anything else raised
where text is masked. JSON text under a hashing key that holds a kept
value deeper is still hashed whole. The key walk asks each mapping once,
where it asked twice, and each rule's kind is cached on the rule tuple's
identity.

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

mask_by_patterns sets aside each JSON object a keep rule ships -- in a
message, a repr, JSON in a JSON string -- as a `[KEPT-<letters>-MASKED]`
placeholder (stash_kept), runs the rules over the rest, and puts each
placeholder still there back as it was written. A label's shape with no
digit, so every rule leaves it; one a rule destroys takes the value with
it. Text that already holds `[KEPT-` is masked as before.

kept_spans reads rule 2's objects top down: a label match is no span and
is not looked into, an object nothing matches is read member by member,
and nothing right after a CVV or SAD key (a quoted key, `key=`, an XML
start tag) is kept. A credential before it (`token=`, `"token":`,
`Bearer`, `<password>`) does not stop it.

Rule 2 and mask_objects run only for label rules, and a rule is asked
only about text holding one of its hints, the key walk of JSON text
included.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
redact_body labels what a label rule matches first (mask_objects), then
sets aside what a keep rule ships (stash_kept), runs its body rules over
the rest and puts the kept values back as written. A credential's JSON
value, an extra secret key such as Connect's `signature`, and a form
value no longer read into one: `"token": "{\"version\"…}"` keeps its
quotes.

An element's entity-encoded JSON is read as it decodes (_encoded_leaf),
and when it keeps a value the element's text is set aside too: base64's
`==` after a run holding `cvv` read as a CVV form field, and about 1 in
250 entity-encoded Apple Pay tokens lost a padding byte to the label.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…s after ecsctx's

A service that masks again after ecsctx (Connect does) must leave what
ecsctx ships as sent as it was. is_kept(value, key=None) says whether a
mapping or JSON text is one: a keep rule in force matches it first, the
guard lets it through, and the key it sits under is no CVV or SAD key.
mask_outside_kept(text, mask) runs a service's text masker with each
kept value set aside as a `[KEPT-<letters>-MASKED]` placeholder, putting
back each one the masker left. Both never raise.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…T_SAD_RULES label them as 0.16.0 did

Decided for #159487 on 2026-10-01: an Apple Pay or Google Pay token is
single-use, and the token as it was sent is what debugs a wallet payment.
WALLET_RULES are now keep rules with strict matchers -- an Apple Pay
PKPaymentToken, its `paymentData` (EC_v1, RSA_v1), a Google Pay payment
method token -- and nothing besides the keys each writes, so a token with
a key of its own is walked as before (a PKPaymentToken keeps only its
paymentData). Saved-card tokens stay masked; nothing under a CVV or SAD
key is kept. Samsung Pay (a JWE) is not matched: no captured token pins
its shape yet.

WALLET_SAD_RULES = (APPLE_PAY_RULE, GOOGLE_PAY_RULE) are 0.16.0's label
rules, unchanged. The golden test runs with them, so masking_0156.json
stays byte-identical; with WALLET_RULES every golden input that holds no
wallet hint masks exactly as the file says.

BREAKING CHANGE: a service listing `ECSCTX_MASK_VALUE_RULES =
[*WALLET_RULES]` logs wallet tokens as sent instead of `[SAD-MASKED]`.
List WALLET_SAD_RULES to keep 0.16.0's behaviour.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
README: a "Keep rules" subsection under "Value rules" -- the protocol,
where rules are asked and in what order, the floor and the guard, why a
credential or card key above a kept value does not stop it, the JSON-text
and text-floor limits, is_kept and mask_outside_kept, and the cost. The
Ottu paragraph carries the #159487 decision and WALLET_SAD_RULES; the
pack and type tables, the redact_body and text-rule paragraphs say what a
keep rule does there; ECSCTX_REDACT_BODY_LOG_CAP's default 4096 cuts a
roughly 5 KB Apple Pay token. CLAUDE.md, ecsctx/CLAUDE.md and the
patterns docstring follow.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…te key or a repr comment hides no card data

The matcher and the guard read the parsed object, but a keep ships the
text as written. json.loads keeps the last of two duplicate keys and
ast.literal_eval drops a comment, so a PAN or a CVV in the shadowed key
or the comment shipped past the guard, with Ottu's own WALLET_RULES
(review C1).

A keep decision on text now rests on a parse that refuses a key written
twice (json_as_written): JSON text that names one is not kept, in the key
walk (text_ruling; the walk of its parse keeps nothing either), in text
and in bodies (kept_spans), and by is_kept. A JSON-text leaf naming a key
twice inside a match is card data to the guard. A Python repr is kept
only where its parse renders back to it exactly. Label rules read text as
in 0.16.0.

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

_mask_object left any keep match as written and did not look into it,
so where the guard refused one -- or it was written otherwise than it
parses -- a label rule's match inside it shipped unlabelled, in text and
in redact_body, while the key walk and kept_spans walked it (review I1).
It now reads such a match member by member, as the brief's guard says:
walked as if nothing matched.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The guard read keys and whole leaves, so a CVV named by a pair's
identifier -- {"name": "cvv", "value": "123"} -- rode through a keep
match, even KeepRule(lambda v: True), where the key walk masks it by
the pair (review I2). A mapping with a `value` key and an identifier
(filters._PAIR_IDENTIFIERS) that the key rules read as a card, a CVV or
other SAD is now card data to the guard. The README says what it reads:
keys, pairs and whole leaves; a CVV written inside a longer string is
the matcher's to keep out.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…af is text

The matchers checked each object's keys, not its leaves' types, so a
container or a number in a slot -- a `{name, value}` pair labelled cvv in
`signature`, `paymentMethod.displayName` or `signedMessage` -- rode
through a matched wallet (review I2). Every leaf a wallet writes is a
string: Apple Pay's `signature`, `paymentMethod` fields and
`transactionIdentifier`, Google Pay's `signature`, `signedMessage` and
`signedKey`, and `signatures` a list of strings. Anything else is no
wallet's, and is walked as before.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…umber's shape

A string leaf was card data to the guard only when pan_shaped read it
and its digits passed int_is_pan, so a card number with a 1, 7, 8 or 9
prefix, or one failing Luhn, shipped inside a kept value (review M1).
The guard now reads every pan_shaped leaf as a card number, as the brief
has it, but for thirteen bare digits from a 1: epoch milliseconds, Google
Pay's keyExpiration, without which no Google Pay token is ever kept.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Reading text as written cost a second, strict parse on every KEEP: a
wallet-bearing event masked in 683 µs where 0.16.0's label rules took
555. Where keep rules are asked, text_ruling and kept_spans now parse
strictly first and read the text as json.loads does only where a key is
written twice: 651 µs.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…65, not 2033

Google Pay's keyExpiration starts with a 2 from 18 May 2033, and the
exemption read only thirteen digits from a 1: every Google Pay token would
then have been refused and masked whole. It now reads a 1 or a 2; no card
number is thirteen digits from either. A 13-digit run from a 3 still
blocks keeping.

From the re-review of the keep rules (N1).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@jab3z
jab3z requested a review from suliman-ottu as a code owner October 1, 2026 14:20
@jab3z

jab3z commented Oct 1, 2026

Copy link
Copy Markdown
Contributor Author

@claude review

@claude

claude Bot commented Oct 1, 2026 •

Copy link
Copy Markdown

Claude finished @jab3z's task in 25s —— View job


Review of #71: keep rules for wallet tokens (0.17.0)

I read the ecsctx/masking/value_rules.py diff in full. I did not read the diffs for patterns.py, filters.py, config.py, contrib/net, or contrib/ottu/masking.py line by line. I could not run the test suite: pytest was blocked by the sandbox permissions. The 5757-pass figure is from the PR description and I haven't checked it.

What I checked

  • Design. The keep decision is separate from the guard (holds_card_data) and from the floor, so KeepRule(lambda v: True) still can't ship card data. A keep matcher that raises counts as no match, which is correct on the redact_url and redact_body paths.
  • Duplicate keys. json_as_written rejects duplicate keys, and text_ruling degrades KEEP to None when the text names a key twice. This closes the case where the shipped text carries a key that json.loads dropped.
  • Epoch-milliseconds exemption. The regex [12][0-9]{12} with fullmatch on the stripped string is tight. Dropping the exemption to bare 13-digit values from a 1 or a 2 is justified by the comment.
  • Fail-closed behaviour. Depth over 64, non-JSON objects and unknown types all count as card data, so the guard fails closed.

Findings

  1. Rule-split cache churn on the text path (value_rules.py, _split and applicable). applicable() returns a fresh tuple whenever a hint filter drops at least one rule. _split() then stores it under id(rules), and a new tuple per call means a new entry per call. After 64 entries the whole cache is cleared, which also evicts the long-lived configured tuple. The tuple is also kept alive by its entry, so ids can't be reused wrongly, so this is a performance problem and not a correctness one. It only happens with mixed hinted rules, e.g. WALLET_RULES alongside other rules. It would show up as the "line with a wallet" cost in your bench. Cheap fixes:

    • cache applicable per (id(rules), matched-hint-mask); or
    • have text_ruling and label_within split once and filter the already-split keep and label lists, rather than re-splitting the filtered tuple.
  2. hints validation. _hints_are_text accepts set and frozenset, which have no order. applicable only needs membership, so this is fine. The docs say "collection of strings", which matches.

  3. Parked items. I agree that none of M2–M8 block the release. Two I would move up the follow-up list:

    • M4 (placeholder forgery through an entity-encoded leaf). The result is that restore copies a wallet. That is an integrity weakness in the very mechanism that sets kept spans aside. Add a per-call nonce to the placeholder, or reject input that already contains [KEPT-.
    • N3 (free text in a wallet's text field ships). This is a real PII path by design. Say so in the README's keep-rules section, not only in the follow-up ticket, because a service owner reading the docs will assume the guard covers it.
  4. Breaking change on upgrade. Connect and Ottu PG flip from labels to shipped tokens automatically when they list [*WALLET_RULES]. That is the intended decision (#159487), and the PR says so. Make sure the 0.17.0 changelog or release note names it explicitly, since it changes what leaves the process.

Verdict

No blocking correctness problems in what I read. Finding 1 is a small performance fix worth doing before Connect adopts this. I'd confirm CI is green on 3.10 and 3.12 before merging, since I couldn't run it here. I can't approve PRs.
· branch feat/keep-rules

@jab3z

jab3z commented Oct 1, 2026

Copy link
Copy Markdown
Contributor Author

@claude review

@claude

claude Bot commented Oct 1, 2026 •

Copy link
Copy Markdown

Claude finished @jab3z's task in 24s —— View job


Review of #71: keep rules for wallet tokens (0.17.0)

I read the full ecsctx/masking/value_rules.py diff this run. I did not read the diffs for patterns.py, filters.py, config.py, contrib/net, contrib/ottu/masking.py or the tests line by line. I did not run the test suite, so I haven't checked the 5757-pass figure or the bench numbers. This repeats my earlier review where it still holds and adds what I confirmed on a second read.

Checked in value_rules.py

  • Guard and floor are separate from the matcher. ruling() and label_within() both call holds_card_data before they return KEEP. KeepRule(lambda v: True) therefore can't ship a card key, a card-labelled {name, value} pair, or a whole-leaf PAN.
  • Fail-closed paths. Depth above 64, non-JSON objects and unknown types all count as card data. JSON text that names a key twice counts as card data too.
  • Duplicate keys. text_ruling degrades KEEP to None when _json_as_read reports the text wasn't parsed as written. What ships is then never narrower than what the guard read.
  • Keep matchers that raise. They count as no match, so redact_url and redact_body stay non-raising. Label rules keep their 0.16.0 behaviour.
  • Epoch-milliseconds exemption. [12][0-9]{12} with fullmatch on the stripped string is narrow, and the comment explains it.

Findings

  1. Split-cache churn on the text path (confirmed in the code).

    • applicable() returns a new tuple whenever a hint filter drops a rule.
    • text_ruling, text_label and label_within then pass that tuple to _split(), which caches it under id(rules).
    • A fresh tuple per call means a fresh entry per call. At 64 entries _splits.clear() runs, which also evicts the long-lived configured tuple.
    • The cost is performance only. The entry holds the tuple, so an id can't be reused wrongly.
    • It happens only with a mix of hinted rules where some don't match the text, such as WALLET_RULES next to other rules. That is probably part of the 651 µs against 555 µs on the wallet line.
    • Fixes:
      • Split once on the configured tuple, then filter its keep and label lists by hint.
      • Or don't cache tuples that applicable() built.
  2. A refused keep rule hides later label rules. _first_match picks the first matching rule, and the guard runs after it. If a keep rule matches and the guard refuses it, ruling() returns None and label_within() walks the children. A later label rule that also matches, for example WALLET_SAD_RULES after WALLET_RULES, is never asked. This fails closed, because the value is walked and the key and content rules still apply. It could still surprise someone who lists keep and label rules together and expects a fallback to the label. A docstring line would do, or the call could continue past a refused keep rule.

  3. Parked items. None block the release. I would move two up the follow-up list:

    • M4, placeholder forgery through an entity-encoded leaf. Restore then copies a wallet. A per-call nonce in the placeholder, or rejecting input that already holds [KEPT-, would close it.
    • N3, free text in a wallet's text field ships. State this in the README "Keep rules" section as well as in the ticket. A service owner reading the docs will otherwise assume the guard covers it.
  4. Upgrade behaviour. Connect and Ottu PG listing [*WALLET_RULES] changes what leaves the process on upgrade, from labels to shipped tokens. This matches the #159487 decision. Please name it explicitly in the 0.17.0 release note.

Verdict

I found no blocking correctness problem in the part I read. Finding 1 is worth fixing before Connect adopts this. Please confirm CI is green on 3.10 and 3.12 before merging. I can't approve PRs.
· branch feat/keep-rules

jab3z and others added 2 commits October 1, 2026 17:58
… each time

`applicable()` built a new tuple whenever a text held some rules' hints and
not others (Ottu's WALLET_RULES on a line with an Apple Pay token, say).
`_split()` caches on a tuple's identity, so each text added an entry, and the
64th emptied the cache, the configured rules' entry with it: 100 such lines
split the rules 305 times. Each selection of a rule tuple now has one tuple,
so its split is cached once. On a wallet line masking takes about 82 µs
where it took 88.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ard data is read in a kept value

A keep match the guard refuses is walked as if nothing had matched, so a
label rule listed after it for the same shape is never asked (a test now
pins it). And the guard reads card data only: PII or a credential inside a
kept value ships with it, which the README now says beside the guard.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@jab3z
jab3z merged commit b1d1e55 into main Oct 1, 2026
5 of 6 checks passed
@jab3z
jab3z deleted the feat/keep-rules branch October 1, 2026 15:09
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.

1 participant