From 392f14a83dedad96750749d46ad808f9c9bad493 Mon Sep 17 00:00:00 2001 From: Craig Jennings Date: Thu, 23 Jul 2026 23:41:24 -0500 Subject: chore(inbox): park the personal-gmail account-binding guard MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Home hit a cross-account hazard: a triage fire reached the personal inbox through a work-bound Gmail MCP and pulled 201 DeepSat messages, which would have run every hygiene action against the wrong mailbox. The fix adds one guard to the plugin's Scan phase — verify a sample result's recipient before classifying, fall back to the local mu mirror on mismatch rather than another MCP. Parked for approval since the plugin is a synced asset. Graded [#A] on severity alone: acting on the wrong person's mailbox is a privacy exposure regardless of how rarely it happens. Diff and companion note staged under working/. --- todo.org | 13 ++++ .../companion-note-from-home.org | 5 ++ working/triage-account-guard/proposed.diff | 11 ++++ .../triage-intake.personal-gmail.org.proposed | 74 ++++++++++++++++++++++ 4 files changed, 103 insertions(+) create mode 100644 working/triage-account-guard/companion-note-from-home.org create mode 100644 working/triage-account-guard/proposed.diff create mode 100644 working/triage-account-guard/triage-intake.personal-gmail.org.proposed diff --git a/todo.org b/todo.org index 08603b0..60f751c 100644 --- a/todo.org +++ b/todo.org @@ -89,6 +89,19 @@ Craig approved 2026-07-23. Applied to =claude-templates/.ai/notes.org= and synce CLOSED: [2026-07-23 Thu] Craig approved 2026-07-23. Added =*** Clear temp/= to =wrap-it-up.org= Step 3, after the archive pass, and synced to the mirror. Two guards: confirm before deleting anything that reads as in-progress rather than throwaway (that belongs in =working/=, so move it there), and skip entirely where =temp/= isn't gitignored, since that means the project uses the directory for something else. This closes the last open clause of his 2026-07-20 working/temp capture. +** VERIFY [#A] Parked: account-binding guard for the personal-gmail triage plugin (from home) +:PROPERTIES: +:LAST_REVIEWED: 2026-07-23 +:END: +What arrived: home hit a real cross-account hazard on 2026-07-23 — a sentry triage fire used =mcp__claude_ai_Gmail= (bound to the DeepSat work account, name gives no hint) instead of =google-docs-personal=, pulled 201 unread work messages, and would have run every hygiene action against the wrong inbox. The fix adds one guard block to the =triage-intake.personal-gmail.org= Scan phase: confirm a sample result's =to:= is =craigmartinjennings@gmail.com= before classifying, and on mismatch or unavailability fall back to the local mu mirror rather than another MCP. + +Recommendation: accept as-is. The diff is a single clean addition between the promotions-filter warning and the maxResults-cap warning — nothing else in the plugin changes. The bug is reproduced and dated, the account→tool mapping was verified by home 2026-07-23 (google-docs-personal=personal, google-docs-work + claude_ai_Gmail=work-bound), and the mu-mirror fallback is the right escape (it's account-fixed by maildir, not by an ambiguous MCP name). I agree with home's judgment that cmail needs no analogous note — it's a bridge script fixed to c@cjennings.net by construction, no tool-binding ambiguity. + +Graded [#A] on severity alone, per the todo-format security/privacy carve-out: acting on the wrong person's mailbox is a privacy exposure, and one occurrence with work mail misrouted through a personal-hygiene close is a showstopper regardless of frequency. + +Prepared diff: [[file:working/triage-account-guard/proposed.diff]] — apply is mechanical (home's file becomes canonical) on your go. Companion note: [[file:working/triage-account-guard/companion-note-from-home.org]]. +Say "approve the parked account guard" (or adjust / reject) and it gets applied. + ** VERIFY [#B] Parked: term-translation-density voice pattern #48 (from work) :PROPERTIES: :LAST_REVIEWED: 2026-07-23 diff --git a/working/triage-account-guard/companion-note-from-home.org b/working/triage-account-guard/companion-note-from-home.org new file mode 100644 index 0000000..92157ca --- /dev/null +++ b/working/triage-account-guard/companion-note-from-home.org @@ -0,0 +1,5 @@ +#+TITLE: Companion to the triage-intake.personal-gmail.org file just +#+SOURCE: from home +#+DATE: 2026-07-23 23:38:56 -0500 + +Companion to the triage-intake.personal-gmail.org file just sent: added a Verify-account-binding guard under Scan. Why: on 2026-07-23 a home sentry triage fire used mcp__claude_ai_Gmail expecting personal Gmail and instead pulled 201 unread DeepSat WORK messages — that MCP is bound to the work account and its name gives no hint. The plugin already correctly specifies mcp__google-docs-personal; the gap was that nothing made the agent verify the binding before trusting (and then acting on) the results. The guard: confirm a sample result's to: is craigmartinjennings@gmail.com before classifying; if wrong or unavailable, fall back to the local mu mirror (maildir /gmail, sync first) rather than another MCP. Verified account→tool mapping 2026-07-23: google-docs-personal=personal gmail, google-docs-work=deepsat, claude_ai_Gmail=deepsat(work-bound); maildirs gmail/cmail/dmail = craigmartinjennings@gmail.com / c@cjennings.net / craig.jennings@deepsat.com. No engine change needed — the engine is account-agnostic by design; the guard belongs in the plugin. Consider whether cmail (bridge script, account-fixed by construction) needs any analogous note — I judged not. diff --git a/working/triage-account-guard/proposed.diff b/working/triage-account-guard/proposed.diff new file mode 100644 index 0000000..0eda2f6 --- /dev/null +++ b/working/triage-account-guard/proposed.diff @@ -0,0 +1,11 @@ +--- .ai/workflows/triage-intake.personal-gmail.org 2026-07-16 10:42:14.461682666 -0500 ++++ working/triage-account-guard/triage-intake.personal-gmail.org.proposed 2026-07-23 23:38:44.358507825 -0500 +@@ -27,6 +27,8 @@ + + ⚠ *Do NOT add =-category:promotions -category:social=.* That filter masked 67 promo+social messages across two runs (2026-05-04, 2026-05-06), both needing a follow-up sweep. Pull the full unfiltered set; the trash-leaning bias in Classify handles promotions and social directly. + ++⚠ *Verify the account binding before trusting the scan.* =mcp__google-docs-personal= must resolve to =craigmartinjennings@gmail.com=. Several Gmail-capable MCPs are connected and they bind to *different* accounts — =mcp__claude_ai_Gmail= is bound to the DeepSat *work* account, and its name gives no hint of that. A wrong-account scan returns a plausible mailbox that is the wrong person's, and every hygiene action in the close then fires on the wrong inbox (this happened 2026-07-23: a sweep used =claude_ai_Gmail= and pulled 201 unread *DeepSat work* messages instead of personal). Guard, every scan: confirm a sample result's =to:= is =craigmartinjennings@gmail.com= before classifying. If it isn't, or if =google-docs-personal= is unavailable, do NOT reach for another MCP — use the local mu mirror: sync first (=mbsync gmail && mu index=; the index lags), then =mu find 'maildir:/gmail/INBOX AND flag:unread AND date:..now'=. The three accounts and their maildirs: =gmail= = craigmartinjennings@gmail.com, =cmail= = c@cjennings.net, =dmail= = craig.jennings@deepsat.com (work — out of scope from a home session, whichever tool reaches it). ++ + ⚠ *The MCP caps at =maxResults=100= and exposes NO =pageToken= parameter.* The response carries a =nextPageToken=, but the tool can't consume it, so a pile over 100 is silently truncated — the tail below the cap never gets classified, and every later anchored sweep skips it (it predates the new anchor). This is exactly how a 300+ backlog accumulated invisibly by 2026-07-08. Two consequences: + + - *Never treat a 100-row result as complete.* When a scan returns exactly 100, walk the tail in *date slices*: re-query with =before:= (day resolution), repeat until a page returns fewer than 100, dedupe by message id across slices (the day-resolution boundary overlaps). diff --git a/working/triage-account-guard/triage-intake.personal-gmail.org.proposed b/working/triage-account-guard/triage-intake.personal-gmail.org.proposed new file mode 100644 index 0000000..d66ed0b --- /dev/null +++ b/working/triage-account-guard/triage-intake.personal-gmail.org.proposed @@ -0,0 +1,74 @@ +#+TITLE: Triage Intake — Personal Gmail Source +#+AUTHOR: Craig Jennings +#+DATE: 2026-05-26 + +# Source plugin for the triage-intake engine. See triage-intake.org for the +# contract and the Phase A-D orchestration. This file declares ONE source. + +* Source: personal-gmail +:PROPERTIES: +:ORDER: 20 +:ENABLED: mcp google-docs-personal present +:ANCHOR: epoch +:SUBAGENT_OVER: 50 +:END: + +** Scan + +Personal Gmail unread in the inbox since the anchor: + +#+begin_src text +mcp__google-docs-personal__listMessages q="is:unread in:inbox after:" maxResults=100 +#+end_src + +⚠ *Express every anchor cutoff as the literal UNIX epoch* — =after:1784177122= and =before:1784177122= for the same anchor, never the =YYYY/MM/DD= form. This governs *both* anchored queries: the scan above and the backlog-residue probe below. They must meet at the same instant or mail falls between them permanently. Gmail's day-resolution operators fail two different ways: =after:YYYY/MM/DD HH:MM:SS= is not valid syntax at all — Gmail parses the space as a term separator, treats =HH:MM:SS= as a search term that never matches, and returns 0 results, silently masking unread mail — while =before:YYYY/MM/DD= is valid but excludes the named day entirely, so pairing it with a second-resolution scan leaves the whole anchor day covered by neither query. The engine supplies == because this source declares =ANCHOR: epoch=. + +The rule binds the *anchor* windows only. The date-slice walk below deliberately uses =before:= at day resolution — safe there because consecutive slices overlap and get deduped by message id. + +⚠ *Do NOT add =-category:promotions -category:social=.* That filter masked 67 promo+social messages across two runs (2026-05-04, 2026-05-06), both needing a follow-up sweep. Pull the full unfiltered set; the trash-leaning bias in Classify handles promotions and social directly. + +⚠ *Verify the account binding before trusting the scan.* =mcp__google-docs-personal= must resolve to =craigmartinjennings@gmail.com=. Several Gmail-capable MCPs are connected and they bind to *different* accounts — =mcp__claude_ai_Gmail= is bound to the DeepSat *work* account, and its name gives no hint of that. A wrong-account scan returns a plausible mailbox that is the wrong person's, and every hygiene action in the close then fires on the wrong inbox (this happened 2026-07-23: a sweep used =claude_ai_Gmail= and pulled 201 unread *DeepSat work* messages instead of personal). Guard, every scan: confirm a sample result's =to:= is =craigmartinjennings@gmail.com= before classifying. If it isn't, or if =google-docs-personal= is unavailable, do NOT reach for another MCP — use the local mu mirror: sync first (=mbsync gmail && mu index=; the index lags), then =mu find 'maildir:/gmail/INBOX AND flag:unread AND date:..now'=. The three accounts and their maildirs: =gmail= = craigmartinjennings@gmail.com, =cmail= = c@cjennings.net, =dmail= = craig.jennings@deepsat.com (work — out of scope from a home session, whichever tool reaches it). + +⚠ *The MCP caps at =maxResults=100= and exposes NO =pageToken= parameter.* The response carries a =nextPageToken=, but the tool can't consume it, so a pile over 100 is silently truncated — the tail below the cap never gets classified, and every later anchored sweep skips it (it predates the new anchor). This is exactly how a 300+ backlog accumulated invisibly by 2026-07-08. Two consequences: + +- *Never treat a 100-row result as complete.* When a scan returns exactly 100, walk the tail in *date slices*: re-query with =before:= (day resolution), repeat until a page returns fewer than 100, dedupe by message id across slices (the day-resolution boundary overlaps). +- *Never report =resultSizeEstimate= as a count.* It's unreliable — observed stuck at "201" across three different queries whose real union exceeded 300. + +*** Backlog-residue check (every sweep — cheap, mandatory) + +The anchored scan is blind to anything unread from *before* the anchor. After it, run one probe for pre-anchor residue: + +#+begin_src text +mcp__google-docs-personal__listMessages q="is:unread in:inbox before:" maxResults=5 +#+end_src + +The cutoff is the epoch, matching the scan's =after:= — see the epoch rule above. + +If it returns any messages, surface one loud line in the sweep summary: "Backlog: unread predating the anchor exists (N+ shown; date-slice to inventory)" and offer a backlog sweep. Never fold the residue into a quiet sweep — an anchored "no changes" claim is only true for the window the scan saw. (Added 2026-07-08 after ~300 pre-anchor unread accumulated unseen; the probe returns actual messages, so it works where the estimate lies. Shipped with a day-resolution cutoff that hid the entire anchor day; fixed to epoch 2026-07-16 after a home sweep reported the backlog clear while two July-15 messages sat unread.) + +** Classify + +Bias: *trash-leaning* — personal Gmail is high noise volume. + +- *Noise-trash:* newsletters, Substacks, retail/SaaS marketing, social digests, redundant aggregator digests (Notion/Miro daily), wrong-recipient mail, past-event calendar artifacts. +- *Noise-keep:* receipts, order confirmations, statements — low value but worth the audit trail. +- *FYI:* substantive personal mail with no action owed. +- *Action:* an explicit ask, a reply owed, a time-sensitive personal matter. + +** Render + +#+begin_example +**Personal Gmail — N unread.** +- Action: +- FYI: +- Noise: N trash candidates, M keep +#+end_example + +Omit the block if zero unread. + +** Actions + +- trash :: =mcp__google-docs-personal__trashMessage= id= (recoverable from Gmail Trash for 30 days) +- mark-read :: =mcp__google-docs-personal__modifyMessageLabels= id= removeLabelIds=["UNREAD"] +- star+read :: =mcp__google-docs-personal__modifyMessageLabels= id= addLabelIds=["STARRED"] removeLabelIds=["UNREAD"] +- attach-fetch:: =.ai/scripts/gmail-fetch-attachments.py --profile personal --message-id --output-dir = -- cgit v1.2.3