popoto.privacy.never_record¶
popoto.privacy.never_record
¶
Never-record firewall — deterministic pre-storage privacy gate (#561).
What this guarantees¶
Content matching the enumerated guaranteed class below never reaches Redis:
it is dropped in :meth:popoto.models.base.Model.save before the write
filter, before pre_save(), and therefore before serialization, HSET,
index writes, BM25 tokenization, embedding calls, and co-occurrence edges.
Nothing about the dropped text is persisted — only a content-free tombstone
carrying a random id and a reason code.
The guaranteed class:
off_the_record
An explicit human marker. Voids the entire turn, not a guessed span.
private_key_block
PEM / OpenSSH / PuTTY private key headers.
credential_prefix
Vendor-prefixed API tokens (Anthropic, OpenAI, GitHub, AWS, Google,
Slack, GitLab, npm, HuggingFace, DigitalOcean, SendGrid, Stripe).
jwt
Three-segment base64url JSON Web Tokens.
credential_assignment
password=/api_key:/token= and friends followed by a value.
url_userinfo
scheme://user:password@host.
payment_card
13-19 digit runs that pass the Luhn checksum.
government_id
US SSN-shaped strings with a valid-area guard.
high_entropy
The backstop for unknown-prefix tokens: long, credential-charset,
high-Shannon-entropy tokens.
What this does NOT guarantee¶
This is a deterministic pattern gate, not an oracle. Read the holes:
- Over-blocking is accepted and expected. Base64 blobs and long random identifiers in ordinary prose will be dropped. That is the price of the zero-false-negative goal on the class above.
- Canonical git SHAs and UUIDs are excluded from entropy scoring
(:data:
_STRUCTURAL_EXCLUSIONS). Developer memory content mentions commit SHAs constantly, and dropping every one of them would make the firewall worse than useless. The cost is a real, enumerated hole: a bare 40-hex-char secret with no vendor prefix is not caught by the entropy backstop. It is still caught if it appears aspassword=<sha>or similar. - Digit-free tokens, and tokens with no long separator-free run, are
excluded from entropy scoring (:func:
_entropy_candidate). Entropy per character does not separateExtractionProviderRegistry(3.87 bits) ortext-embedding-3-smallfrom a real secret, so scoring whole tokens voided 11.6% of this repo's own documentation paragraphs.
The cost is large and should not be read as a footnote. A secret
escapes the backstop if it contains no digit, or if a separator breaks it
so no unbroken run reaches NR_ENTROPY_MIN_TOKEN_LEN. The separator case
dominates, because base64's own alphabet includes - and _. Measured
over 200k random base64url tokens per length: ~49% escape at 20
characters, ~27% at 32, ~12% at 43 (compare the digit-free case alone at
~3% and ~0.4%). Treat high_entropy as opportunistic, not as a second
guarantee. Only high_entropy is affected; every named format has its
own detector, and that is where the guarantee lives.
- A novel credential format with no known prefix, low entropy, and no
assignment context can pass. The detector corpus is explicit and lives in
one module so it can be extended; issue #561's module M9 (seeded audit) is
the ongoing measurement of false negatives.
- Semantically sensitive content is out of scope. The gate matches shape,
not meaning. An LLM voter may be added later and may only ADD drops — the
guarantee above rests solely on this deterministic core.
Why patterns are module constants, not Defaults entries¶
The numeric thresholds are tuning dials and live in
:class:popoto.fields.constants.Defaults. The pattern corpus does not: it is
a security-relevant list, and exposing it as a mutable registry would let a
caller silently weaken the guarantee.
No Redis modules are used — tombstones are a plain HASH and a capped LIST, so the firewall works identically on Redis and Valkey.
NeverRecordVerdict
dataclass
¶
Result of a never-record scan.
Attributes:
| Name | Type | Description |
|---|---|---|
blocked |
bool
|
True if the content must never be stored. |
reason |
Optional[str]
|
Stable reason code from :data: |
detector |
Optional[str]
|
Name of the specific detector that fired, or None. |
Deliberately carries no matched text, no offset, and no length. This object's repr reaches log files, and any of those three would be a content side channel that defeats the whole module.
Source code in src/popoto/privacy/never_record.py
NeverRecordMixin
¶
Model mixin enforcing the never-record firewall on save().
Deliberately not a :class:~popoto.fields.write_filter.WriteFilterMixin
subclass. The write filter is a scalar salience score with one
compute_filter_score() slot per class, already claimed by salience
scoring in the documented pattern; a firewall is a boolean content
predicate with a tombstone side effect. Different shape, different slot.
Usage::
class Memory(NeverRecordMixin, Model):
content = StringField()
Memory(content="my key is sk-ant-api03-AAAA...").save() # -> False
Memory.never_record_counts() # {"credential_prefix": 1}
:class:~popoto.recipes.default_memory.DefaultMemory carries this mixin
already, so the batteries-included path and the Claude Code / Codex
harness are gated with no adopter code change.
Key fields are excluded from the scan. AutoKeyField/KeyField
values are generated UUID/hex shapes -- exactly what the entropy detector
targets -- so scanning them would let a model block on its own identity.
A key is never user content.
Source code in src/popoto/privacy/never_record.py
605 606 607 608 609 610 611 612 613 614 615 616 617 618 619 620 621 622 623 624 625 626 627 628 629 630 631 632 633 634 635 636 637 638 639 640 641 642 643 644 645 646 647 648 649 650 651 652 653 654 655 656 657 658 659 660 661 662 663 664 665 666 667 668 669 670 671 672 673 674 675 676 677 678 679 680 681 682 683 684 685 686 687 688 689 690 691 692 693 694 695 696 697 698 699 700 701 702 703 704 705 706 707 708 709 710 711 712 713 714 715 716 717 718 719 720 721 722 723 724 725 726 727 728 729 730 731 732 733 734 735 736 737 738 739 740 741 742 743 744 745 746 747 748 749 750 751 752 753 754 755 756 757 758 759 760 761 762 763 764 765 766 767 768 769 770 771 772 773 774 775 776 | |
never_record_counts(redis_client=None)
classmethod
¶
Return {reason_code: count} of drops for this model.
Content-free by construction -- this is the auditable half of the guarantee.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
redis_client
|
Any
|
Optional client override, for tests. |
None
|
Returns:
| Name | Type | Description |
|---|---|---|
dict |
dict[str, int]
|
Reason code to integer count. Empty if nothing dropped. |
Source code in src/popoto/privacy/never_record.py
never_record_log(limit=100, redis_client=None)
classmethod
¶
Return the most recent drop tombstones, newest first.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
limit
|
int
|
Maximum entries to return. |
100
|
redis_client
|
Any
|
Optional client override, for tests. |
None
|
Returns:
| Name | Type | Description |
|---|---|---|
list |
list[dict[str, Any]]
|
Dicts with |
list[dict[str, Any]]
|
entry contains any fragment of the dropped content. |
Source code in src/popoto/privacy/never_record.py
scan_never_record(text)
¶
Scan text for content that must never be recorded.
Pure and deterministic: no Redis, no model, no network, no clock. Safe to call from an extraction pipeline before an LLM sees the candidate.
Every regex detector marked whitespace-insensitive runs against two renderings — the raw text and a de-whitespaced rendering — so a credential split across whitespace or newlines is still caught. That is the adversarial case named in issue #561's acceptance criteria.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
text
|
Any
|
The candidate content. Non-str input returns a clean verdict; callers pass field values of arbitrary type. |
required |
Returns:
| Type | Description |
|---|---|
NeverRecordVerdict
|
NeverRecordVerdict. Falsy when the content may be stored. Carries a |
NeverRecordVerdict
|
reason code and detector name but never any fragment of the input. |
Source code in src/popoto/privacy/never_record.py
never_record_key(class_name, kind)
¶
Build a never-record Redis key.
The $NR: namespace is disjoint from $WF: (write filter) and
$TOMB: (lifecycle tombstones, which deliberately archive the payload).
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
class_name
|
str
|
Model class name. |
required |
kind
|
str
|
|
required |
Returns:
| Name | Type | Description |
|---|---|---|
str |
str
|
Redis key like |
Source code in src/popoto/privacy/never_record.py
write_tombstone(class_name, verdict, redis_client=None, *, model_class=None)
¶
Record that a drop happened, without recording what was dropped.
Writes two plain Redis/Valkey structures (no modules):
$NR:{class}:counts-- HASH,HINCRBY <reason> 1$NR:{class}:drops-- LIST, capped at :attr:Defaults.NR_TOMBSTONE_LOG_MAX
The entry id is :func:uuid.uuid4, deliberately not a hash or
fingerprint of the content. A content-derived id would be a confirmation
oracle: anyone holding a candidate secret could hash it and check the log
for a match. This is the precise point where the never-record tombstone
diverges from :class:popoto.recipes.memory_lifecycle.Tombstone, which
stores an ExistenceFilter fingerprint on purpose so writes can be matched
against it.
No field of the entry contains, derives from, or is length-correlated with the dropped text.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
class_name
|
str
|
Model class name, for the key namespace. |
required |
verdict
|
NeverRecordVerdict
|
The blocking verdict. Only its reason/detector are stored. |
required |
redis_client
|
Any
|
Optional client override, for tests. |
None
|
model_class
|
Any
|
The refusing model (#759 M4). On a non-Redis backend the entry goes to that backend's audit tables instead; omitted (or a Redis-bound model), it goes to Redis as before. |
None
|
Returns:
| Name | Type | Description |
|---|---|---|
str |
str
|
The tombstone entry id. |
Source code in src/popoto/privacy/never_record.py
513 514 515 516 517 518 519 520 521 522 523 524 525 526 527 528 529 530 531 532 533 534 535 536 537 538 539 540 541 542 543 544 545 546 547 548 549 550 551 552 553 554 555 556 557 558 559 560 561 562 563 564 565 566 567 568 569 570 571 572 573 574 575 576 577 578 579 580 581 582 583 584 585 586 587 588 589 590 591 592 593 594 595 596 597 | |