apple-tools-mcp 2.0.0 → 2.0.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/writeTools.js CHANGED
@@ -32,7 +32,7 @@ import {
32
32
  import { contactsAdd, contactsEdit, contactsRemove } from "./contactsWrite.js";
33
33
  import { planWriteRoute, planAfterDelegation, tccFallbackAdvice } from "./writeRouting.js";
34
34
  import { requestWriteViaBridge, probeSocket, defaultSocketPath } from "./writeBridge.js";
35
- import { isTccDenial } from "./appleScript.js";
35
+ import { needsHostTccAdvice } from "./appleScript.js";
36
36
 
37
37
  const CONFIRM_PROPS = {
38
38
  dry_run: { type: "boolean", description: "Preview only: report what would happen and change nothing (default false)" },
@@ -48,7 +48,7 @@ export const WRITE_TOOL_DEFINITIONS = [
48
48
  // ============ MAIL WRITES ============
49
49
  {
50
50
  name: "mail_send",
51
- description: "Send an email through Mail.app. Sending to more than one recipient in total (to + cc + bcc) requires confirm=true. Use dry_run=true to preview.",
51
+ description: "Send an email through Mail.app. Sending to more than one recipient in total (to + cc + bcc) requires confirm=true. Use dry_run=true to preview. A send hang is a timeout, not TCC, unless Mail reports -1743/-10004; the tool checks Sent before failing. Clients must Sent-check before retrying a timed-out send.",
52
52
  inputSchema: {
53
53
  type: "object",
54
54
  properties: {
@@ -82,7 +82,7 @@ export const WRITE_TOOL_DEFINITIONS = [
82
82
  },
83
83
  {
84
84
  name: "mail_reply",
85
- description: "Reply to an existing email. reply_all=true fans out to every original recipient and requires confirm=true.",
85
+ description: "Reply to an existing email. reply_all=true fans out to every original recipient and requires confirm=true. A send hang is a timeout, not TCC, unless Mail reports -1743/-10004; the tool checks Sent before failing. Clients must Sent-check before retrying a timed-out send.",
86
86
  inputSchema: {
87
87
  type: "object",
88
88
  properties: {
@@ -97,7 +97,7 @@ export const WRITE_TOOL_DEFINITIONS = [
97
97
  },
98
98
  {
99
99
  name: "mail_forward",
100
- description: "Forward an existing email to new recipients. More than one recipient requires confirm=true.",
100
+ description: "Forward an existing email to new recipients. More than one recipient requires confirm=true. A send hang is a timeout, not TCC, unless Mail reports -1743/-10004; the tool checks Sent before failing. Clients must Sent-check before retrying a timed-out send.",
101
101
  inputSchema: {
102
102
  type: "object",
103
103
  properties: {
@@ -207,6 +207,7 @@ export const WRITE_TOOL_DEFINITIONS = [
207
207
  type: "object",
208
208
  properties: {
209
209
  event_id: { type: "string", description: "Event ID from calendar_date or calendar_add (required)" },
210
+ eventkit_id: { type: "string", description: "EventKit eventIdentifier from calendar_add (via EventKit). Prefer this over Calendar.app uid lookup." },
210
211
  title: { type: "string", description: "New title" },
211
212
  start: { type: "string", description: "New start as YYYY-MM-DD HH:MM local time" },
212
213
  end: { type: "string", description: "New end as YYYY-MM-DD HH:MM local time" },
@@ -231,7 +232,9 @@ export const WRITE_TOOL_DEFINITIONS = [
231
232
  inputSchema: {
232
233
  type: "object",
233
234
  properties: {
234
- event_id: { type: "string", description: "Event ID from calendar_date (required)" },
235
+ event_id: { type: "string", description: "Event ID from calendar_date or calendar_add (required)" },
236
+ eventkit_id: { type: "string", description: "Optional EventKit eventIdentifier returned by calendar_add (via EventKit). Needed so writeOnly EventKit can delete the event it created." },
237
+ calendar_name: { type: "string", description: "Optional calendar from calendar_list_calendars; narrows the AppleScript delete so Calendar does not scan every calendar" },
235
238
  ...CONFIRM_PROPS
236
239
  },
237
240
  required: ["event_id"]
@@ -327,6 +330,13 @@ export const WRITE_TOOL_HANDLERS = {
327
330
  contacts_remove: contactsRemove
328
331
  };
329
332
 
333
+ /**
334
+ * Smoke-only helpers, never MCP write tools. CallTool uses isWriteTool, so
335
+ * these names must stay off WRITE_TOOL_HANDLERS or a client can invoke them
336
+ * without dry_run / confirm.
337
+ */
338
+ export const SMOKE_ONLY_AUTOMATION_PROBES = ["mail_automation_probe", "messages_automation_probe"];
339
+
330
340
  export const WRITE_TOOL_NAMES = Object.keys(WRITE_TOOL_HANDLERS);
331
341
 
332
342
  export function isWriteTool(name) {
@@ -368,7 +378,7 @@ export async function dispatchWriteTool(name, args = {}, deps = {}) {
368
378
  log = () => {}
369
379
  } = deps;
370
380
 
371
- if (!isWriteTool(name)) {
381
+ if (SMOKE_ONLY_AUTOMATION_PROBES.includes(name) || !isWriteTool(name)) {
372
382
  return { ok: false, message: `Unknown write tool: ${name}` };
373
383
  }
374
384
 
@@ -389,8 +399,16 @@ export async function dispatchWriteTool(name, args = {}, deps = {}) {
389
399
  }
390
400
 
391
401
  const result = runLocally(name, args);
392
- if (result && result.ok === false && isTccDenial(result.message)) {
393
- return { ...result, message: `${result.message} ${tccFallbackAdvice({ bridgeAvailable })}` };
402
+ if (
403
+ result &&
404
+ result.ok === false &&
405
+ result.suppressTccAdvice !== true &&
406
+ needsHostTccAdvice(result.message)
407
+ ) {
408
+ return {
409
+ ...result,
410
+ message: `${result.message} ${tccFallbackAdvice({ bridgeAvailable, execPath: process.execPath })}`
411
+ };
394
412
  }
395
413
  return result;
396
414
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "apple-tools-mcp",
3
- "version": "2.0.0",
3
+ "version": "2.0.2",
4
4
  "description": "MCP server for semantic search and write actions across Apple Mail, Messages, Calendar, and Contacts",
5
5
  "type": "module",
6
6
  "main": "index.js",
@@ -47,12 +47,15 @@
47
47
  "lib/",
48
48
  "scripts/audit-index.js",
49
49
  "scripts/smoke-writes.js",
50
+ "scripts/postinstall.js",
50
51
  "README.md",
51
52
  "LICENSE"
52
53
  ],
53
54
  "scripts": {
54
55
  "start": "node index.js",
55
56
  "indexer": "node index.js --mode=indexer",
57
+ "permissions": "node index.js permissions",
58
+ "postinstall": "node scripts/postinstall.js",
56
59
  "build-index": "node -e \"import('./indexer.js').then(i=>i.rebuildIndex()).catch(e=>{console.error(e.message);process.exit(1)})\"",
57
60
  "audit": "node scripts/audit-index.js",
58
61
  "smoke:writes": "node scripts/smoke-writes.js",
@@ -0,0 +1,32 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Print-only reminder after npm install / upgrade.
4
+ *
5
+ * Must not run GUI / TCC probes unattended: a LaunchAgent or headless
6
+ * npm cannot click Allow. The operator runs `apple-tools-mcp permissions`
7
+ * on the host UI.
8
+ */
9
+
10
+ export function postinstallReminderText() {
11
+ return [
12
+ "apple-tools-mcp: after first install or upgrade, run permissions on the Mac UI",
13
+ "(with System Settings → Privacy & Security → Automation open):",
14
+ "",
15
+ " $(which node) $(which apple-tools-mcp) permissions",
16
+ " apple-tools-mcp permissions",
17
+ " npx apple-tools-mcp permissions",
18
+ "",
19
+ "Use the same node binary the product uses (process.execPath).",
20
+ "A shebang `apple-tools-mcp` can start a different node than the path you typed;",
21
+ "Allows attach to execPath. Re-run with that exact node if the command WARNs.",
22
+ "This reminder does not grant anything and does not run the probes."
23
+ ].join("\n");
24
+ }
25
+
26
+ export function printPostinstallReminder(write = console.log) {
27
+ write(postinstallReminderText());
28
+ }
29
+
30
+ if (process.argv[1] && /postinstall\.js$/.test(process.argv[1])) {
31
+ printPostinstallReminder();
32
+ }
@@ -2,13 +2,23 @@
2
2
  /**
3
3
  * Write-tool smoke test for QA prove-out on a Node host (Mac Mini / LaunchAgent).
4
4
  *
5
- * Covers both privacy classes that gate this package's writes:
6
- * - Contacts CRUD (AddressBook class, via Contacts.app)
5
+ * Covers the Automation / privacy classes that gate this package's writes:
6
+ * - Mail compose (Automation → Mail.app). dry_run never talks to Mail, so
7
+ * this step runs a real `make new outgoing message` (then discards it).
8
+ * A hang or timeout is TCC / Automation denied, not "Mail.app missing".
9
+ * - Messages account/service lookup (Automation → Messages.app). Nothing
10
+ * is sent. A deny fails --apply the same way Mail does.
11
+ * - Contacts CRUD (AddressBook class, via Contacts.app). The write path
12
+ * launches Contacts.app before CRUD; keep Contacts, Mail, and Messages
13
+ * running on the host. Calendar does not need to stay open (EventKit).
7
14
  * - Calendar CRUD (calendars class, via Calendar.app)
8
15
  *
9
- * Both must pass on the Node host, which is the ship gate. Both are expected
10
- * to fail under a host app that holds neither entitlement - that is a
11
- * documented host limitation, not a package failure.
16
+ * Mail, Messages, Contacts, and Calendar must all pass on the Node host —
17
+ * that is the ship gate. A missing grant for any one of those four apps
18
+ * fails --apply. Contacts and Calendar CRUD are expected to fail under a
19
+ * host app that holds neither entitlement - that is a documented host
20
+ * limitation, not a package failure. Mail and Messages are separate Apple
21
+ * Events targets: a Contacts/Calendar grant does not include them.
12
22
  *
13
23
  * Default is a dry run: no contact or event is created, edited, or deleted.
14
24
  * It is not a no-op, though: it reads contacts from the AddressBook database
@@ -32,6 +42,8 @@
32
42
  import { loadContacts, getContactStats } from "../contacts.js";
33
43
  import { probeSocket, defaultSocketPath } from "../lib/writeBridge.js";
34
44
  import { dispatchWriteTool } from "../lib/writeTools.js";
45
+ import { probeMailAutomation } from "../lib/mailWrite.js";
46
+ import { probeMessagesAutomation } from "../lib/messagesWrite.js";
35
47
  import { isIndexerMode } from "../lib/processMode.js";
36
48
 
37
49
  export function parseSmokeArgs(argv = []) {
@@ -92,6 +104,37 @@ export function extractEventId(message) {
92
104
  return match ? match[1] : null;
93
105
  }
94
106
 
107
+ export function extractEventKitId(message) {
108
+ const match = String(message || "").match(/eventkit_id:\s*(\S+)/);
109
+ if (!match) return null;
110
+ return match[1].replace(/[.,;]+$/, "");
111
+ }
112
+
113
+ export function createdViaEventKit(message) {
114
+ const text = String(message || "");
115
+ return /via:\s*EventKit\b/i.test(text) && Boolean(extractEventKitId(text));
116
+ }
117
+
118
+ /**
119
+ * Prefer an On My Mac calendar so Calendar.app delete is reliable if EventKit
120
+ * remove still misses. EventKit add/remove is the primary path on iCloud
121
+ * (writeOnly can delete events it created). An explicit --calendar= wins.
122
+ */
123
+ export function pickSmokeCalendar(calendars, explicitName) {
124
+ if (explicitName) {
125
+ return { name: explicitName, reason: `--calendar=${explicitName}` };
126
+ }
127
+ const writable = (calendars || []).filter((c) => c.writable);
128
+ const local = writable.filter((c) => c.local);
129
+ if (local[0]) {
130
+ return { name: local[0].name, reason: "On My Mac (EventKit sourceType local)" };
131
+ }
132
+ if (writable[0]) {
133
+ return { name: writable[0].name, reason: "first writable calendar (no local calendar listed)" };
134
+ }
135
+ return { name: "Calendar", reason: "fallback name Calendar" };
136
+ }
137
+
95
138
  /**
96
139
  * Listing calendars is a live Calendar.app call even during a dry run, so a
97
140
  * refusal there says something about this host - but it is not a failure of
@@ -104,6 +147,67 @@ export function calendarListSeverity(apply) {
104
147
  return apply ? "error" : "warning";
105
148
  }
106
149
 
150
+ /**
151
+ * Mail compose is a live Apple Events call (dry_run of mail_send never
152
+ * touches Mail). On --apply a deny fails the ship gate. On a dry run it is
153
+ * advisory, like calendar_list_calendars, so a refused prompt is WARN.
154
+ *
155
+ * @returns {"error"|"warning"}
156
+ */
157
+ export function mailProbeSeverity(apply) {
158
+ return apply ? "error" : "warning";
159
+ }
160
+
161
+ export function messagesProbeSeverity(apply) {
162
+ return mailProbeSeverity(apply);
163
+ }
164
+
165
+ /**
166
+ * How smoke exercises Mail Apple Events.
167
+ *
168
+ * The compose-and-discard helper is smoke-script-only — not an MCP write
169
+ * tool. On --apply with the write bridge up, also run public mail_draft so
170
+ * launchd-owned node is fail-closed for Mail (same make new outgoing message
171
+ * verb; nothing is sent).
172
+ *
173
+ * @returns {{ useLocalHelper: boolean, useMailDraft: boolean, reason: string }}
174
+ */
175
+ export function planMailSmokeTouch({ apply, daemonPath }) {
176
+ if (apply && daemonPath) {
177
+ return {
178
+ useLocalHelper: true,
179
+ useMailDraft: true,
180
+ reason: "local compose-and-discard helper plus mail_draft via the write bridge (live Mail Apple Events; nothing is sent)"
181
+ };
182
+ }
183
+ return {
184
+ useLocalHelper: true,
185
+ useMailDraft: false,
186
+ reason: "smoke-script compose-and-discard helper (live Mail Apple Events; nothing is sent). mail_send dry_run never touches Mail."
187
+ };
188
+ }
189
+
190
+ /**
191
+ * How smoke exercises Messages Apple Events.
192
+ * Smoke-script-only helper; this gate never sends and is not an MCP tool.
193
+ *
194
+ * @returns {{ useLocalHelper: boolean, reason: string }}
195
+ */
196
+ export function planMessagesSmokeTouch() {
197
+ return {
198
+ useLocalHelper: true,
199
+ reason: "smoke-script Messages account lookup (live Apple Events; nothing is sent). messages_send dry_run never touches Messages."
200
+ };
201
+ }
202
+
203
+ export function mailDraftSmokeArgs(stamp) {
204
+ return {
205
+ to: [`atm-mail-probe-${stamp}@example.com`],
206
+ subject: `ATM Mail Automation probe ${stamp}`,
207
+ body: "Created by apple-tools-mcp smoke test; safe to delete from Drafts."
208
+ };
209
+ }
210
+
107
211
  /**
108
212
  * A start/end pair well in the future, so a smoke event never collides with
109
213
  * anything real on the calendar.
@@ -146,7 +250,7 @@ async function main() {
146
250
  console.log("apple-tools-mcp write smoke test");
147
251
  console.log("=".repeat(60));
148
252
  line("Mode:", apply
149
- ? "APPLY (will change Contacts and Calendar)"
253
+ ? "APPLY (will change Contacts and Calendar; Mail compose is live, nothing is sent)"
150
254
  : "DRY RUN (no creates, edits, or deletes)");
151
255
  line("Process:", indexerMode ? "indexer daemon" : "plain node / stdio");
152
256
  line("Write bridge:", bridgeUp ? `listening at ${socketPath}` : `not listening (${socketPath})`);
@@ -162,13 +266,18 @@ async function main() {
162
266
  "\nTCC note: macOS attributes this work to the process responsible for it.\n" +
163
267
  "Contact reads use sqlite + Full Disk Access; the CRUD steps use Contacts.app\n" +
164
268
  "and Calendar.app, gated by the AddressBook and calendars privacy classes.\n" +
269
+ "Mail compose is a separate Automation target (node → Mail.app). A hang or\n" +
270
+ "timeout there is TCC / Automation denied, not Mail.app missing.\n" +
165
271
  `Writes take the same route production MCP clients take: ${route.reason}.`
166
272
  );
167
273
  if (!apply) {
168
274
  console.log(
169
- "Dry run: nothing is created, edited, or deleted. calendar_list_calendars\n" +
275
+ "Dry run: no creates, edits, or deletes for Contacts/Calendar. calendar_list_calendars\n" +
170
276
  "still runs for real - it is a live Calendar.app query and a TCC touch -\n" +
171
- "so a denial there is reported as a warning, not a failure."
277
+ "so a denial there is reported as a warning, not a failure.\n" +
278
+ "The Mail step also runs for real (make new outgoing message) because mail_send\n" +
279
+ "dry_run never touches Mail. A Mail deny is TCC / Automation denied; on a dry\n" +
280
+ "run it is a warning, on --apply it fails the ship gate."
172
281
  );
173
282
  }
174
283
 
@@ -192,7 +301,44 @@ async function main() {
192
301
  const common = apply ? { confirm: true } : { dry_run: true };
193
302
  const results = [];
194
303
 
304
+ // Mail first so first-run Allow includes Mail in the same pass as Contacts/Calendar.
305
+ // Helpers are smoke-script-only — not MCP write tools. mail_send dry_run never talks to Mail.
306
+ console.log("\n--- Mail Automation (Mail.app compose / Apple Events) ---");
307
+ if (!apply) {
308
+ console.log(" (live make new outgoing message even on a dry run; mail_send dry_run never touches Mail)");
309
+ }
310
+
311
+ const mailPlan = planMailSmokeTouch({ apply, daemonPath: route.path === "daemon" });
312
+ const mailSeverity = mailProbeSeverity(apply);
313
+ const mailHelperResult = probeMailAutomation();
314
+ if (mailHelperResult.ok === false && mailSeverity === "warning") {
315
+ step("mail Automation compose", mailHelperResult, "warning");
316
+ console.log(" Warning only: mail_send dry_run never touches Mail. --apply fails closed if Mail Automation is still denied.");
317
+ } else {
318
+ results.push(step("mail Automation compose", mailHelperResult, mailSeverity));
319
+ }
320
+ if (mailPlan.useMailDraft) {
321
+ console.log(` ${mailPlan.reason}`);
322
+ results.push(step("mail_draft (daemon Mail Automation)", await run("mail_draft", mailDraftSmokeArgs(stamp))));
323
+ }
324
+
325
+ console.log("\n--- Messages Automation (Messages.app accounts / Apple Events) ---");
326
+ if (!apply) {
327
+ console.log(" (live Messages account lookup even on a dry run; messages_send dry_run never touches Messages; nothing is sent)");
328
+ }
329
+
330
+ const messagesSeverity = messagesProbeSeverity(apply);
331
+ const messagesResult = probeMessagesAutomation();
332
+
333
+ if (messagesResult.ok === false && messagesSeverity === "warning") {
334
+ step("messages Automation lookup", messagesResult, "warning");
335
+ console.log(" Warning only: messages_send dry_run never touches Messages. --apply fails closed if Messages Automation is still denied.");
336
+ } else {
337
+ results.push(step("messages Automation lookup", messagesResult, messagesSeverity));
338
+ }
339
+
195
340
  console.log("\n--- Contacts CRUD (Contacts.app / AddressBook privacy class) ---");
341
+ console.log(" Keep Contacts, Mail, and Messages running for write reliability. Calendar does not need to stay open (EventKit).");
196
342
  const created = step("contacts_add", await run("contacts_add", {
197
343
  first_name: "ATM Smoke",
198
344
  last_name: `Test ${stamp}`,
@@ -240,11 +386,12 @@ async function main() {
240
386
  }
241
387
 
242
388
  const writable = (calendars.calendars || []).filter((c) => c.writable);
243
- const targetCalendar = calendar || (writable[0] && writable[0].name) || "Calendar";
389
+ const picked = pickSmokeCalendar(calendars.calendars || [], calendar);
390
+ const targetCalendar = picked.name;
244
391
  if (calendar && writable.length > 0 && !writable.some((c) => c.name === calendar)) {
245
392
  console.log(` Warning: --calendar=${calendar} is not in the writable list; trying it anyway.`);
246
393
  }
247
- line(" Target calendar:", targetCalendar);
394
+ line(" Target calendar:", `${targetCalendar} (${picked.reason})`);
248
395
 
249
396
  const window = smokeEventWindow();
250
397
  const eventCreated = step("calendar_add", await run("calendar_add", {
@@ -260,12 +407,19 @@ async function main() {
260
407
 
261
408
  if (eventCreated.ok !== false) {
262
409
  const eventId = apply ? extractEventId(eventCreated.message) : "ATM-SMOKE-EVENT-UID";
263
- if (apply && !eventId) {
410
+ const extractedKitId = apply ? extractEventKitId(eventCreated.message) : "EK-SMOKE";
411
+ const eventKitId = extractedKitId || (eventId && eventId.includes(":") ? eventId : null);
412
+ if (apply && !createdViaEventKit(eventCreated.message)) {
413
+ console.log(" calendar_add did not print via: EventKit and eventkit_id.");
414
+ console.log(" Ship-gate refuses a Calendar.app-only create: writeOnly EventKit cannot delete those events, and Calendar.app delete hangs.");
415
+ results.push({ ok: false, message: "calendar_add ship-gate requires via: EventKit and eventkit_id" });
416
+ } else if (apply && !eventId) {
264
417
  console.log(" calendar_add reported success but returned no event id; skipping edit/delete.");
265
418
  results.push({ ok: false });
266
419
  } else {
267
420
  results.push(step("calendar_edit", await run("calendar_edit", {
268
421
  event_id: eventId,
422
+ eventkit_id: eventKitId || undefined,
269
423
  title: `ATM smoke test ${stamp} (edited)`,
270
424
  ...common
271
425
  })));
@@ -273,10 +427,25 @@ async function main() {
273
427
  if (apply && keep) {
274
428
  console.log(` Left event ${eventId} in place (--keep). Delete it when you are done.`);
275
429
  } else {
276
- const removed = step("calendar_remove", await run("calendar_remove", { event_id: eventId, ...common }));
430
+ const removed = step("calendar_remove", await run("calendar_remove", {
431
+ event_id: eventId,
432
+ eventkit_id: eventKitId || undefined,
433
+ calendar_name: targetCalendar,
434
+ ...common
435
+ }));
277
436
  results.push(removed);
278
437
  if (apply && removed.ok === false) {
279
- console.log(` Created ${eventId} but could not delete it. Remove it in Calendar.app.`);
438
+ console.log(` Created ${eventId} on ${targetCalendar} but could not delete it.`);
439
+ console.log(" add/edit succeeded, so this is a delete-path failure, not missing Calendar Automation.");
440
+ console.log(" Same write-bridge RPC as add/edit; Calendar.app has no remove/move-to-trash (delete + EventKit fallback).");
441
+ if (removed.diagnostics) {
442
+ console.log(` ${removed.diagnostics}`);
443
+ } else {
444
+ const rawLine = String(removed.message || "").match(/osascript kind=\S+ error=.*/);
445
+ if (rawLine) console.log(` ${rawLine[0]}`);
446
+ }
447
+ console.log(" Paste the osascript kind=/error= line if asking for another tip.");
448
+ console.log(" Remove the leftover event in Calendar.app.");
280
449
  }
281
450
  }
282
451
  }
@@ -293,14 +462,16 @@ async function main() {
293
462
  } else {
294
463
  console.log("These writes ran inside the indexer daemon, so this is a real gate failure:");
295
464
  console.log("grant the daemon's node binary Full Disk Access (reads) and Allow node in");
296
- console.log("System Settings > Privacy & Security > Automation for Contacts.app and");
297
- console.log("Calendar.app. Do not add node via + in the Contacts or Calendars privacy lists.");
465
+ console.log("System Settings > Privacy & Security > Automation for Mail.app, Messages.app,");
466
+ console.log("Contacts.app, and Calendar.app. A hang or timeout on Mail compose is TCC /");
467
+ console.log("Automation denied, not Mail.app missing. Do not add node via + in the");
468
+ console.log("Contacts or Calendars privacy lists.");
298
469
  }
299
470
  process.exitCode = 1;
300
471
  } else if (apply) {
301
- console.log(`Result: PASS - Contacts and Calendar CRUD both work (${route.path === "daemon" ? "via the write bridge" : "in this process"}).`);
472
+ console.log(`Result: PASS - Mail, Messages, Contacts, and Calendar Automation all work (${route.path === "daemon" ? "via the write bridge" : "in this process"}).`);
302
473
  } else {
303
- console.log("Result: PASS - dry run only. Re-run with --apply to prove real CRUD.");
474
+ console.log("Result: PASS - dry run only. Re-run with --apply to prove real CRUD and fail-closed Mail/Messages/Contacts/Calendar Automation.");
304
475
  }
305
476
  }
306
477