Class: Pikuri::Thunderbird::ComposeGuard

Inherits:
Object
  • Object
show all
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_case identifiers 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

Constructor Details

#initialize(backend: nil) ⇒ ComposeGuard

Parameters:

  • backend (Gloda::Contacts, nil) (defaults to: nil)

    contact resolver for the novelty check; nil disables it (the verdict then carries a "couldn't check" note).



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.

Parameters:

  • to (String)

    recipient address(es), comma-separated.

  • cc (String, nil) (defaults to: nil)

    Cc address(es), comma-separated.

  • bcc (String, nil) (defaults to: nil)

    Bcc address(es), comma-separated.

  • subject (String, nil) (defaults to: nil)

    subject line.

  • body (String)

    plain-text body.

Returns:



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_fail_message(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