Class: Pikuri::Thunderbird::ComposeGuard
- Inherits:
-
Object
- Object
- Pikuri::Thunderbird::ComposeGuard
- Defined in:
- lib/pikuri/thunderbird/compose_guard.rb
Overview
The mechanical outbound guard for MailCompose — deterministic, never an AI verdict. The compose window is a strong human barrier on the visible (the recipient, gross body content) but soft on the invisible (control bytes, look-alike domains, subtle body-exfil), so the machine covers what the eye can't. It runs before MailtoUri encodes anything.
ComposeGuard.new(backend: contacts).check(to: 'a@acme.com', body: 'Hi')
# => #<Verdict ok=true, to=["a@acme.com"], notes=[...]>
Two tiers:
- Hard reject — a suspicious body fails closed (nothing is handed
off; the human retypes in Thunderbird), because an agent-authored
outbound body has no honest reason to carry control/bidi/zero-width/
homoglyph characters. A suspicious subject or address is dropped
(omitted from the draft) for the human to retype — addresses additionally
must be ASCII-only and non-punycode (an
xn--domain is a pre-encoded homoglyph that would otherwise pass Sanitizer's mixed-script check as pure ASCII). - Soft warn — the correspondence-graph novelty check: a recipient domain the user has never exchanged mail with (per Gloda::Contacts#domains_seen) stays in the draft but earns a loud note urging out-of-band verification. A hard reject here would fire on every genuinely-new correspondent. A body carrying an encoded-looking blob (a long base64, hex or base32 run — see #body_blob_note) likewise stays but earns a located note.
Why per-field address omission
If any address in a field (To/Cc/Bcc) fails the hard checks, the whole field is dropped, not just the bad address — "the recipient is the attack," so forcing a full retype of a tainted field beats silently keeping the addresses that happened to pass beside it.
Immutable.
Defined Under Namespace
Classes: Verdict
Constant Summary collapse
- LOGGER =
Pikuri.logger_for('Thunderbird::ComposeGuard')
- BLOB_TOKEN_MIN =
Shortest token the blob spotter sums toward a body warning. Below 16 the three-class test gets flaky and honest engineering tokens (short SHAs, build IDs) creep in — and since the sum is body-wide, that means alarm fatigue. See #body_blob_note.
16- BLOB_JOIN_MIN =
Joined blob-token length at/above which the body earns the encoded-blob note. A 32-byte key is 44 base64 chars, comfortably over. See #body_blob_note.
32- BLOB_ALPHABET =
The standard base64 alphabet. Excludes base64url's +-+/+_+ on purpose: those collide with hyphenated labels and
snake_caseidentifiers common in honest mail, whereas +++/=never appear in prose tokens. Cost: base64url/JWT-shaped payloads slip by — accepted, per #body_blob_note. %r{\A[A-Za-z0-9+/=]+\z}- HEX_ALPHABET =
Single-case hex — what a key already wears on disk. Mixed case is absent on purpose: that is three-class, so BLOB_ALPHABET has it. No interior
-, so a dashed UUID matches nothing here. /\A(?:[a-f0-9]+|[A-F0-9]+)\z/- BASE32_ALPHABET =
RFC-4648 base32; single-case like HEX_ALPHABET, so it shares the longer thresholds below.
/\A[A-Z2-7]+=*\z/- SINGLE_CLASS_TOKEN_MIN =
BLOB_TOKEN_MIN's twin for the single-alphabet encodings, doubled: a one-alphabet run is likelier to be an honest identifier than a base64 run is, so it must be twice as long to count.
32- SINGLE_CLASS_JOIN_MIN =
BLOB_JOIN_MIN's twin, in the only gap available: above a full git SHA-1 (40), below a 32-byte key in base32 (52) or hex (64). Accepts one irreducible false positive — a lone SHA-256 checksum warns, since a 256-bit hash and a 256-bit key are the same string (+D_blob_spotter_alphabets+).
48
Instance Method Summary collapse
-
#check(to:, body:, cc: nil, bcc: nil, subject: nil) ⇒ Verdict
Check one compose request and return a Verdict.
- #initialize(backend: nil) ⇒ ComposeGuard constructor
Constructor Details
#initialize(backend: nil) ⇒ ComposeGuard
96 97 98 |
# File 'lib/pikuri/thunderbird/compose_guard.rb', line 96 def initialize(backend: nil) @backend = backend end |
Instance Method Details
#check(to:, body:, cc: nil, bcc: nil, subject: nil) ⇒ Verdict
Check one compose request and return a Verdict.
108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 |
# File 'lib/pikuri/thunderbird/compose_guard.rb', line 108 def check(to:, body:, cc: nil, bcc: nil, subject: nil) body_res = Pikuri::Sanitizer.sanitize(body.to_s) unless body_res.warnings.empty? return Verdict.new(ok: false, error: (body_res), to: [], cc: [], bcc: [], subject: nil, body: nil, notes: []) end notes = [] kept_to = check_addresses('To', to, notes) kept_cc = check_addresses('Cc', cc, notes) kept_bcc = check_addresses('Bcc', bcc, notes) novelty_note(kept_to + kept_cc + kept_bcc, notes) body_blob_note(body.to_s, notes) Verdict.new(ok: true, error: nil, to: kept_to, cc: kept_cc, bcc: kept_bcc, subject: check_subject(subject, notes), body: body.to_s.empty? ? nil : body, notes: notes) end |