Skip to content

v2.23.0

Choose a tag to compare

@kiki830621 kiki830621 released this 21 Jul 16:22

Added

  • 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 #177 skip_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.