You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
New check_automation tool — the third TCC probe axis (#293, formalizing the #288 Strategy's deferred optional; blueprint = che-ical-mcp's EventKit probe/setup pattern). check_fda and check_accessibility covered two axes; Automation had only the after-the-fact -1743 guidance. AutomationStatus.probe() wraps AEDeterminePermissionToAutomateTarget(askUserIfNeeded: false) — a NON-prompting status read issued BY the binary itself (the only honest vantage per #288's attribution model: the signed binary holds its own grant, so a shell osascript probe would report the terminal's grant instead). Four states with per-state remediation: granted / denied (recorded -1743 — reuses AutomationHelp.guidance verbatim, single text source) / not-determined (-1744 — run any Mail tool to trigger the prompt; the prompt names the binary/host, not the terminal) / Mail-not-running (open Mail first — the probe deliberately has no side effects, no auto-launch); unexpected OSStatus surfaces verbatim, judged conservatively. Pure state→report mapping unit-tested; the live probe is the thin residue layer. Tool count 52→53 (census guards updated across README/README_zh-TW/server.json/mcpb manifest). 1013→1019 tests (cluster).
Custom from_address now rides the wrapper-free clean path — verified sender popup (#219, the #175 follow-up). mailto always composes from the default account, so a custom sender previously forced the legacy path (correct account, body wrapped in blockquote type="cite"). The GUI now drives the compose window's From popup — located as the pop up button whose current VALUE carries an @ (the signature popup doesn't) — clicks the menu item containing the requested address, then reads the popup value back (the issue's hard requirement): any mismatch/absence raises a pre-dispatch SENDERPOPUP sentinel, the script's on-error cleanup closes OUR window (saving no, never a pre-existing same-titled draft), and the router falls back to legacy whose set sender guarantees the right account — a send never leaves from the wrong account. Eligibility flip: hasCustomSender no longer disqualifies; without Accessibility the reason names the popup mechanism it needs. Result string discloses the verified sender; tool descriptions / compose-wrapper-free rules updated (the manual account-switch footgun recipe is superseded, kept only for Accessibility-less machines). Popup phase is ordering-pinned before dispatch. Cross-model verify (2 Claude lenses + Codex) hardened three safety edges in-loop: the popup selection AND read-back match by exact addr-spec (the senderMatches handler tests a menu label by exact <addr> suffix — is _addr or ends with "<addr>" — never extraction or substring contains — a contains check would have verified notme@x / me@x.tw as me@x and sent from the wrong account (Codex BLOCKING)); a pre-existing same-subject window aborts the clean path (System Events can't read Mail's window id, so a title collision made identity ambiguous → legacy fallback); and disclosure/refusal strings + shouldUseMailtoCompose doc were de-staled (no longer say "pending #219"). A second cross-model round (Codex R3 BLOCKING) closed two more edges: a quoted-local-part from_address ("prefix<foo"@evil) could suffix-match a crafted evil account label → a new isSimpleAddrSpec eligibility gate routes any non-simple custom sender (quote/angle-bracket/whitespace/multi-@) to legacy, so senderMatches is only ever driven with a simple addr-spec and the spoof is unreachable; and the new-window identity now asserts exactly one(new ∧ title==subject) candidate (_ourMatches), failing closed on a concurrent same-subject window rather than risking a saving no on the user's window — while still not over-rejecting when launching Mail opens its viewer too. Both Claude lenses PASS across rounds; the residuals (GUI-automation title TOCTOU, a cosmetic orphan compose window on a pre-try guard, case-fold of two same-canonical-mailbox accounts) are inherent or non-exploitable. Live smoke (real multi-account Mail) is the attended residue.
Display-name recipients ride the clean path on drafts — GUI clipboard fill (#277, #219's complement; together the three-way "clean body + names + chosen account" now coexists on drafts). RFC 6068 can't carry a display name, so Name <email> recipients forced the legacy path. On the draft path the mailto URL now omits any To/Cc list that carries a display name and the GUI fills it after the window opens: clipboard paste (never keystrokes — CJK names would hit IME nondeterminism, the #220 lesson; clipboard preserved via the #175 machinery), exploiting the fresh window's To-field default focus, tab to tokenize, Cc likewise. Draft-only by design: a failed fill on a send would fire with missing recipients, so compose_email keeps display-names → legacy; a display-name bcc also stays legacy (the Bcc field isn't reliably fillable) — the eligibility reason spells out the boundary (displayNameFillViable, threaded from both createDraft probe sites, wiring-scan pinned). Cross-model verify (Codex BLOCKING) scoped the fill to To-only: Mail's Cc field can be hidden via Header Fields, so a Tab-to-Cc paste could silently land in Subject and drop the Cc — display-name cc/bcc now keep the legacy path (displayNameFillViable requires a display-name-free cc AND bcc), the To field is always visible + default-focused. A defense-in-depth guard in composeViaMailto throws if a send ever reaches the fill phase (structurally unreachable via eligibility, but fail-loud beats a wrong-recipient send). Result string tells the caller to verify To in the saved draft (fill read-back is limited — the honest verification is the attended live smoke, shared with #219).
Added
-1743 (Automation TCC denied) now returns actionable guidance (#288). The raw AppleScript error (-1743): Not authorized to send Apple events to Mail passed through with zero remediation — two independent sessions re-diagnosed the same wall from one bare line (recurrence documented on the issue). The mapping lives at the single MailError.scriptFailed rendering sink (every AppleScript-backed tool funnels through it — the FullDiskAccessHelp precedent, Automation axis), matches on the error CODE (never the locale-dependent message text), and appends — the raw error stays first. Guidance (attribution model empirically corrected pre-merge, #288 evidence 2026-07-21): the signed MCP binary holds its own Automation grant — TCC identity keyed to the binary's signing identity (the #211 FDA lesson, Automation axis), separate from the terminal's; on the incident machine shell osascript controlled Mail while the binary still got -1743, so the guidance pre-empts the osascript-works misdiagnosis, names the System Settings → Privacy & Security → Automation entry location (Desktop extension: under Claude.app), gives the remembered-denial escape hatch (tccutil reset AppleEvents — macOS never re-prompts a recorded Deny), the per-install/update-invalidation warning (#211 family), and the immediate zero-TCC fallback pointer to open_mailto (#287, cite-block-free). All other error codes stay byte-identical passthrough (pinned by test). 998→1001 tests.
Changed
open_mailto now goes through LaunchServices — zero Automation TCC (#287). The old implementation drove tell application "Mail" / mailto — an Apple event, so the nominally "just open a URL" tool required Automation TCC and died with -1743 on an unauthorized machine, exactly where a zero-TCC escape hatch is needed (session-verified: shell open "mailto:..." worked while both MCP compose paths failed). Now NSWorkspace.shared.open posts the URL through LaunchServices: no Apple events, no TCC, and the mailto compose window is inherently cite-block-free (#175). A mailto: scheme guard replaces the old accept-anything behavior (an https: URL would have silently opened a browser); open returning false (no mailto handler) fails loud with a remediation hint. Disclosed trade-offs (result string + tool description): the window opens in the system DEFAULT mail client (may not be Mail.app), and mailto cannot carry attachments (RFC 6068 — drag manually). The tool description, .claude/rules/compose-wrapper-free.md (new "TCC fallback ladder" section), and README (new -1743 troubleshooting section) now all spell out the cite-block-avoidance ladder — (a) clean path (Automation+Accessibility) → (b) open_mailto (zero TCC) → (c) legacy injection (wrapped; formal mail unacceptable) — with the iron rule: on -1743, (b) is the answer, never (c), and the responsible-process table (CLI → terminal app; Desktop extension → Claude.app; independent grants, update-invalidation per #211). 994→998 tests.
Fixed
atCount counts unicode scalars, not grapheme clusters (#289, the atCount sibling of #280's angle-scan fix, surfaced by its verify R2). @ fused with a trailing combining scalar (U+FE0F) into one grapheme cluster compared unequal to "@" under Character-level counting — a@\u{FE0F}b@c counted atCount==1 and passed with the masked @ intact (Mail-level invalid, no mis-send; the masked-only-@ case was already fail-safe at atCount==0). Scalar counting sees every U+0040 → atCount==2 → rejected. Cross-model verify (Codex) caught the boundary sibling in-loop: the prefix/suffix checks were still Character-level, so a LEADING fused @ (@\u{FE0F}x — empty local part) would have flipped from accidental-reject to accept; they now compare unicode scalars too. Documented residual: a trailing-side mask (user@\u{FE0F}) leaves the @ non-terminal and the FE0F-only domain is accepted as Mail-level-invalid garbage (benign, same class as a@- — the old rejection was an accident of the fusion bug itself; domain grammar is out of lite scope; pinned). Combining scalars elsewhere are unaffected (café@x passes, pinned). 1001→1006 tests (cluster).
parseRecipient split is now quote-aware (#286, surfaced by the #280 verify). The display-name / addr-spec split used a bare lastIndex(of: "<"), which landed INSIDE an RFC 5322 quoted local-part that starts with < — "Foo" <"<a>"@x> (grammar-legal) extracted as name=Foo" <, addr=a>"@x, and the mis-paired wasQuoted prefix/suffix check accepted the garbled name. The split now picks the last <outside any quoted string (quoted-pair escapes honored, same semantics as unescapeQuotedPairs/containsUnquotedAngle), so the shape extracts cleanly and composes; the #280-era fail-loud pin flipped to a pass expectation. Split-happened invariant: an unquoted < requires every earlier quote closed, so the extracted name always carries balanced quote state — wasQuoted can no longer pair quotes from two different sources. Two deliberate edges pinned: an unterminated quote never yields an unquoted < ("Foo <a@x> stays bare → validator rejects; the old split silently accepted the unbalanced-quote name — fail-loud direction), and a grapheme-masked < (fused with U+FE0F) is literal at this layer (no split; the validator's #280 scalar scan rejects downstream — splitting at a scalar index could land mid-grapheme and trap). Cross-model verify (Codex) surfaced a third pinned edge: a quote inside a CFWS comment (Name (") <a@x> — " is legal ctext) reads as a quoted-string opener to the comment-blind lite scan, and an ODD quote count before < lands in the same unterminated-quote bare→reject class (deliberate boundary, consistent with #280's angles-in-comments pin; an EVEN count — Acme ("The Best") <s@a.com> — still splits fine, pinned). 989→994 tests.
batch_export_emails_markdown now consumes fromPartialEmlx — bulk archives are never silently header-only (#283, closes the #274 gap on archive-mail's primary Step 5.0 path). The export fetch closure (EmlxParser.readEmail, format "text") already carried the #274 partial-.emlx bit, but ExportEmailsMarkdown.run dropped it: a header-synced / body-not-downloaded message rendered into a header-only .md with a clean "written" status. Now the run judges each fetched email with a renderer-aligned predicate (partialBodyMissingForExport: partial AND textBody ?? htmlBody ?? "" empty — exactly the .md body selection) — deliberately AFTER the #177 dedup skip (an already-archived duplicate stays "skipped", unannotated). Cross-model verify (Codex) rejected the first-cut "text"-format judgment: a .partial.emlx can carry a full html-only body (Mail also uses partial files when attachments are stored externally; html-only mail has no text/plain part), which a text-only judgment mis-flags — and under skip_partial would silently omit a complete email from the corpus forever (its re-export re-judges and re-skips). The ??-precedence edge is pinned: a non-nil empty text body wins the renderer selection, so the rendered body is empty → flagged. Default behavior is byte-compatible annotate-and-write: the manifest item gains negative-only body_downloaded: false (#274 contract parity — false or absent, never true; threaded through written/escape-error/write-error items) and the summary gains a body_not_downloaded count for an O(1) SOP check. New opt-in opts.skip_partial keeps the header-only email OUT of the corpus instead: status "header_only", nothing written — so the SOP's clean loop is "re-fetch flagged ids via single get_email (whose #274 fallback doubles as the download nudge), re-run export for just those ids", with no stale-file cleanup and no collision-guard -N duplicates. Two more verify-round tightenings: the skipped email still consumes its filename slot (the skip branch runs AFTER filename resolution + uniquify — Codex: name attribution must be independent of download state, so the re-export lands on the same name it would have taken complete, and a same-(date,slug) sibling takes the -N suffix either way); and the schema description scopes the re-export recipe to skip_partial:true (consumer-lens empirical finding: under the DEFAULT annotate-and-write mode the recipe silently produces a stale header-only .md PLUS a -N-suffixed duplicate, because #232's cross-call collision guard seeds from files really on disk — default-mode guidance now says delete each flagged item's written_path first, or use skip_partial from the start). A schema-advertises census test pins opts.properties.skip_partial (the #177skip_message_ids_path precedent, one level deeper). Fetch-error items stay unannotated (no content to judge — documented). SOP-side consumption (plugins-repo archive-mail.md reading the new signal) is cross-repo and tracked on the issue. 973→981 tests.
Recipient lite validator — the angle scan now also covers the display-name path, at scalar level (#280, residual of #270). The #270 unquoted-angle guard was gated on parsed.name == nil, so when a mailbox form successfully parsed a display name the extracted addr-spec was never re-scanned — Name <a>b@x> extracts addr a>b@x (the parser splits at the LAST <, so the earlier > stays embedded), passes atCount==1, and the stray > landed in the AppleScript recipient as a Mail-level invalid (no mis-send; same benign-malformed class as #270). The scan now runs unconditionally on the parser-extracted addr-spec; the quote-aware exemption is unchanged, so legal quoted local-parts still pass on both paths (王小明 <"a>b"@example.net> locked by test). Cross-model verify (3 Claude lenses + Codex) tightened two more edges in-loop: containsUnquotedAngle iterates unicodeScalars instead of Characters — a > fused with a trailing combining scalar (e.g. U+FE0F) into one grapheme cluster compared unequal to ">" and slipped the Character-level scan (pre-existing since #270, both paths; all structural chars are single ASCII scalars, so scalar comparison is strictly more precise); and CFWS comments are a pinned deliberate boundary — grammar-legal Name <user@example.net(>)> is rejected consistently on both paths now, matching the bare path's shipped #270 behavior (the lite validator never supported comments: one carrying @ always failed atCount; comment-aware scanning is full-parser territory). Verify also surfaced a pre-existing parseRecipient lastIndex-split defect ("Foo" <"<a>"@x> garbles the extraction — the OLD gate silently composed to the mangled address, the NEW gate rejects loudly; pinned as deliberate fail-loud until the parser split is fixed) — tracked as #286. 976→981 tests.