• v0.46.1 8cc358a757

    v0.46.1 Stable

    joeyr released this 2026-08-25 16:34:05 -05:00 | 70 commits to main since this release

    The alias index no longer costs a user-visible pause. It was 8.1 seconds on
    the first get_message_security after a restart, paid by whoever asked first.
    Now it is ~1 second of work, and it happens before anyone asks.

    Fixed

    • The index asks the server for the relayed mail instead of reading the
      archive.
      It swept all 4,602 messages because the alias is only visible in
      headers — but the relay is visible to IMAP SEARCH, and
      origin.resolve returns relayed only when the From domain is a trusted
      relay. So SEARCH FROM folded over TRUSTED_RELAYS matches exactly the
      messages the sweep could learn from, and excludes only what it would have
      discarded.

      Verified rather than assumed — both built on one connection, same moment:

      messages read time index
      full sweep 4,602 7.5s 82 aliases, 102 pairs
      SEARCH FROM 307 1.0s 82 aliases, 102 pairs

      Byte-identical. resolve still runs on every hit and is not redundant with
      the search: SEARCH matched the relay's name, resolve is what verifies its
      signature, and a message failing that must not enter the index.

      An intermediate version walked the archive newest-first and stopped after two
      batches taught nothing — aliased mail is recent, all 258 relayed messages sit
      inside the newest 1,000. It worked, and it is gone: it still read 2,000
      messages for a 7.3s build, and it needed a heuristic about how far back to
      walk that the search does not.

    • The build runs at startup, in the background. warm_alias_index() is
      called from __main__ after preflight, against the client that lives as long
      as the process — not from __init__, where preflight and the tests would each
      trigger their own. The build takes a lock, so a request arriving mid-build
      waits for the one already running rather than starting a second.

      A zero MAIL_ALIAS_INDEX_TTL still switches the whole thing off, and now
      starts no thread either.

    Downloads
  • v0.46.0 b99909d347

    v0.46.0 Stable

    joeyr released this 2026-08-25 15:57:11 -05:00 | 71 commits to main since this release

    Mail arriving at a single-purpose alias is now legible as such. An alias has
    an expected sender, so an origin it has never seen is a signal on its own —
    independent of authentication, and independent of any brand claim. That is the
    attacker shape the rest of the hygiene checks miss: authenticate a throwaway
    domain, impersonate nobody, and every existing check passes.

    Added

    • mail/aliases.py — which alias a message was delivered to, what that
      alias has received before, and two flags. Shared by both tenants.

      There is no database, and that is the interesting part. The design called
      for a persisted per-alias store plus a one-time backfill. Two measurements
      replaced it. An MCP server only sees a message when the model asks about one,
      so a store fed from the request path learns from a tiny biased sample and
      would flag nearly everything. And the mailbox already is the history:
      fetching four trimmed headers across the whole 4,603-message account takes
      6.9 seconds, so the index is built on demand and cached
      (MAIL_ALIAS_INDEX_TTL, default 1800s, 0 disables). No volume, no
      migration, no backfill step, and no cold start in which every sender is new.
      A restart costs one rebuild rather than a lost store.

      IMAP SEARCH cannot narrow the sweep — Gmail indexes neither
      X-SimpleLogin-Envelope-To nor deliveredto: for relayed mail. All three
      search forms return zero hits against an alias with 35 messages present.

    • alias_intent_mismatch — the alias name implies a brand the origin is
      not. Blocking, because it contradicts a claim. It reuses the _BRAND_DOMAINS
      map and _domain_is_the_brands from 0.44.2 rather than matching loosely, and
      it needs no history at all, so it works on the first message an alias ever
      receives.

    • alias_origin_unrecognized — first message this alias has had from this
      registered domain. Non-blocking, and that is a measurement, not a
      preference.
      14 of 83 aliases sampled — 16% — have legitimately received mail
      from more than one registered origin, every case in the sample benign: one
      brand on several domains (statefarm.com, statefarmservice.com,
      sfauthentication.com), an ESP, a franchise's marketing agency, a second
      signup on the same alias. The design asked for a refusal here; that would have
      refused all of them.

      Held-out check: trained on the older 155 relayed messages, replayed the newer
      104. Two flagged — 1.9% — mlbemail.com on an mlb.* alias that knew
      mlb.com, and a franchise agency on a mcdonalds.* alias. Both benign, both
      correctly ranked as a note rather than an alarm.

      The honest consequence: an attacker who authenticates a throwaway domain
      and impersonates nobody is surfaced with evidence, not blocked. Blocking that
      shape costs a refusal on one alias in six, and a flag that cries wolf at that
      rate is one the reader learns to skip.

    • alias_context in get_message_security — which alias, which origin,
      what that alias has seen before, and why it tripped. Evidence with no
      recommendation attached, because the decision about a leaked alias is made
      outside this server. Absent entirely when no alias is identifiable, so an
      ordinary mailbox does not acquire a signal that means nothing there.

    • hygiene.inspect(extra_flags=...) — observations computed outside the
      module, merged into the result. The alias checks need an IMAP sweep and
      hygiene is network-free by construction, so the IO stays in the client and
      the finished flags arrive here. That property is what makes everything else in
      the module fixture-testable.

    Fixed

    • The account holder's alias username was in a docstring, shipped in 0.45.0.
      test_no_pii scans tracked files, and mail/origin.py was untracked when
      that release was gated — so the gate could not see it until the commit landed,
      and caught it on the next run. Worth recording as the gate's second real
      catch, and as the reason a new file deserves one gate run after staging.

    Changed

    • Alias identification prefers the relay's X-SimpleLogin-Envelope-To, then
      Delivered-To/X-Original-To, then a recipient at a known alias domain. That
      order is measured: the envelope header identifies 278 of 281 relayed messages,
      To/Cc catches 3 more, and the alias is usually not in To at all. Reading
      To[0] would have named the group on a group message and asked every
      subsequent question about the wrong address.
    Downloads
  • v0.45.0 a2436cfb11

    v0.45.0 Stable

    joeyr released this 2026-08-25 15:41:16 -05:00 | 72 commits to main since this release

    Mail that arrives through a forwarder is now judged by who actually sent it.
    56% of the sampled inbox is relayed, and every check that reads the sending
    domain was reading the forwarder. Also fixes a DKIM property name that had the
    RFC 8058 coverage check inert on the whole Gmail tenant.

    Added

    • mail/origin.py — recovers the original sender from behind a forwarding
      hop, shared by both tenants. Origin carries the domain to judge, a
      provenance (direct / relayed / relayed_unresolved), the relay, and
      whether the answer is a claim.

      The design this replaces read the ARC chain, and it could not have worked.
      RFC 8617 puts the first relay's record of the original sender's
      authentication in the i=1 ARC-Authentication-Results. On all 228 relayed
      messages sampled, that header reads in full: i=1; mail.protonmail.ch — an
      authserv-id and nothing else. The chain validates and carries no verdicts
      inside it. A resolver written against it returns "the original failed
      authentication" for every forwarded message, and since that is a blocking
      signal it would have refused 28 of 28 relayed unsubscribe offers where 21 are
      allowed today. Measured before the module was written.

      What is available is the rewritten From — shaped "Example Store - hello at store.example" <hello_at_store_example_TOKEN@RELAY> — carried in the display
      half on 228 of 228 and the local part on 117. Reading it is the same
      trust decision as trusting the relay's ARC seal: same domain, same key, same
      notary. It is not read without the relay's own verified DKIM signature over
      From, present on 232 of 232 signatures sampled.

      The downgrade is coverage, not trust, and it bounds the feature. The chain
      would have carried a verdict; the rewritten From carries a claim. So
      Origin.claimed is always true on relayed mail, the output says so in a
      sentence, and the unsubscribe gate was deliberately not retiered on
      "authenticated origin" — that question has no input on this account.

      TRUSTED_RELAYS is simplelogin.co and duck.com, both from observation.
      A third was expected and appears on zero of 400 messages, so it is not there.

    • origin in get_message_security on both tenants, always present so the
      shape does not change between direct and relayed mail. The chained-relay case
      is live and handled: privacy.example → simplelogin.co → duck.com reports
      the origin, not the previous hop, because a candidate that is itself a relay
      is a hop rather than a sender.

    Fixed

    • The RFC 8058 signature-coverage check was inert on the entire Gmail
      tenant.
      MethodResult.domain looked for header.d; Gmail reports the DKIM
      domain as header.i and never as header.d — 492 of 492 verdicts
      sampled. So the property returned "" for every DKIM verdict there, which
      meant unsubscribe._verified_signatures found nothing, headers_are_signed
      returned None rather than a verdict, and all 48 one-click offers were
      reported as signature_coverage_unknown instead of being checked. The
      security report also showed a DKIM pass with no signing domain.

      Fixing it turns one message from allowed into correctly refused: a sender
      whose signature covers List-Unsubscribe but not List-Unsubscribe-Post,
      which RFC 8058 §3.2 requires for one-click.

    • brand_mismatch judges the recovered origin. Seven relayed Facebook
      messages were refused for impersonating Facebook — they came from
      facebookmail.com through SimpleLogin, and the check saw simplelogin.co.
      Relayed unsubscribe offers go from 21 allowed / 7 refused to 28 / 0 on the
      sample, with no direct-mail decision changed.

      _reply_to_mismatch is deliberately left alone: under relaying both From
      and Reply-To are the relay, so it compares a domain against itself and is
      silent rather than wrong — and it is non-blocking, so a misfire costs a
      warning.

    Changed

    • authresults.summarise names the relay and refuses to lend it the sender's
      credibility.
      Untreated it reported "the message really was sent by
      'simplelogin.co'"
      — true and useless — beside a DMARC policy note describing
      the relay's published policy as though it were the sender's. That is
      false reassurance rather than a false alarm: a brand mismatch is visible when
      it misfires, a strong policy attributed to the wrong domain is not.
    Downloads
  • v0.44.2 64b21da1ac

    v0.44.2 Stable

    joeyr released this 2026-08-25 15:12:31 -05:00 | 73 commits to main since this release

    Two shipped defects in the same brand check, pointing opposite ways — and an
    instructions block that warned about a flag with no parameter behind it.

    Fixed

    • brand_mismatch no longer reads a brand out of the middle of a word. The check
      asked brand in display_name against bare substrings, and ups is inside groups,
      signups, meetups and startups — so "Facebook Groups" <…@facebookmail.com>, a
      legitimate message straight from the brand's own domain with no relay in the path, was
      flagged as a UPS impersonation. That is not cosmetic: brand_mismatch is not in
      unsubscribe.NON_BLOCKING_FLAGS, so it was a hard refusal to unsubscribe from real
      mail. irs inside firstclass is the same shape. The display name is now split into
      words, and matched against those and against runs of adjacent words glued together.

    • The same check now fires for brands written as separate words. The tokens are
      spelled closed up — bankofamerica, cashapp, wellsfargo — and no display name is
      written that way, so matching single words meant those entries could never match
      anything. They were dead from the day they were added, for exactly the brands where an
      impersonation costs the most.

    • A brand token inside a domain label is no longer accepted as the brand's domain.
      The domain was flattened (paypal-billing.com → paypalbillingcom) and tested for the
      token anywhere inside it, so paypal-billing.com and apple-id-verify.com — the exact
      shape a lookalike takes — passed silently. The flattening could not simply be
      dropped: it was also what let bankofamerica.com match. So _BRANDS became
      _BRAND_DOMAINS, and a domain qualifies two ways — it is one the brand is listed as
      sending from (exact or subdomain, the authresults.is_trusted rule), or the brand is a
      whole dot-label of it. The map is deliberately sparse, because the label rule already
      covers usps.com and corporate.target.com; facebookmail.com is the shape that
      needs an entry, and it is indistinguishable from a lookalike by string rules alone.

      Only brand-controlled domains belong in that map. Shared sending infrastructure —
      amazonses.com, an ESP's bounce domain — must never be listed: the map grants the
      brand's name to whatever it contains, so one shared domain would hand every tenant on
      that platform a free pass.

    • icloudmail's instructions block describes the delete split as it actually is.
      It still said deleting was "reversible unless permanent is set". delete_message has
      taken a single message_id since 0.30.0, when that argument was split out into
      expunge_message; the sentence was never updated. Both halves pointed the wrong way at
      once — a warning attached to an argument that does not exist, and no warning at all on
      expunge_message, which is registered whenever MAIL_EXPUNGE_ENABLED is set and
      went unmentioned. The text is now conditional on that gate, the way _absent() already
      conditions the rest of the capability prose.

      Worth naming as a class: a schema that drifts from its implementation surfaces as a
      type error at call time, but an instructions block that drifts from the schema surfaces
      as nothing at all — and it is injected into the model's context and acted on before any
      tool call is made. This one failed safe. The same drift running the other way is
      confident action on a false premise.

    Downloads
  • v0.44.1 371fbba262

    0.44.1 Stable

    joeyr released this 2026-08-24 23:36:57 -05:00 | 74 commits to main since this release

    The account holder's name came out of history, and the guard that should have caught it
    went in.
    No runtime change; the whole release is the gate and the record.

    Added

    • test_no_pii.py refuses a display name beside an address unless it is on a list of
      placeholders. A name is the one kind of PII with no shape to match — "Lovelace, Ada"
      and a real one are the same string to a regex — so the rule is inverted the way the
      reserved-domain rule for addresses already is: enumerate what may appear, not what may
      not. Hashing the real name into the guard was the obvious alternative and is worse than
      it looks; a salted digest of a common given name and surname falls to a small dictionary,
      which publishes in a public repo roughly what it was meant to protect.

      It reads bytes literals and reassembles f-strings, because both are blind spots exactly
      where the fixtures live: a raw From: header is bytes, and f"Pay{ZWSP}Pal Security" is
      two AST nodes, so a name split across an interpolation would pass a naive walk.

    • test_no_pii.py refuses a token-shaped literal that is not bound to a module-level
      name
      , in the one file the other rules cannot scan — this one. The real Canvas token
      hid for two releases inside the docstring of the helper written to recognise its shape,
      and the convention that replaced it had already drifted twice before anything checked it.

    Changed

    • CLAUDE.md records the rewrite and what it actually took. Taking a value out of the
      tree is not taking it out of the repo: the name stayed published in 20 blobs for a day
      after the 2026-08-23 sweep replaced it. Removing it took git filter-repo, 99 commits
      recreated and 57 of 74 tags repointed — all 66 releases survived, because Forgejo keys
      them to the tag name. And the force-push was not the end: Forgejo served the old commits
      by hash until git gc --prune=now ran inside the container. Verify a rewrite by asking
      the API for a hash that should be gone.

    • The emoji-calendar arithmetic is current again — 140 commits and 73 of the 74 tags —
      and that decision is now priced rather than assumed, since a rewrite of comparable size
      has since been paid for something else.

    Downloads
  • v0.44.0 e020278e3e

    joeyr released this 2026-08-24 21:16:07 -05:00 | 75 commits to main since this release

    Canvas C5, half of it: get_discussion. Scheduled by use rather than by
    plan — a real session asked what a graded discussion required, and every tool on
    the tenant could say it was due on Friday and worth 2.5 points while none could
    say what it asked for. Two defects came out of building it, both shipped in
    0.43.0 and both silent.

    Added

    • get_discussion — a discussion's prompt, and for a graded one its due
      date and point value from the assignment object Canvas returns inline. It
      reads the PROMPT only: list_discussion_entries — every student's replies —
      stays declined, and this server still cannot post.

      It also hands over assignment_id, which is a different id from the
      topic's. That distinction is the reason the tool exists at all.

    • Every item that names a reader now hands over the id that reader takes.
      Module items carry assignment_id, topic_id or page_url beside
      opens_with; planner items carry assignment_id or topic_id.

    Fixed

    • list_modules named a tool and handed over an id that tool rejects. A
      module item has two ids — id is its place in the module, content_id is the
      thing it points at — and 0.43.0 emitted only the first while labelling
      Assignment items opens_with: get_assignment. Measured on two live courses:
      that id returns 404 from the assignments endpoint and content_id
      returns the assignment. Pages were unaffected, because page_url was added
      when the same trap was spotted there and nothing checked the other kinds.

      An item whose handle is missing now names no tool at all, rather than making a
      promise that 404s.

    • A module lock was reported as emptiness, in three different ways. Measured
      across all three courses on 2026-08-24, one condition and three behaviours:

      Tool What Canvas returns when locked What 0.43.0 said
      get_page body is null, reason in lock_explanation "This page exists but has no text in it… do not retry"
      get_assignment description is null, same an assignment with no instructions
      get_discussion message IS the lock notice, byte for byte (new tool — caught on its first live run)

      Twelve locked assignments and two locked pages on the account the day this was
      measured, so none of it was hypothetical. All three now carry locked and a
      stripped lock_explanation, and the notes distinguish "Canvas is withholding
      this" from "the instructor left it blank". A withheld discussion prompt comes
      back empty with prompt_withheld: true rather than as 155 characters of
      plausible prose in the field the instructions live in.

    Verified

    • Live pass 2026-08-24 through the tool layer, 35 checks, all green. The
      planner labels a discussion get_discussion and an assignment
      get_assignment; every module item's handle opens its own reader; the graded
      topic reports prompt_withheld with the module holding it named; and the call
      that started this — a topic id sent to get_assignment — still fails, which
      is correct, while the assignment_id the topic hands over succeeds.

    • probes/canvas_probe_c5.py holds the measurements the design rests on —
      gitignored like every other probe, so what it established is written down
      here, in CLAUDE.md and in the fixtures rather than only in the script.

    Downloads
  • v0.43.0 0ad4d54339

    joeyr released this 2026-08-24 18:48:01 -05:00 | 79 commits to main since this release

    Canvas C4: list_modules and get_page. The tenant is complete at nine tools.

    Added

    • list_modules — a course's outline in order, items inline, with each module's
      per-student state. That state is the only thing on this tenant that answers "where am
      I in this course"
      — grades say how it went, the planner says what is due, neither says
      what has been worked through. A null state means the course does not track progress,
      which is not "nothing started".
    • get_page — lecture notes, weekly overviews, exam guidance. The largest body of
      prose this tenant can reach: 78 of one course's 146 module items, median 4,620 characters.
    • Items say which tool can open them, or say nothing. opens_with is absent on Files,
      external links and publisher tools — a listing that presents a File identically to a Page
      invites a model to promise a reading it cannot deliver.

    Changed

    • list_module_items is dropped. include[]=items returns items inline for every
      module in one call, so a second tool would exist only to re-fetch what the first already
      returns — the argument that cancelled list_submissions.
    • get_page reverses the decision that declined pages. The original reasoning was
      sound in the abstract and wrong about this account: a page is smaller than the syllabus
      this tenant already returns. What the abstract argument could not see is that pages are
      over half of all module content, so declining them turned list_modules into a list of
      things nothing could open.

    Cost

    list_modules is the most expensive call in this project — 5.32 with items for one
    course, against 0.07–1.14 for everything else here. It takes a course_id rather than
    sweeping.

    Testing

    All eight new guards proved by breaking them — including "a File is openable, it has a
    url" and "the numeric id is the page handle", each of which would have left the two tools
    unusable together while looking correct apart.

    1383 tests green.

    Downloads
  • v0.42.0 99f78b1230

    joeyr released this 2026-08-24 18:13:36 -05:00 | 81 commits to main since this release

    Canvas C3: list_upcoming_assignments. One request answers "what is due" across
    every course, sorted by due date, with submission state attached. Seven tools.

    The filter is the phase, and the obvious implementation is wrong

    Filter on submissions, not on plannable_type. Canvas states whether an item counts
    toward a grade in the item itself — submissions is a dict of flags on anything gradeable
    and the literal false on anything else.

    Probe 10 returned 23 items: assignment ×14, discussion_topic ×5, announcement ×4 —
    and one of those five discussion topics is gradeable and carries a due date. A graded
    discussion is real coursework, so an allowlist of ("assignment", "quiz") reports fifteen
    items as fourteen, silently.

    The trap the plan predicted was quizzes, and a different instance of the same class fired
    instead.
    No quiz item appeared in the window at all — so a type allowlist would have
    looked perfectly correct that day and broken the first week a quiz was set.

    Added

    • start_date / end_date go to Canvas, proved server-side with a window containing
      nothing that returned zero items rather than everything.
    • Sorted by due_at with undated items last. Canvas orders by plannable_date, which
      is a sort key rather than a deadline.
    • non_coursework_ignored is reported — "nothing is due" and "the planner held four
      announcements and no work" are different facts.

    Changed

    • The planner's submission shape is reconciled with C2's by OMISSION rather than nulls.
      It carries no score; an absent key says "this tool does not answer that", while a null one
      reads as "not graded yet".

    Corrected

    • The 0.29 planner cost recorded earlier was for ten items. Twenty-three cost 1.138, so
      the honest margin over the fan-out is about 2x, not 5x.

    All six new guards proved by breaking them, including filtering by plannable_type the
    obvious way.

    1374 tests green.

    Downloads
  • v0.41.0 13e7ec769d

    joeyr released this 2026-08-24 17:44:56 -05:00 | 82 commits to main since this release

    Canvas C2: the five reads that make the tenant useful. list_assignments,
    get_assignment, get_grades, list_announcements and get_syllabus — one tool to six.

    Every one was designed against a measurement rather than a guess.
    probes/canvas_probe_c2.py ran before any of it was written and moved four decisions.

    The sharpest trap in the phase

    current and final are not two spellings of one number. current scores only what has
    been graded; final treats every ungraded assignment as a zero, so mid-term a student
    doing perfectly well reads as failing. Both are returned because Canvas returns both — but
    the projection is named projected_score_if_nothing_else_is_submitted, and there is
    deliberately no neutral final_score key
    for a model to reach for by mistake.

    Added

    • get_grades is ONE request for every course, not one per course.
    • turned_in reads workflow_state, never the row's existence. Canvas creates a
      submission row for every assignment whether or not anything was submitted, so the obvious
      implementation reports the entire course as turned in. Canvas's own late, missing and
      excused verdicts are carried rather than recomputed — an extension is invisible from
      here and Canvas has already applied it.
    • get_assignment earns its place on RESPONSE SIZE, not on data. Both shapes are 71
      fields; the single fetch adds nothing. The list omits description, the getter carries it.
    • list_announcements spans every course in one request, at half the price of the
      route the documentation leads with.
    • get_syllabus — the only tool that reaches late policies and grade weighting.
      Measured at 13,767 characters of HTML, so it is rendered to text and capped.
    • Pagination follows rel="next" with a cap that reports itself. A cap is a stop, not
      a page size: reaching it means the query is wrong.

    Changed

    • _strip_html moved to mcp_gateway/htmltext.py, the rule timeparse.py was extracted
      under — Canvas is its second caller. Mail behaviour is byte-identical.

    Testing

    All twelve new guards proved by breaking them, each with a plausible implementation
    rather than a nonsense one — "take the first enrolment", "work out late ourselves from
    the dates", "keep sending the params while walking pages".

    1367 tests green.

    Downloads
  • v0.40.1 275f052fd0

    joeyr released this 2026-08-24 16:04:01 -05:00 | 86 commits to main since this release

    A healthy keepalive and a dead one logged the same thing: nothing.

    Found by reading the deployed tenant's log, not by the suite — which asserted what the
    tick DOES and never whether anyone could see it.

    Fixed

    • keepalive ok is logged at info rather than debug. The deployment runs at
      LOG_LEVEL=info, where a tick that succeeded WITHOUT a rotation produced no output at
      all — so silence meant either "healthy, Canvas just did not re-issue" or "the thread
      died an hour ago", with no way to tell which. The one mechanism keeping this tenant's
      credential alive was the one thing the log could not confirm was running
      , which is the
      "up but doing nothing" case preflight.py exists to prevent, arriving an hour at a time
      instead of at boot.

      session rotated is not a substitute, and is why this looked covered: it fires only
      when Canvas chooses to re-issue, so reading it as a heartbeat is reading Canvas's
      cadence and calling it ours.

    • Two new tests. The first asserts at the level the deployment runs at rather than
      checking which method was called — what matters is what survives LOG_LEVEL=info.
      Proved by putting it back to debug and watching it fail. The second pins that a line
      running hourly and forever never carries a cookie value.

    1351 tests green.

    Downloads