-
v0.46.1 Stable
released this
2026-08-25 16:34:05 -05:00 | 70 commits to main since this releaseThe alias index no longer costs a user-visible pause. It was 8.1 seconds on
the firstget_message_securityafter 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 toIMAP SEARCH, and
origin.resolvereturnsrelayedonly when the From domain is a trusted
relay. SoSEARCH FROMfolded overTRUSTED_RELAYSmatches 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 FROM307 1.0s 82 aliases, 102 pairs Byte-identical.
resolvestill runs on every hit and is not redundant with
the search: SEARCH matched the relay's name,resolveis 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_TTLstill switches the whole thing off, and now
starts no thread either.
Downloads
-
Source code (ZIP)
0 downloads
-
Source code (TAR.GZ)
0 downloads
-
-
v0.46.0 Stable
released this
2026-08-25 15:57:11 -05:00 | 71 commits to main since this releaseMail 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,0disables). 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 SEARCHcannot narrow the sweep — Gmail indexes neither
X-SimpleLogin-Envelope-Tonordeliveredto: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_brandsfrom 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.comon anmlb.*alias that knew
mlb.com, and a franchise agency on amcdonalds.*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_contextinget_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
hygieneis 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_piiscans tracked files, andmail/origin.pywas 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/Cccatches 3 more, and the alias is usually not inToat all. Reading
To[0]would have named the group on a group message and asked every
subsequent question about the wrong address.
Downloads
-
Source code (ZIP)
0 downloads
-
Source code (TAR.GZ)
0 downloads
-
-
v0.45.0 Stable
released this
2026-08-25 15:41:16 -05:00 | 72 commits to main since this releaseMail 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.Origincarries 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 thei=1ARC-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 rewrittenFromcarries a claim. So
Origin.claimedis 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_RELAYSissimplelogin.coandduck.com, both from observation.
A third was expected and appears on zero of 400 messages, so it is not there. -
origininget_message_securityon 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.comreports
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.domainlooked forheader.d; Gmail reports the DKIM
domain asheader.iand never asheader.d— 492 of 492 verdicts
sampled. So the property returned""for every DKIM verdict there, which
meantunsubscribe._verified_signaturesfound nothing,headers_are_signed
returnedNonerather than a verdict, and all 48 one-click offers were
reported assignature_coverage_unknowninstead 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 coversList-Unsubscribebut notList-Unsubscribe-Post,
which RFC 8058 §3.2 requires for one-click. -
brand_mismatchjudges the recovered origin. Seven relayed Facebook
messages were refused for impersonating Facebook — they came from
facebookmail.comthrough SimpleLogin, and the check sawsimplelogin.co.
Relayed unsubscribe offers go from 21 allowed / 7 refused to 28 / 0 on the
sample, with no direct-mail decision changed._reply_to_mismatchis deliberately left alone: under relaying bothFrom
andReply-Toare 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.summarisenames 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
-
Source code (ZIP)
0 downloads
-
Source code (TAR.GZ)
0 downloads
-
-
v0.44.2 Stable
released this
2026-08-25 15:12:31 -05:00 | 73 commits to main since this releaseTwo 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_mismatchno longer reads a brand out of the middle of a word. The check
askedbrand in display_nameagainst bare substrings, andupsis 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_mismatchis not in
unsubscribe.NON_BLOCKING_FLAGS, so it was a hard refusal to unsubscribe from real
mail.irsinside 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, sopaypal-billing.comandapple-id-verify.com— the exact
shape a lookalike takes — passed silently. The flattening could not simply be
dropped: it was also what letbankofamerica.commatch. So_BRANDSbecame
_BRAND_DOMAINS, and a domain qualifies two ways — it is one the brand is listed as
sending from (exact or subdomain, theauthresults.is_trustedrule), or the brand is a
whole dot-label of it. The map is deliberately sparse, because the label rule already
coversusps.comandcorporate.target.com;facebookmail.comis 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_messagehas
taken a singlemessage_idsince 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 wheneverMAIL_EXPUNGE_ENABLEDis 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
-
Source code (ZIP)
0 downloads
-
Source code (TAR.GZ)
0 downloads
-
-
0.44.1 Stable
released this
2026-08-24 23:36:57 -05:00 | 74 commits to main since this releaseThe 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.pyrefuses 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
bytesliterals and reassembles f-strings, because both are blind spots exactly
where the fixtures live: a rawFrom:header is bytes, andf"Pay{ZWSP}Pal Security"is
two AST nodes, so a name split across an interpolation would pass a naive walk. -
test_no_pii.pyrefuses 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.mdrecords 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 tookgit 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 untilgit gc --prune=nowran 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
-
Source code (ZIP)
0 downloads
-
Source code (TAR.GZ)
0 downloads
-
-
released this
2026-08-24 21:16:07 -05:00 | 75 commits to main since this releaseCanvas 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 carryassignment_id,topic_idorpage_urlbeside
opens_with; planner items carryassignment_idortopic_id.
Fixed
-
list_modulesnamed a tool and handed over an id that tool rejects. A
module item has two ids —idis its place in the module,content_idis the
thing it points at — and 0.43.0 emitted only the first while labelling
Assignment itemsopens_with: get_assignment. Measured on two live courses:
that id returns 404 from the assignments endpoint andcontent_id
returns the assignment. Pages were unaffected, becausepage_urlwas 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_pagebodyis null, reason inlock_explanation"This page exists but has no text in it… do not retry" get_assignmentdescriptionis null, samean assignment with no instructions get_discussionmessageIS 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 carrylockedand a
strippedlock_explanation, and the notes distinguish "Canvas is withholding
this" from "the instructor left it blank". A withheld discussion prompt comes
back empty withprompt_withheld: truerather 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 discussionget_discussionand an assignment
get_assignment; every module item's handle opens its own reader; the graded
topic reportsprompt_withheldwith the module holding it named; and the call
that started this — a topic id sent toget_assignment— still fails, which
is correct, while theassignment_idthe topic hands over succeeds. -
probes/canvas_probe_c5.pyholds the measurements the design rests on —
gitignored like every other probe, so what it established is written down
here, inCLAUDE.mdand in the fixtures rather than only in the script.
Downloads
-
Source code (ZIP)
0 downloads
-
Source code (TAR.GZ)
0 downloads
-
-
released this
2026-08-24 18:48:01 -05:00 | 79 commits to main since this releaseCanvas C4:
list_modulesandget_page. The tenant is complete at nine tools.Added
list_modules— a course's outline in order, items inline, with each module's
per-studentstate. 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_withis 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_itemsis dropped.include[]=itemsreturns 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 cancelledlist_submissions.get_pagereverses 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 turnedlist_modulesinto a list of
things nothing could open.
Cost
list_modulesis 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 acourse_idrather 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
-
Source code (ZIP)
0 downloads
-
Source code (TAR.GZ)
0 downloads
-
released this
2026-08-24 18:13:36 -05:00 | 81 commits to main since this releaseCanvas 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 onplannable_type. Canvas states whether an item counts
toward a grade in the item itself —submissionsis a dict of flags on anything gradeable
and the literalfalseon 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. Noquizitem 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_datego to Canvas, proved server-side with a window containing
nothing that returned zero items rather than everything.- Sorted by
due_atwith undated items last. Canvas orders byplannable_date, which
is a sort key rather than a deadline. non_coursework_ignoredis 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_typethe
obvious way.1374 tests green.
Downloads
-
Source code (ZIP)
0 downloads
-
Source code (TAR.GZ)
0 downloads
-
released this
2026-08-24 17:44:56 -05:00 | 82 commits to main since this releaseCanvas C2: the five reads that make the tenant useful.
list_assignments,
get_assignment,get_grades,list_announcementsandget_syllabus— one tool to six.Every one was designed against a measurement rather than a guess.
probes/canvas_probe_c2.pyran before any of it was written and moved four decisions.The sharpest trap in the phase
currentandfinalare not two spellings of one number.currentscores only what has
been graded;finaltreats 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 namedprojected_score_if_nothing_else_is_submitted, and there is
deliberately no neutralfinal_scorekey for a model to reach for by mistake.Added
get_gradesis ONE request for every course, not one per course.turned_inreadsworkflow_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 ownlate,missingand
excusedverdicts are carried rather than recomputed — an extension is invisible from
here and Canvas has already applied it.get_assignmentearns its place on RESPONSE SIZE, not on data. Both shapes are 71
fields; the single fetch adds nothing. The list omitsdescription, the getter carries it.list_announcementsspans 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_htmlmoved tomcp_gateway/htmltext.py, the ruletimeparse.pywas 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 outlateourselves from
the dates", "keep sending the params while walking pages".1367 tests green.
Downloads
-
Source code (ZIP)
0 downloads
-
Source code (TAR.GZ)
0 downloads
-
released this
2026-08-24 16:04:01 -05:00 | 86 commits to main since this releaseA 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 okis logged atinforather thandebug. 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" casepreflight.pyexists to prevent, arriving an hour at a time
instead of at boot.session rotatedis 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 survivesLOG_LEVEL=info.
Proved by putting it back todebugand watching it fail. The second pins that a line
running hourly and forever never carries a cookie value.
1351 tests green.
Downloads
-
Source code (ZIP)
0 downloads
-
Source code (TAR.GZ)
0 downloads
-