apple-notes-mcp 2.5.0 → 2.5.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.
@@ -2056,10 +2056,11 @@ export class AppleNotesManager {
2056
2056
  */
2057
2057
  listAttachments(title, account) {
2058
2058
  const targetAccount = this.resolveAccount(account);
2059
+ const safeAccount = escapePlainStringForAppleScript(targetAccount);
2059
2060
  const safeTitle = escapePlainStringForAppleScript(title);
2060
2061
  const script = `
2061
2062
  tell application "Notes"
2062
- tell account "${targetAccount}"
2063
+ tell account "${safeAccount}"
2063
2064
  set theNote to note "${safeTitle}"
2064
2065
  set attachmentList to {}
2065
2066
  repeat with a in attachments of theNote
@@ -2435,19 +2436,21 @@ export class AppleNotesManager {
2435
2436
  * Simple HTML to plaintext conversion for export.
2436
2437
  */
2437
2438
  htmlToPlaintext(html) {
2438
- return html
2439
+ return (html
2439
2440
  .replace(/<br\s*\/?>/gi, "\n")
2440
2441
  .replace(/<\/div>/gi, "\n")
2441
2442
  .replace(/<\/p>/gi, "\n")
2442
2443
  .replace(/<[^>]+>/g, "")
2443
2444
  .replace(/&nbsp;/g, " ")
2444
- .replace(/&amp;/g, "&")
2445
2445
  .replace(/&lt;/g, "<")
2446
2446
  .replace(/&gt;/g, ">")
2447
2447
  .replace(/&quot;/g, '"')
2448
2448
  .replace(/&#92;/g, "\\")
2449
+ // Decode &amp; LAST so an encoded entity like "&amp;lt;" round-trips to the
2450
+ // literal "&lt;" instead of being double-unescaped to "<".
2451
+ .replace(/&amp;/g, "&")
2449
2452
  .replace(/\n{3,}/g, "\n\n")
2450
- .trim();
2453
+ .trim());
2451
2454
  }
2452
2455
  /**
2453
2456
  * Exports all notes as a JSON structure for backup/migration.
@@ -315,6 +315,17 @@ describe("AppleNotesManager", () => {
315
315
  manager = new AppleNotesManager();
316
316
  vi.clearAllMocks();
317
317
  });
318
+ describe("listAttachments — security", () => {
319
+ it("escapes the account name so it cannot break out of the AppleScript literal (injection regression)", () => {
320
+ mockExecuteAppleScript.mockReturnValue({ success: true, output: "" });
321
+ manager.listAttachments("My Note", 'evil" injected');
322
+ const script = String(mockExecuteAppleScript.mock.calls.at(-1)?.[0]);
323
+ // The account's double-quote must be escaped (\\") — a raw quote would
324
+ // terminate the tell-account string literal and allow `do shell script` injection.
325
+ expect(script).toContain('tell account "evil\\" injected"');
326
+ expect(script).not.toContain('tell account "evil" injected"');
327
+ });
328
+ });
318
329
  // ---------------------------------------------------------------------------
319
330
  // Note Creation
320
331
  // ---------------------------------------------------------------------------
@@ -2362,3 +2373,33 @@ describe("AppleNotesManager", () => {
2362
2373
  });
2363
2374
  });
2364
2375
  });
2376
+ describe("htmlToPlaintext (export helper)", () => {
2377
+ // htmlToPlaintext is a private, pure string transform used by exportNote; it
2378
+ // touches no AppleScript, so we exercise it directly through a cast.
2379
+ const toPlaintext = (html) => new AppleNotesManager().htmlToPlaintext(html);
2380
+ it("decodes the basic HTML entities", () => {
2381
+ expect(toPlaintext("a &amp; b")).toBe("a & b");
2382
+ expect(toPlaintext("&lt;tag&gt;")).toBe("<tag>");
2383
+ expect(toPlaintext("say &quot;hi&quot;")).toBe('say "hi"');
2384
+ expect(toPlaintext("path&#92;file")).toBe("path\\file");
2385
+ expect(toPlaintext("a&nbsp;b")).toBe("a b");
2386
+ });
2387
+ it("decodes &amp; last so encoded entities round-trip (no double-unescape)", () => {
2388
+ // The literal text "&lt;" is stored in HTML as "&amp;lt;" and must decode
2389
+ // back to "&lt;", NOT be double-unescaped to "<".
2390
+ expect(toPlaintext("&amp;lt;")).toBe("&lt;");
2391
+ expect(toPlaintext("&amp;gt;")).toBe("&gt;");
2392
+ expect(toPlaintext("&amp;amp;")).toBe("&amp;");
2393
+ expect(toPlaintext("&amp;nbsp;")).toBe("&nbsp;");
2394
+ });
2395
+ it("converts block/line tags to newlines and strips other tags", () => {
2396
+ expect(toPlaintext("one<br>two")).toBe("one\ntwo");
2397
+ expect(toPlaintext("<div>a</div><div>b</div>")).toBe("a\nb");
2398
+ expect(toPlaintext("<p>x</p><p>y</p>")).toBe("x\ny");
2399
+ expect(toPlaintext("<b>bold</b>")).toBe("bold");
2400
+ });
2401
+ it("collapses 3+ newlines and trims surrounding whitespace", () => {
2402
+ expect(toPlaintext("a<br><br><br><br>b")).toBe("a\n\nb");
2403
+ expect(toPlaintext(" <div>x</div> ")).toBe("x");
2404
+ });
2405
+ });
@@ -24,7 +24,7 @@
24
24
  * @module utils/checklistParser
25
25
  * @see https://github.com/sweetrb/apple-notes-mcp/issues/2
26
26
  */
27
- import { execSync } from "child_process";
27
+ import { execFileSync } from "child_process";
28
28
  import * as zlib from "zlib";
29
29
  import * as fs from "fs";
30
30
  import * as path from "path";
@@ -43,7 +43,7 @@ export function hasFullDiskAccess() {
43
43
  if (!fs.existsSync(NOTES_DB_PATH))
44
44
  return false;
45
45
  // Try to open the database with a simple query
46
- execSync(`sqlite3 -readonly "${NOTES_DB_PATH}" "SELECT 1;"`, {
46
+ execFileSync("sqlite3", ["-readonly", NOTES_DB_PATH, "SELECT 1;"], {
47
47
  encoding: "utf8",
48
48
  timeout: 3000,
49
49
  stdio: ["pipe", "pipe", "pipe"],
@@ -75,7 +75,7 @@ function queryNoteData(noteId) {
75
75
  // Query for the gzipped protobuf data, output as hex for safe transport
76
76
  const query = `SELECT hex(nd.ZDATA) FROM ZICNOTEDATA nd JOIN ZICCLOUDSYNCINGOBJECT n ON nd.ZNOTE = n.Z_PK WHERE n.Z_PK = ${pk};`;
77
77
  try {
78
- const result = execSync(`sqlite3 -readonly "${NOTES_DB_PATH}" "${query}"`, {
78
+ const result = execFileSync("sqlite3", ["-readonly", NOTES_DB_PATH, query], {
79
79
  encoding: "utf8",
80
80
  timeout: 5000,
81
81
  stdio: ["pipe", "pipe", "pipe"],
@@ -9,7 +9,7 @@ import * as zlib from "zlib";
9
9
  import { getChecklistItems, hasFullDiskAccess } from "./checklistParser.js";
10
10
  // Mock child_process to avoid actual database access
11
11
  vi.mock("child_process", () => ({
12
- execSync: vi.fn(),
12
+ execFileSync: vi.fn(),
13
13
  spawnSync: vi.fn(() => ({ error: null })),
14
14
  }));
15
15
  // Mock fs for database existence checks
@@ -17,9 +17,9 @@ vi.mock("fs", () => ({
17
17
  existsSync: vi.fn(() => true),
18
18
  statSync: vi.fn(() => ({ mtimeMs: Date.now() })),
19
19
  }));
20
- import { execSync } from "child_process";
20
+ import { execFileSync } from "child_process";
21
21
  import { existsSync } from "fs";
22
- const mockExecSync = vi.mocked(execSync);
22
+ const mockExecSync = vi.mocked(execFileSync);
23
23
  const mockExistsSync = vi.mocked(existsSync);
24
24
  /**
25
25
  * Builds a minimal Apple Notes protobuf structure with checklist items.
@@ -225,6 +225,6 @@ describe("getChecklistItems", () => {
225
225
  it("extracts correct primary key from note ID", () => {
226
226
  mockExecSync.mockReturnValue("");
227
227
  getChecklistItems("x-coredata://12345-ABCDE/ICNote/p42");
228
- expect(mockExecSync).toHaveBeenCalledWith(expect.stringContaining("Z_PK = 42"), expect.any(Object));
228
+ expect(mockExecSync).toHaveBeenCalledWith("sqlite3", expect.arrayContaining([expect.stringContaining("Z_PK = 42")]), expect.any(Object));
229
229
  });
230
230
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "apple-notes-mcp",
3
- "version": "2.5.0",
3
+ "version": "2.5.2",
4
4
  "description": "MCP server for Apple Notes - create, search, update, and manage notes via Claude and other AI assistants",
5
5
  "type": "module",
6
6
  "main": "build/index.js",
@@ -68,7 +68,7 @@
68
68
  "@types/turndown": "^5.0.6",
69
69
  "@typescript-eslint/eslint-plugin": "^8.0.0",
70
70
  "@typescript-eslint/parser": "^8.0.0",
71
- "@vitest/coverage-v8": "^2.1.9",
71
+ "@vitest/coverage-v8": "^3.2.6",
72
72
  "eslint": "^9.0.0",
73
73
  "globals": "^17.0.0",
74
74
  "husky": "^9.1.7",
@@ -78,7 +78,7 @@
78
78
  "tsconfig-paths": "^4.2.0",
79
79
  "typescript": "^5.0.0",
80
80
  "typescript-eslint": "^8.51.0",
81
- "vitest": "^2.0.0"
81
+ "vitest": "^3.2.6"
82
82
  },
83
83
  "volta": {
84
84
  "node": "24.17.0"