ソースを参照

Merge pull request #6 from sweetrb/fix/security-hardening

fix: security hardening — input validation, path traversal, batch limits
Rob Sweet 3 ヶ月 前
コミット
06b91f14be
12 ファイル変更910 行追加186 行削除
  1. 15 0
      CHANGELOG.md
  2. 3 2
      CLAUDE.md
  3. 13 1
      CONTRIBUTING.md
  4. 21 14
      README.md
  5. 22 0
      SECURITY.md
  6. 221 137
      package-lock.json
  7. 2 0
      package.json
  8. 45 19
      src/index.ts
  9. 245 0
      src/security.test.ts
  10. 36 13
      src/services/appleMailManager.ts
  11. 271 0
      test/integration.test.ts
  12. 16 0
      vitest.integration.config.ts

+ 15 - 0
CHANGELOG.md

@@ -5,6 +5,21 @@ All notable changes to this project will be documented in this file.
 The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
 and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
 
+## [Unreleased]
+
+### Security
+- **Message ID validation** - Message IDs are now validated as numeric-only (`/^\d+$/`) to prevent injection attacks
+- **Batch size cap** - Batch operations are limited to a maximum of 100 messages per request
+- **Date filter validation** - Date filters are validated to allow only alphanumeric characters and safe punctuation; an additional belt-and-suspenders `escapeForAppleScript()` call is applied before interpolation
+- **Attachment save path traversal prevention** - `save-attachment` uses `path.resolve` and restricts save paths to the user's home directory, `/tmp`, `/private/tmp`, and `/Volumes`; attachment names containing `/`, `\`, null bytes, or `..` are rejected
+- **Defense-in-depth ID coercion** - All AppleScript message ID interpolations now use `Number(id)` as an extra safeguard
+- **Attachment count limit** - `send-email` and `create-draft` enforce a maximum of 20 file attachments
+
+### Added
+- **Security test suite** - `src/security.test.ts` with unit tests for all input validation schemas and path traversal prevention
+- **Integration test suite** - `test/integration.test.ts` for live Mail.app testing
+- **New npm scripts** - `test:integration` and `test:all` for running integration and combined test suites
+
 ## [1.2.0] - 2026-03-14
 
 ### Added

+ 3 - 2
CLAUDE.md

@@ -92,7 +92,7 @@ The `to`, `cc`, and `bcc` parameters must always be arrays:
 - Sends individual personalized emails to a list of recipients
 - Use `{{placeholder}}` tokens in subject and body, replaced per-recipient
 - Each recipient gets their own email — recipients don't see each other
-- Max 100 recipients per batch, delay between sends (default 500ms, max 10s)
+- Max 100 recipients per batch, delay between sends (default 500ms, max 10000ms)
 - Example variables: `{ "Name": "Alice", "Company": "Acme" }`
 
 ### reply-to-message
@@ -187,6 +187,7 @@ The `to`, `cc`, and `bcc` parameters must always be arrays:
    batch-flag-messages ids=["123", "456"] → flag multiple
    OR
    batch-unflag-messages ids=["123", "456"] → unflag multiple
+   Note: all batch operations are limited to 100 messages per request
 ```
 
 ### Check for attachments
@@ -237,7 +238,7 @@ The `to`, `cc`, and `bcc` parameters must always be arrays:
 1. send-email to=["colleague@company.com"] subject="Report" body="See attached." attachments=["/Users/me/report.pdf"]
    OR to let the user review first:
 2. create-draft to=["colleague@company.com"] subject="Report" body="See attached." attachments=["/Users/me/report.pdf"]
-   Note: attachment paths must be absolute and the files must exist
+   Note: attachment paths must be absolute and the files must exist; max 20 files per message
 ```
 
 ### Send personalized emails (mail merge)

+ 13 - 1
CONTRIBUTING.md

@@ -48,18 +48,30 @@ npm run format:check
 All new features should include tests. We use Vitest for testing.
 
 ```bash
-# Run tests once
+# Run unit tests
 npm test
 
 # Run tests in watch mode
 npm run test:watch
+
+# Run integration tests (requires macOS with Mail.app configured)
+npm run test:integration
+
+# Run all tests (unit + integration)
+npm run test:all
 ```
 
+### Test File Locations
+
+- **Unit tests:** `src/services/appleMailManager.test.ts` (core logic), `src/security.test.ts` (input validation and security schemas)
+- **Integration tests:** `test/integration.test.ts` (live Mail.app interaction)
+
 ### Testing Guidelines
 
 - Tests mock the `executeAppleScript` function since AppleScript only works on macOS
 - Test both success and failure paths
 - Test edge cases (empty strings, special characters, etc.)
+- Security-sensitive changes should include tests in `src/security.test.ts`
 
 ## Pull Request Process
 

+ 21 - 14
README.md

@@ -191,7 +191,7 @@ Send a new email immediately.
 | `cc` | string[] | No | CC recipients |
 | `bcc` | string[] | No | BCC recipients |
 | `account` | string | No | Send from specific account |
-| `attachments` | string[] | No | Absolute file paths to attach (e.g., `["/Users/me/report.pdf"]`) |
+| `attachments` | string[] | No | Absolute file paths to attach, max 20 files (e.g., `["/Users/me/report.pdf"]`) |
 
 **Example:**
 ```json
@@ -212,11 +212,11 @@ Send individual personalized emails to a list of recipients (mail merge). Each r
 
 | Parameter | Type | Required | Description |
 |-----------|------|----------|-------------|
-| `recipients` | object[] | Yes | List of recipients (see below) |
+| `recipients` | object[] | Yes | List of recipients, max 100 (see below) |
 | `subject` | string | Yes | Email subject — use `{{Key}}` for placeholders |
 | `body` | string | Yes | Email body — use `{{Key}}` for placeholders |
 | `account` | string | No | Send from specific account |
-| `delayMs` | number | No | Delay between sends in ms (default: 500) |
+| `delayMs` | number | No | Delay between sends in ms (default: 500, max 10000) |
 
 Each recipient object:
 
@@ -253,7 +253,7 @@ Save an email to Drafts without sending.
 | `cc` | string[] | No | CC recipients |
 | `bcc` | string[] | No | BCC recipients |
 | `account` | string | No | Account for draft |
-| `attachments` | string[] | No | Absolute file paths to attach |
+| `attachments` | string[] | No | Absolute file paths to attach, max 20 files |
 
 **Returns:** Confirmation that draft was created.
 
@@ -371,19 +371,19 @@ Save a message attachment to disk.
 
 ### Batch Operations
 
-All batch operations accept an array of message IDs and return per-item success/failure results.
+All batch operations accept an array of message IDs (max 100 per batch) and return per-item success/failure results.
 
 #### `batch-delete-messages`
 
 | Parameter | Type | Required | Description |
 |-----------|------|----------|-------------|
-| `ids` | string[] | Yes | Message IDs to delete |
+| `ids` | string[] | Yes | Message IDs to delete (max 100) |
 
 #### `batch-move-messages`
 
 | Parameter | Type | Required | Description |
 |-----------|------|----------|-------------|
-| `ids` | string[] | Yes | Message IDs to move |
+| `ids` | string[] | Yes | Message IDs to move (max 100) |
 | `mailbox` | string | Yes | Destination mailbox |
 | `account` | string | No | Account containing mailbox |
 
@@ -391,13 +391,13 @@ All batch operations accept an array of message IDs and return per-item success/
 
 | Parameter | Type | Required | Description |
 |-----------|------|----------|-------------|
-| `ids` | string[] | Yes | Message IDs |
+| `ids` | string[] | Yes | Message IDs (max 100) |
 
 #### `batch-flag-messages` / `batch-unflag-messages`
 
 | Parameter | Type | Required | Description |
 |-----------|------|----------|-------------|
-| `ids` | string[] | Yes | Message IDs |
+| `ids` | string[] | Yes | Message IDs (max 100) |
 
 ---
 
@@ -718,6 +718,11 @@ If installed from source, use this configuration:
 | Attachments require absolute paths | File attachments must use full absolute paths (e.g., `/Users/me/file.pdf`) |
 | No smart mailboxes | Cannot access Smart Mailboxes via AppleScript |
 | In-memory templates | Email templates are not persisted across server restarts |
+| Numeric-only message IDs | Message IDs must contain only digits (validated by schema) |
+| Batch size cap | Batch operations are limited to 100 messages per request |
+| Date filter format | Date filters accept alphanumeric characters and safe punctuation only |
+| Attachment save path restrictions | `save-attachment` only allows saving to home directory, `/tmp`, `/private/tmp`, and `/Volumes`; path traversal is blocked |
+| Attachment count limit | `send-email` and `create-draft` accept a maximum of 20 file attachments |
 
 ### Backslash Escaping (Important for AI Agents)
 
@@ -776,11 +781,13 @@ The `\\\\` in JSON becomes `\\` in the actual string, which represents a single
 ## Development
 
 ```bash
-npm install      # Install dependencies
-npm run build    # Compile TypeScript
-npm test         # Run test suite (28 tests)
-npm run lint     # Check code style
-npm run format   # Format code
+npm install            # Install dependencies
+npm run build          # Compile TypeScript
+npm test               # Run unit tests
+npm run test:integration  # Run integration tests (requires Mail.app)
+npm run test:all       # Run all tests (unit + integration)
+npm run lint           # Check code style
+npm run format         # Format code
 ```
 
 ---

+ 22 - 0
SECURITY.md

@@ -31,6 +31,28 @@ This MCP server:
 
 The server requires macOS automation permissions to function. These permissions are managed by macOS and can be revoked at any time in System Preferences > Privacy & Security > Automation.
 
+## Input Validation & Security Hardening
+
+The server enforces multiple layers of input validation to prevent injection and abuse:
+
+### Message ID Validation
+All message IDs are validated against a numeric-only schema (`/^\d+$/`). Non-numeric IDs are rejected before reaching AppleScript. As a defense-in-depth measure, all ID values are also coerced through `Number(id)` at every AppleScript interpolation point.
+
+### Batch Operation Limits
+Batch operations (`batch-delete-messages`, `batch-move-messages`, `batch-mark-as-read`, `batch-mark-as-unread`, `batch-flag-messages`, `batch-unflag-messages`) are capped at 100 messages per request to prevent resource exhaustion.
+
+### Date Filter Validation
+Date filter parameters (`dateFrom`, `dateTo`) are validated to accept only alphanumeric characters and safe punctuation (spaces, commas, slashes, hyphens, colons, periods). An additional `escapeForAppleScript()` call is applied as a belt-and-suspenders safeguard before any date string is interpolated into AppleScript.
+
+### Attachment Save Path Restrictions
+The `save-attachment` tool prevents path traversal attacks:
+- Save paths are resolved to absolute paths using `path.resolve`
+- Only paths within the user's home directory, `/tmp`, `/private/tmp`, and `/Volumes` are allowed
+- Attachment filenames containing `/`, `\`, null bytes (`\0`), or `..` are rejected
+
+### Attachment Count Limits
+The `send-email` and `create-draft` tools accept a maximum of 20 file attachments per message. The `send-serial-email` tool enforces a maximum of 100 recipients per batch and a maximum inter-send delay of 10,000ms.
+
 ## Email Security Best Practices
 
 When using this server with AI assistants:

+ 221 - 137
package-lock.json

@@ -545,9 +545,9 @@
       }
     },
     "node_modules/@eslint/config-array/node_modules/brace-expansion": {
-      "version": "1.1.12",
-      "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz",
-      "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==",
+      "version": "1.1.13",
+      "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.13.tgz",
+      "integrity": "sha512-9ZLprWS6EENmhEOpjCYW2c8VkmOvckIJZfkr7rBW6dObmfgJ/L1GpSYW5Hpo9lDz4D1+n0Ckz8rU7FwHDQiG/w==",
       "dev": true,
       "license": "MIT",
       "dependencies": {
@@ -556,9 +556,9 @@
       }
     },
     "node_modules/@eslint/config-array/node_modules/minimatch": {
-      "version": "3.1.2",
-      "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz",
-      "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==",
+      "version": "3.1.5",
+      "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz",
+      "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==",
       "dev": true,
       "license": "ISC",
       "dependencies": {
@@ -619,9 +619,9 @@
       }
     },
     "node_modules/@eslint/eslintrc/node_modules/brace-expansion": {
-      "version": "1.1.12",
-      "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz",
-      "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==",
+      "version": "1.1.13",
+      "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.13.tgz",
+      "integrity": "sha512-9ZLprWS6EENmhEOpjCYW2c8VkmOvckIJZfkr7rBW6dObmfgJ/L1GpSYW5Hpo9lDz4D1+n0Ckz8rU7FwHDQiG/w==",
       "dev": true,
       "license": "MIT",
       "dependencies": {
@@ -653,9 +653,9 @@
       }
     },
     "node_modules/@eslint/eslintrc/node_modules/minimatch": {
-      "version": "3.1.2",
-      "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz",
-      "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==",
+      "version": "3.1.5",
+      "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz",
+      "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==",
       "dev": true,
       "license": "ISC",
       "dependencies": {
@@ -943,9 +943,9 @@
       }
     },
     "node_modules/@rollup/rollup-android-arm-eabi": {
-      "version": "4.54.0",
-      "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.54.0.tgz",
-      "integrity": "sha512-OywsdRHrFvCdvsewAInDKCNyR3laPA2mc9bRYJ6LBp5IyvF3fvXbbNR0bSzHlZVFtn6E0xw2oZlyjg4rKCVcng==",
+      "version": "4.60.0",
+      "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.60.0.tgz",
+      "integrity": "sha512-WOhNW9K8bR3kf4zLxbfg6Pxu2ybOUbB2AjMDHSQx86LIF4rH4Ft7vmMwNt0loO0eonglSNy4cpD3MKXXKQu0/A==",
       "cpu": [
         "arm"
       ],
@@ -957,9 +957,9 @@
       ]
     },
     "node_modules/@rollup/rollup-android-arm64": {
-      "version": "4.54.0",
-      "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.54.0.tgz",
-      "integrity": "sha512-Skx39Uv+u7H224Af+bDgNinitlmHyQX1K/atIA32JP3JQw6hVODX5tkbi2zof/E69M1qH2UoN3Xdxgs90mmNYw==",
+      "version": "4.60.0",
+      "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.60.0.tgz",
+      "integrity": "sha512-u6JHLll5QKRvjciE78bQXDmqRqNs5M/3GVqZeMwvmjaNODJih/WIrJlFVEihvV0MiYFmd+ZyPr9wxOVbPAG2Iw==",
       "cpu": [
         "arm64"
       ],
@@ -971,9 +971,9 @@
       ]
     },
     "node_modules/@rollup/rollup-darwin-arm64": {
-      "version": "4.54.0",
-      "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.54.0.tgz",
-      "integrity": "sha512-k43D4qta/+6Fq+nCDhhv9yP2HdeKeP56QrUUTW7E6PhZP1US6NDqpJj4MY0jBHlJivVJD5P8NxrjuobZBJTCRw==",
+      "version": "4.60.0",
+      "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.60.0.tgz",
+      "integrity": "sha512-qEF7CsKKzSRc20Ciu2Zw1wRrBz4g56F7r/vRwY430UPp/nt1x21Q/fpJ9N5l47WWvJlkNCPJz3QRVw008fi7yA==",
       "cpu": [
         "arm64"
       ],
@@ -985,9 +985,9 @@
       ]
     },
     "node_modules/@rollup/rollup-darwin-x64": {
-      "version": "4.54.0",
-      "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.54.0.tgz",
-      "integrity": "sha512-cOo7biqwkpawslEfox5Vs8/qj83M/aZCSSNIWpVzfU2CYHa2G3P1UN5WF01RdTHSgCkri7XOlTdtk17BezlV3A==",
+      "version": "4.60.0",
+      "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.60.0.tgz",
+      "integrity": "sha512-WADYozJ4QCnXCH4wPB+3FuGmDPoFseVCUrANmA5LWwGmC6FL14BWC7pcq+FstOZv3baGX65tZ378uT6WG8ynTw==",
       "cpu": [
         "x64"
       ],
@@ -999,9 +999,9 @@
       ]
     },
     "node_modules/@rollup/rollup-freebsd-arm64": {
-      "version": "4.54.0",
-      "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.54.0.tgz",
-      "integrity": "sha512-miSvuFkmvFbgJ1BevMa4CPCFt5MPGw094knM64W9I0giUIMMmRYcGW/JWZDriaw/k1kOBtsWh1z6nIFV1vPNtA==",
+      "version": "4.60.0",
+      "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.60.0.tgz",
+      "integrity": "sha512-6b8wGHJlDrGeSE3aH5mGNHBjA0TTkxdoNHik5EkvPHCt351XnigA4pS7Wsj/Eo9Y8RBU6f35cjN9SYmCFBtzxw==",
       "cpu": [
         "arm64"
       ],
@@ -1013,9 +1013,9 @@
       ]
     },
     "node_modules/@rollup/rollup-freebsd-x64": {
-      "version": "4.54.0",
-      "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.54.0.tgz",
-      "integrity": "sha512-KGXIs55+b/ZfZsq9aR026tmr/+7tq6VG6MsnrvF4H8VhwflTIuYh+LFUlIsRdQSgrgmtM3fVATzEAj4hBQlaqQ==",
+      "version": "4.60.0",
+      "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.60.0.tgz",
+      "integrity": "sha512-h25Ga0t4jaylMB8M/JKAyrvvfxGRjnPQIR8lnCayyzEjEOx2EJIlIiMbhpWxDRKGKF8jbNH01NnN663dH638mA==",
       "cpu": [
         "x64"
       ],
@@ -1027,13 +1027,16 @@
       ]
     },
     "node_modules/@rollup/rollup-linux-arm-gnueabihf": {
-      "version": "4.54.0",
-      "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.54.0.tgz",
-      "integrity": "sha512-EHMUcDwhtdRGlXZsGSIuXSYwD5kOT9NVnx9sqzYiwAc91wfYOE1g1djOEDseZJKKqtHAHGwnGPQu3kytmfaXLQ==",
+      "version": "4.60.0",
+      "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.60.0.tgz",
+      "integrity": "sha512-RzeBwv0B3qtVBWtcuABtSuCzToo2IEAIQrcyB/b2zMvBWVbjo8bZDjACUpnaafaxhTw2W+imQbP2BD1usasK4g==",
       "cpu": [
         "arm"
       ],
       "dev": true,
+      "libc": [
+        "glibc"
+      ],
       "license": "MIT",
       "optional": true,
       "os": [
@@ -1041,13 +1044,16 @@
       ]
     },
     "node_modules/@rollup/rollup-linux-arm-musleabihf": {
-      "version": "4.54.0",
-      "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.54.0.tgz",
-      "integrity": "sha512-+pBrqEjaakN2ySv5RVrj/qLytYhPKEUwk+e3SFU5jTLHIcAtqh2rLrd/OkbNuHJpsBgxsD8ccJt5ga/SeG0JmA==",
+      "version": "4.60.0",
+      "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.60.0.tgz",
+      "integrity": "sha512-Sf7zusNI2CIU1HLzuu9Tc5YGAHEZs5Lu7N1ssJG4Tkw6e0MEsN7NdjUDDfGNHy2IU+ENyWT+L2obgWiguWibWQ==",
       "cpu": [
         "arm"
       ],
       "dev": true,
+      "libc": [
+        "musl"
+      ],
       "license": "MIT",
       "optional": true,
       "os": [
@@ -1055,13 +1061,16 @@
       ]
     },
     "node_modules/@rollup/rollup-linux-arm64-gnu": {
-      "version": "4.54.0",
-      "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.54.0.tgz",
-      "integrity": "sha512-NSqc7rE9wuUaRBsBp5ckQ5CVz5aIRKCwsoa6WMF7G01sX3/qHUw/z4pv+D+ahL1EIKy6Enpcnz1RY8pf7bjwng==",
+      "version": "4.60.0",
+      "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.60.0.tgz",
+      "integrity": "sha512-DX2x7CMcrJzsE91q7/O02IJQ5/aLkVtYFryqCjduJhUfGKG6yJV8hxaw8pZa93lLEpPTP/ohdN4wFz7yp/ry9A==",
       "cpu": [
         "arm64"
       ],
       "dev": true,
+      "libc": [
+        "glibc"
+      ],
       "license": "MIT",
       "optional": true,
       "os": [
@@ -1069,13 +1078,16 @@
       ]
     },
     "node_modules/@rollup/rollup-linux-arm64-musl": {
-      "version": "4.54.0",
-      "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.54.0.tgz",
-      "integrity": "sha512-gr5vDbg3Bakga5kbdpqx81m2n9IX8M6gIMlQQIXiLTNeQW6CucvuInJ91EuCJ/JYvc+rcLLsDFcfAD1K7fMofg==",
+      "version": "4.60.0",
+      "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.60.0.tgz",
+      "integrity": "sha512-09EL+yFVbJZlhcQfShpswwRZ0Rg+z/CsSELFCnPt3iK+iqwGsI4zht3secj5vLEs957QvFFXnzAT0FFPIxSrkQ==",
       "cpu": [
         "arm64"
       ],
       "dev": true,
+      "libc": [
+        "musl"
+      ],
       "license": "MIT",
       "optional": true,
       "os": [
@@ -1083,13 +1095,33 @@
       ]
     },
     "node_modules/@rollup/rollup-linux-loong64-gnu": {
-      "version": "4.54.0",
-      "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.54.0.tgz",
-      "integrity": "sha512-gsrtB1NA3ZYj2vq0Rzkylo9ylCtW/PhpLEivlgWe0bpgtX5+9j9EZa0wtZiCjgu6zmSeZWyI/e2YRX1URozpIw==",
+      "version": "4.60.0",
+      "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.60.0.tgz",
+      "integrity": "sha512-i9IcCMPr3EXm8EQg5jnja0Zyc1iFxJjZWlb4wr7U2Wx/GrddOuEafxRdMPRYVaXjgbhvqalp6np07hN1w9kAKw==",
       "cpu": [
         "loong64"
       ],
       "dev": true,
+      "libc": [
+        "glibc"
+      ],
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ]
+    },
+    "node_modules/@rollup/rollup-linux-loong64-musl": {
+      "version": "4.60.0",
+      "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.60.0.tgz",
+      "integrity": "sha512-DGzdJK9kyJ+B78MCkWeGnpXJ91tK/iKA6HwHxF4TAlPIY7GXEvMe8hBFRgdrR9Ly4qebR/7gfUs9y2IoaVEyog==",
+      "cpu": [
+        "loong64"
+      ],
+      "dev": true,
+      "libc": [
+        "musl"
+      ],
       "license": "MIT",
       "optional": true,
       "os": [
@@ -1097,13 +1129,33 @@
       ]
     },
     "node_modules/@rollup/rollup-linux-ppc64-gnu": {
-      "version": "4.54.0",
-      "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.54.0.tgz",
-      "integrity": "sha512-y3qNOfTBStmFNq+t4s7Tmc9hW2ENtPg8FeUD/VShI7rKxNW7O4fFeaYbMsd3tpFlIg1Q8IapFgy7Q9i2BqeBvA==",
+      "version": "4.60.0",
+      "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.60.0.tgz",
+      "integrity": "sha512-RwpnLsqC8qbS8z1H1AxBA1H6qknR4YpPR9w2XX0vo2Sz10miu57PkNcnHVaZkbqyw/kUWfKMI73jhmfi9BRMUQ==",
+      "cpu": [
+        "ppc64"
+      ],
+      "dev": true,
+      "libc": [
+        "glibc"
+      ],
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ]
+    },
+    "node_modules/@rollup/rollup-linux-ppc64-musl": {
+      "version": "4.60.0",
+      "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.60.0.tgz",
+      "integrity": "sha512-Z8pPf54Ly3aqtdWC3G4rFigZgNvd+qJlOE52fmko3KST9SoGfAdSRCwyoyG05q1HrrAblLbk1/PSIV+80/pxLg==",
       "cpu": [
         "ppc64"
       ],
       "dev": true,
+      "libc": [
+        "musl"
+      ],
       "license": "MIT",
       "optional": true,
       "os": [
@@ -1111,13 +1163,16 @@
       ]
     },
     "node_modules/@rollup/rollup-linux-riscv64-gnu": {
-      "version": "4.54.0",
-      "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.54.0.tgz",
-      "integrity": "sha512-89sepv7h2lIVPsFma8iwmccN7Yjjtgz0Rj/Ou6fEqg3HDhpCa+Et+YSufy27i6b0Wav69Qv4WBNl3Rs6pwhebQ==",
+      "version": "4.60.0",
+      "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.60.0.tgz",
+      "integrity": "sha512-3a3qQustp3COCGvnP4SvrMHnPQ9d1vzCakQVRTliaz8cIp/wULGjiGpbcqrkv0WrHTEp8bQD/B3HBjzujVWLOA==",
       "cpu": [
         "riscv64"
       ],
       "dev": true,
+      "libc": [
+        "glibc"
+      ],
       "license": "MIT",
       "optional": true,
       "os": [
@@ -1125,13 +1180,16 @@
       ]
     },
     "node_modules/@rollup/rollup-linux-riscv64-musl": {
-      "version": "4.54.0",
-      "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.54.0.tgz",
-      "integrity": "sha512-ZcU77ieh0M2Q8Ur7D5X7KvK+UxbXeDHwiOt/CPSBTI1fBmeDMivW0dPkdqkT4rOgDjrDDBUed9x4EgraIKoR2A==",
+      "version": "4.60.0",
+      "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.60.0.tgz",
+      "integrity": "sha512-pjZDsVH/1VsghMJ2/kAaxt6dL0psT6ZexQVrijczOf+PeP2BUqTHYejk3l6TlPRydggINOeNRhvpLa0AYpCWSQ==",
       "cpu": [
         "riscv64"
       ],
       "dev": true,
+      "libc": [
+        "musl"
+      ],
       "license": "MIT",
       "optional": true,
       "os": [
@@ -1139,13 +1197,16 @@
       ]
     },
     "node_modules/@rollup/rollup-linux-s390x-gnu": {
-      "version": "4.54.0",
-      "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.54.0.tgz",
-      "integrity": "sha512-2AdWy5RdDF5+4YfG/YesGDDtbyJlC9LHmL6rZw6FurBJ5n4vFGupsOBGfwMRjBYH7qRQowT8D/U4LoSvVwOhSQ==",
+      "version": "4.60.0",
+      "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.60.0.tgz",
+      "integrity": "sha512-3ObQs0BhvPgiUVZrN7gqCSvmFuMWvWvsjG5ayJ3Lraqv+2KhOsp+pUbigqbeWqueGIsnn+09HBw27rJ+gYK4VQ==",
       "cpu": [
         "s390x"
       ],
       "dev": true,
+      "libc": [
+        "glibc"
+      ],
       "license": "MIT",
       "optional": true,
       "os": [
@@ -1153,13 +1214,16 @@
       ]
     },
     "node_modules/@rollup/rollup-linux-x64-gnu": {
-      "version": "4.54.0",
-      "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.54.0.tgz",
-      "integrity": "sha512-WGt5J8Ij/rvyqpFexxk3ffKqqbLf9AqrTBbWDk7ApGUzaIs6V+s2s84kAxklFwmMF/vBNGrVdYgbblCOFFezMQ==",
+      "version": "4.60.0",
+      "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.60.0.tgz",
+      "integrity": "sha512-EtylprDtQPdS5rXvAayrNDYoJhIz1/vzN2fEubo3yLE7tfAw+948dO0g4M0vkTVFhKojnF+n6C8bDNe+gDRdTg==",
       "cpu": [
         "x64"
       ],
       "dev": true,
+      "libc": [
+        "glibc"
+      ],
       "license": "MIT",
       "optional": true,
       "os": [
@@ -1167,23 +1231,40 @@
       ]
     },
     "node_modules/@rollup/rollup-linux-x64-musl": {
-      "version": "4.54.0",
-      "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.54.0.tgz",
-      "integrity": "sha512-JzQmb38ATzHjxlPHuTH6tE7ojnMKM2kYNzt44LO/jJi8BpceEC8QuXYA908n8r3CNuG/B3BV8VR3Hi1rYtmPiw==",
+      "version": "4.60.0",
+      "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.60.0.tgz",
+      "integrity": "sha512-k09oiRCi/bHU9UVFqD17r3eJR9bn03TyKraCrlz5ULFJGdJGi7VOmm9jl44vOJvRJ6P7WuBi/s2A97LxxHGIdw==",
       "cpu": [
         "x64"
       ],
       "dev": true,
+      "libc": [
+        "musl"
+      ],
       "license": "MIT",
       "optional": true,
       "os": [
         "linux"
       ]
     },
+    "node_modules/@rollup/rollup-openbsd-x64": {
+      "version": "4.60.0",
+      "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.60.0.tgz",
+      "integrity": "sha512-1o/0/pIhozoSaDJoDcec+IVLbnRtQmHwPV730+AOD29lHEEo4F5BEUB24H0OBdhbBBDwIOSuf7vgg0Ywxdfiiw==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "openbsd"
+      ]
+    },
     "node_modules/@rollup/rollup-openharmony-arm64": {
-      "version": "4.54.0",
-      "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.54.0.tgz",
-      "integrity": "sha512-huT3fd0iC7jigGh7n3q/+lfPcXxBi+om/Rs3yiFxjvSxbSB6aohDFXbWvlspaqjeOh+hx7DDHS+5Es5qRkWkZg==",
+      "version": "4.60.0",
+      "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.60.0.tgz",
+      "integrity": "sha512-pESDkos/PDzYwtyzB5p/UoNU/8fJo68vcXM9ZW2V0kjYayj1KaaUfi1NmTUTUpMn4UhU4gTuK8gIaFO4UGuMbA==",
       "cpu": [
         "arm64"
       ],
@@ -1195,9 +1276,9 @@
       ]
     },
     "node_modules/@rollup/rollup-win32-arm64-msvc": {
-      "version": "4.54.0",
-      "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.54.0.tgz",
-      "integrity": "sha512-c2V0W1bsKIKfbLMBu/WGBz6Yci8nJ/ZJdheE0EwB73N3MvHYKiKGs3mVilX4Gs70eGeDaMqEob25Tw2Gb9Nqyw==",
+      "version": "4.60.0",
+      "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.60.0.tgz",
+      "integrity": "sha512-hj1wFStD7B1YBeYmvY+lWXZ7ey73YGPcViMShYikqKT1GtstIKQAtfUI6yrzPjAy/O7pO0VLXGmUVWXQMaYgTQ==",
       "cpu": [
         "arm64"
       ],
@@ -1209,9 +1290,9 @@
       ]
     },
     "node_modules/@rollup/rollup-win32-ia32-msvc": {
-      "version": "4.54.0",
-      "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.54.0.tgz",
-      "integrity": "sha512-woEHgqQqDCkAzrDhvDipnSirm5vxUXtSKDYTVpZG3nUdW/VVB5VdCYA2iReSj/u3yCZzXID4kuKG7OynPnB3WQ==",
+      "version": "4.60.0",
+      "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.60.0.tgz",
+      "integrity": "sha512-SyaIPFoxmUPlNDq5EHkTbiKzmSEmq/gOYFI/3HHJ8iS/v1mbugVa7dXUzcJGQfoytp9DJFLhHH4U3/eTy2Bq4w==",
       "cpu": [
         "ia32"
       ],
@@ -1223,9 +1304,9 @@
       ]
     },
     "node_modules/@rollup/rollup-win32-x64-gnu": {
-      "version": "4.54.0",
-      "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.54.0.tgz",
-      "integrity": "sha512-dzAc53LOuFvHwbCEOS0rPbXp6SIhAf2txMP5p6mGyOXXw5mWY8NGGbPMPrs4P1WItkfApDathBj/NzMLUZ9rtQ==",
+      "version": "4.60.0",
+      "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.60.0.tgz",
+      "integrity": "sha512-RdcryEfzZr+lAr5kRm2ucN9aVlCCa2QNq4hXelZxb8GG0NJSazq44Z3PCCc8wISRuCVnGs0lQJVX5Vp6fKA+IA==",
       "cpu": [
         "x64"
       ],
@@ -1237,9 +1318,9 @@
       ]
     },
     "node_modules/@rollup/rollup-win32-x64-msvc": {
-      "version": "4.54.0",
-      "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.54.0.tgz",
-      "integrity": "sha512-hYT5d3YNdSh3mbCU1gwQyPgQd3T2ne0A3KG8KSBdav5TiBg6eInVmV+TeR5uHufiIgSFg0XsOWGW5/RhNcSvPg==",
+      "version": "4.60.0",
+      "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.60.0.tgz",
+      "integrity": "sha512-PrsWNQ8BuE00O3Xsx3ALh2Df8fAj9+cvvX9AIA6o4KpATR98c9mud4XtDWVvsEuyia5U4tVSTKygawyJkjm60w==",
       "cpu": [
         "x64"
       ],
@@ -1677,9 +1758,9 @@
       }
     },
     "node_modules/ajv": {
-      "version": "6.12.6",
-      "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz",
-      "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==",
+      "version": "6.14.0",
+      "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.14.0.tgz",
+      "integrity": "sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==",
       "dev": true,
       "license": "MIT",
       "dependencies": {
@@ -1753,9 +1834,9 @@
       }
     },
     "node_modules/anymatch/node_modules/picomatch": {
-      "version": "2.3.1",
-      "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz",
-      "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==",
+      "version": "2.3.2",
+      "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz",
+      "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==",
       "dev": true,
       "license": "MIT",
       "engines": {
@@ -1813,9 +1894,9 @@
       }
     },
     "node_modules/brace-expansion": {
-      "version": "2.0.2",
-      "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz",
-      "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==",
+      "version": "2.0.3",
+      "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.3.tgz",
+      "integrity": "sha512-MCV/fYJEbqx68aE58kv2cA/kiky1G8vux3OR6/jbS+jIMe/6fJWa0DTzJU7dqijOWYwHi1t29FlfYI9uytqlpA==",
       "dev": true,
       "license": "MIT",
       "dependencies": {
@@ -2281,9 +2362,9 @@
       }
     },
     "node_modules/eslint/node_modules/brace-expansion": {
-      "version": "1.1.12",
-      "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz",
-      "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==",
+      "version": "1.1.13",
+      "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.13.tgz",
+      "integrity": "sha512-9ZLprWS6EENmhEOpjCYW2c8VkmOvckIJZfkr7rBW6dObmfgJ/L1GpSYW5Hpo9lDz4D1+n0Ckz8rU7FwHDQiG/w==",
       "dev": true,
       "license": "MIT",
       "dependencies": {
@@ -2315,9 +2396,9 @@
       }
     },
     "node_modules/eslint/node_modules/minimatch": {
-      "version": "3.1.2",
-      "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz",
-      "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==",
+      "version": "3.1.5",
+      "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz",
+      "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==",
       "dev": true,
       "license": "ISC",
       "dependencies": {
@@ -2589,9 +2670,9 @@
       }
     },
     "node_modules/flatted": {
-      "version": "3.3.3",
-      "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.3.tgz",
-      "integrity": "sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==",
+      "version": "3.4.2",
+      "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.2.tgz",
+      "integrity": "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==",
       "dev": true,
       "license": "ISC"
     },
@@ -3226,9 +3307,9 @@
       }
     },
     "node_modules/micromatch/node_modules/picomatch": {
-      "version": "2.3.1",
-      "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz",
-      "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==",
+      "version": "2.3.2",
+      "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz",
+      "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==",
       "dev": true,
       "license": "MIT",
       "engines": {
@@ -3252,13 +3333,13 @@
       }
     },
     "node_modules/minimatch": {
-      "version": "9.0.5",
-      "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz",
-      "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==",
+      "version": "9.0.9",
+      "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz",
+      "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==",
       "dev": true,
       "license": "ISC",
       "dependencies": {
-        "brace-expansion": "^2.0.1"
+        "brace-expansion": "^2.0.2"
       },
       "engines": {
         "node": ">=16 || 14 >=14.17"
@@ -3515,9 +3596,9 @@
       "license": "ISC"
     },
     "node_modules/picomatch": {
-      "version": "4.0.3",
-      "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
-      "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
+      "version": "4.0.4",
+      "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz",
+      "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
       "dev": true,
       "license": "MIT",
       "engines": {
@@ -3678,9 +3759,9 @@
       }
     },
     "node_modules/readdirp/node_modules/picomatch": {
-      "version": "2.3.1",
-      "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz",
-      "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==",
+      "version": "2.3.2",
+      "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz",
+      "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==",
       "dev": true,
       "license": "MIT",
       "engines": {
@@ -3746,9 +3827,9 @@
       "license": "MIT"
     },
     "node_modules/rollup": {
-      "version": "4.54.0",
-      "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.54.0.tgz",
-      "integrity": "sha512-3nk8Y3a9Ea8szgKhinMlGMhGMw89mqule3KWczxhIzqudyHdCIOHw8WJlj/r329fACjKLEh13ZSk7oE22kyeIw==",
+      "version": "4.60.0",
+      "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.0.tgz",
+      "integrity": "sha512-yqjxruMGBQJ2gG4HtjZtAfXArHomazDHoFwFFmZZl0r7Pdo7qCIXKqKHZc8yeoMgzJJ+pO6pEEHa+V7uzWlrAQ==",
       "dev": true,
       "license": "MIT",
       "dependencies": {
@@ -3762,28 +3843,31 @@
         "npm": ">=8.0.0"
       },
       "optionalDependencies": {
-        "@rollup/rollup-android-arm-eabi": "4.54.0",
-        "@rollup/rollup-android-arm64": "4.54.0",
-        "@rollup/rollup-darwin-arm64": "4.54.0",
-        "@rollup/rollup-darwin-x64": "4.54.0",
-        "@rollup/rollup-freebsd-arm64": "4.54.0",
-        "@rollup/rollup-freebsd-x64": "4.54.0",
-        "@rollup/rollup-linux-arm-gnueabihf": "4.54.0",
-        "@rollup/rollup-linux-arm-musleabihf": "4.54.0",
-        "@rollup/rollup-linux-arm64-gnu": "4.54.0",
-        "@rollup/rollup-linux-arm64-musl": "4.54.0",
-        "@rollup/rollup-linux-loong64-gnu": "4.54.0",
-        "@rollup/rollup-linux-ppc64-gnu": "4.54.0",
-        "@rollup/rollup-linux-riscv64-gnu": "4.54.0",
-        "@rollup/rollup-linux-riscv64-musl": "4.54.0",
-        "@rollup/rollup-linux-s390x-gnu": "4.54.0",
-        "@rollup/rollup-linux-x64-gnu": "4.54.0",
-        "@rollup/rollup-linux-x64-musl": "4.54.0",
-        "@rollup/rollup-openharmony-arm64": "4.54.0",
-        "@rollup/rollup-win32-arm64-msvc": "4.54.0",
-        "@rollup/rollup-win32-ia32-msvc": "4.54.0",
-        "@rollup/rollup-win32-x64-gnu": "4.54.0",
-        "@rollup/rollup-win32-x64-msvc": "4.54.0",
+        "@rollup/rollup-android-arm-eabi": "4.60.0",
+        "@rollup/rollup-android-arm64": "4.60.0",
+        "@rollup/rollup-darwin-arm64": "4.60.0",
+        "@rollup/rollup-darwin-x64": "4.60.0",
+        "@rollup/rollup-freebsd-arm64": "4.60.0",
+        "@rollup/rollup-freebsd-x64": "4.60.0",
+        "@rollup/rollup-linux-arm-gnueabihf": "4.60.0",
+        "@rollup/rollup-linux-arm-musleabihf": "4.60.0",
+        "@rollup/rollup-linux-arm64-gnu": "4.60.0",
+        "@rollup/rollup-linux-arm64-musl": "4.60.0",
+        "@rollup/rollup-linux-loong64-gnu": "4.60.0",
+        "@rollup/rollup-linux-loong64-musl": "4.60.0",
+        "@rollup/rollup-linux-ppc64-gnu": "4.60.0",
+        "@rollup/rollup-linux-ppc64-musl": "4.60.0",
+        "@rollup/rollup-linux-riscv64-gnu": "4.60.0",
+        "@rollup/rollup-linux-riscv64-musl": "4.60.0",
+        "@rollup/rollup-linux-s390x-gnu": "4.60.0",
+        "@rollup/rollup-linux-x64-gnu": "4.60.0",
+        "@rollup/rollup-linux-x64-musl": "4.60.0",
+        "@rollup/rollup-openbsd-x64": "4.60.0",
+        "@rollup/rollup-openharmony-arm64": "4.60.0",
+        "@rollup/rollup-win32-arm64-msvc": "4.60.0",
+        "@rollup/rollup-win32-ia32-msvc": "4.60.0",
+        "@rollup/rollup-win32-x64-gnu": "4.60.0",
+        "@rollup/rollup-win32-x64-msvc": "4.60.0",
         "fsevents": "~2.3.2"
       }
     },
@@ -4645,9 +4729,9 @@
       }
     },
     "node_modules/yaml": {
-      "version": "2.8.2",
-      "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.2.tgz",
-      "integrity": "sha512-mplynKqc1C2hTVYxd0PU2xQAc22TI1vShAYGksCCfxbn/dFwnHTNi1bvYsBTkhdUNtGIf5xNOg938rrSSYvS9A==",
+      "version": "2.8.3",
+      "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.3.tgz",
+      "integrity": "sha512-AvbaCLOO2Otw/lW5bmh9d/WEdcDFdQp2Z2ZUH3pX9U2ihyUY0nvLv7J6TrWowklRGPYbB/IuIMfYgxaCPg5Bpg==",
       "dev": true,
       "license": "ISC",
       "bin": {

+ 2 - 0
package.json

@@ -18,6 +18,8 @@
     "start": "node build/index.js",
     "dev": "tsc --watch",
     "test": "vitest run",
+    "test:integration": "vitest run --config vitest.integration.config.ts",
+    "test:all": "vitest run && vitest run --config vitest.integration.config.ts",
     "test:watch": "vitest",
     "test:coverage": "vitest run --coverage",
     "lint": "eslint src",

+ 45 - 19
src/index.ts

@@ -26,6 +26,30 @@ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"
 import { z } from "zod";
 import { AppleMailManager } from "@/services/appleMailManager.js";
 
+// =============================================================================
+// Shared Validation Schemas
+// =============================================================================
+
+/** Message IDs in Apple Mail are always numeric. Enforce this at the schema level
+ *  to prevent AppleScript injection via the `whose id is ${id}` interpolation. */
+const MESSAGE_ID_SCHEMA = z.string().regex(/^\d+$/, "Message ID must be numeric");
+
+/** Batch operations are capped to prevent unbounded loops / DoS. */
+const BATCH_IDS_SCHEMA = z
+  .array(MESSAGE_ID_SCHEMA)
+  .min(1, "At least one message ID is required")
+  .max(100, "Cannot process more than 100 messages in a single batch");
+
+/** Date filter strings must look like natural-language dates (e.g. "March 1, 2026").
+ *  Block characters that could escape an AppleScript `date "..."` literal. */
+const DATE_FILTER_SCHEMA = z
+  .string()
+  .regex(
+    /^[a-zA-Z0-9 ,/\-:]+$/,
+    "Date must contain only alphanumeric characters, spaces, commas, slashes, hyphens, and colons"
+  )
+  .optional();
+
 // Read version from package.json to keep it in sync
 const require = createRequire(import.meta.url);
 const { version } = require("../package.json") as { version: string };
@@ -105,8 +129,8 @@ server.tool(
     account: z.string().optional().describe("Account to search in (omit to search all accounts)"),
     isRead: z.boolean().optional().describe("Filter by read status"),
     isFlagged: z.boolean().optional().describe("Filter by flagged status"),
-    dateFrom: z.string().optional().describe("Start date filter (e.g., 'January 1, 2026')"),
-    dateTo: z.string().optional().describe("End date filter (e.g., 'March 1, 2026')"),
+    dateFrom: DATE_FILTER_SCHEMA.describe("Start date filter (e.g., 'January 1, 2026')"),
+    dateTo: DATE_FILTER_SCHEMA.describe("End date filter (e.g., 'March 1, 2026')"),
     limit: z.number().optional().describe("Maximum number of results (default: 50)"),
   },
   withErrorHandling(({ query, mailbox, account, limit = 50, dateFrom, dateTo }) => {
@@ -132,7 +156,7 @@ server.tool(
 server.tool(
   "get-message",
   {
-    id: z.string().min(1, "Message ID is required"),
+    id: MESSAGE_ID_SCHEMA,
     preferHtml: z.boolean().optional().describe("Return HTML source instead of plain text"),
   },
   withErrorHandling(({ id, preferHtml }) => {
@@ -193,6 +217,7 @@ server.tool(
     account: z.string().optional().describe("Account to send from"),
     attachments: z
       .array(z.string())
+      .max(20, "Cannot attach more than 20 files")
       .optional()
       .describe("Absolute file paths to attach (e.g., ['/Users/me/report.pdf'])"),
   },
@@ -275,6 +300,7 @@ server.tool(
     account: z.string().optional().describe("Account to create draft in"),
     attachments: z
       .array(z.string())
+      .max(20, "Cannot attach more than 20 files")
       .optional()
       .describe("Absolute file paths to attach (e.g., ['/Users/me/report.pdf'])"),
   },
@@ -295,7 +321,7 @@ server.tool(
 server.tool(
   "reply-to-message",
   {
-    id: z.string().min(1, "Message ID is required"),
+    id: MESSAGE_ID_SCHEMA,
     body: z.string().min(1, "Reply body is required"),
     replyAll: z.boolean().optional().default(false).describe("Reply to all recipients"),
     send: z.boolean().optional().default(true).describe("Send immediately (false = save as draft)"),
@@ -316,7 +342,7 @@ server.tool(
 server.tool(
   "forward-message",
   {
-    id: z.string().min(1, "Message ID is required"),
+    id: MESSAGE_ID_SCHEMA,
     to: z.array(z.string()).min(1, "At least one recipient is required"),
     body: z.string().optional().describe("Optional message to prepend"),
     send: z.boolean().optional().default(true).describe("Send immediately (false = save as draft)"),
@@ -339,7 +365,7 @@ server.tool(
 server.tool(
   "mark-as-read",
   {
-    id: z.string().min(1, "Message ID is required"),
+    id: MESSAGE_ID_SCHEMA,
   },
   withErrorHandling(({ id }) => {
     const success = mailManager.markAsRead(id);
@@ -357,7 +383,7 @@ server.tool(
 server.tool(
   "mark-as-unread",
   {
-    id: z.string().min(1, "Message ID is required"),
+    id: MESSAGE_ID_SCHEMA,
   },
   withErrorHandling(({ id }) => {
     const success = mailManager.markAsUnread(id);
@@ -375,7 +401,7 @@ server.tool(
 server.tool(
   "flag-message",
   {
-    id: z.string().min(1, "Message ID is required"),
+    id: MESSAGE_ID_SCHEMA,
   },
   withErrorHandling(({ id }) => {
     const success = mailManager.flagMessage(id);
@@ -393,7 +419,7 @@ server.tool(
 server.tool(
   "unflag-message",
   {
-    id: z.string().min(1, "Message ID is required"),
+    id: MESSAGE_ID_SCHEMA,
   },
   withErrorHandling(({ id }) => {
     const success = mailManager.unflagMessage(id);
@@ -411,7 +437,7 @@ server.tool(
 server.tool(
   "delete-message",
   {
-    id: z.string().min(1, "Message ID is required"),
+    id: MESSAGE_ID_SCHEMA,
   },
   withErrorHandling(({ id }) => {
     const success = mailManager.deleteMessage(id);
@@ -429,7 +455,7 @@ server.tool(
 server.tool(
   "move-message",
   {
-    id: z.string().min(1, "Message ID is required"),
+    id: MESSAGE_ID_SCHEMA,
     mailbox: z.string().min(1, "Destination mailbox is required"),
     account: z.string().optional().describe("Account containing the destination mailbox"),
   },
@@ -449,7 +475,7 @@ server.tool(
 server.tool(
   "batch-delete-messages",
   {
-    ids: z.array(z.string()).min(1, "At least one message ID is required"),
+    ids: BATCH_IDS_SCHEMA,
   },
   withErrorHandling(({ ids }) => {
     const results = mailManager.batchDeleteMessages(ids);
@@ -471,7 +497,7 @@ server.tool(
 server.tool(
   "batch-move-messages",
   {
-    ids: z.array(z.string()).min(1, "At least one message ID is required"),
+    ids: BATCH_IDS_SCHEMA,
     mailbox: z.string().min(1, "Destination mailbox is required"),
     account: z.string().optional().describe("Account containing the destination mailbox"),
   },
@@ -497,7 +523,7 @@ server.tool(
 server.tool(
   "batch-mark-as-read",
   {
-    ids: z.array(z.string()).min(1, "At least one message ID is required"),
+    ids: BATCH_IDS_SCHEMA,
   },
   withErrorHandling(({ ids }) => {
     const results = mailManager.batchMarkAsRead(ids);
@@ -519,7 +545,7 @@ server.tool(
 server.tool(
   "batch-mark-as-unread",
   {
-    ids: z.array(z.string()).min(1, "At least one message ID is required"),
+    ids: BATCH_IDS_SCHEMA,
   },
   withErrorHandling(({ ids }) => {
     const results = mailManager.batchMarkAsUnread(ids);
@@ -541,7 +567,7 @@ server.tool(
 server.tool(
   "batch-flag-messages",
   {
-    ids: z.array(z.string()).min(1, "At least one message ID is required"),
+    ids: BATCH_IDS_SCHEMA,
   },
   withErrorHandling(({ ids }) => {
     const results = mailManager.batchFlagMessages(ids);
@@ -563,7 +589,7 @@ server.tool(
 server.tool(
   "batch-unflag-messages",
   {
-    ids: z.array(z.string()).min(1, "At least one message ID is required"),
+    ids: BATCH_IDS_SCHEMA,
   },
   withErrorHandling(({ ids }) => {
     const results = mailManager.batchUnflagMessages(ids);
@@ -585,7 +611,7 @@ server.tool(
 server.tool(
   "list-attachments",
   {
-    id: z.string().min(1, "Message ID is required"),
+    id: MESSAGE_ID_SCHEMA,
   },
   withErrorHandling(({ id }) => {
     const attachments = mailManager.listAttachments(id);
@@ -610,7 +636,7 @@ server.tool(
 server.tool(
   "save-attachment",
   {
-    id: z.string().min(1, "Message ID is required"),
+    id: MESSAGE_ID_SCHEMA,
     attachmentName: z.string().min(1, "Attachment name is required"),
     savePath: z.string().min(1, "Save directory path is required"),
   },

+ 245 - 0
src/security.test.ts

@@ -0,0 +1,245 @@
+/**
+ * Tests for security hardening: input validation schemas and path traversal prevention.
+ */
+
+import { describe, it, expect } from "vitest";
+import { resolve, isAbsolute } from "path";
+import { homedir } from "os";
+import { existsSync } from "fs";
+import { z } from "zod";
+
+// Re-define the schemas here to test them in isolation (they're module-scoped in index.ts)
+const MESSAGE_ID_SCHEMA = z.string().regex(/^\d+$/, "Message ID must be numeric");
+
+const BATCH_IDS_SCHEMA = z
+  .array(MESSAGE_ID_SCHEMA)
+  .min(1, "At least one message ID is required")
+  .max(100, "Cannot process more than 100 messages in a single batch");
+
+const DATE_FILTER_SCHEMA = z
+  .string()
+  .regex(
+    /^[a-zA-Z0-9 ,/\-:]+$/,
+    "Date must contain only alphanumeric characters, spaces, commas, slashes, hyphens, and colons"
+  )
+  .optional();
+
+describe("MESSAGE_ID_SCHEMA", () => {
+  it("accepts valid numeric IDs", () => {
+    expect(MESSAGE_ID_SCHEMA.parse("12345")).toBe("12345");
+    expect(MESSAGE_ID_SCHEMA.parse("0")).toBe("0");
+    expect(MESSAGE_ID_SCHEMA.parse("999999999")).toBe("999999999");
+  });
+
+  it("rejects non-numeric IDs", () => {
+    expect(() => MESSAGE_ID_SCHEMA.parse("abc")).toThrow();
+    expect(() => MESSAGE_ID_SCHEMA.parse("123abc")).toThrow();
+    expect(() => MESSAGE_ID_SCHEMA.parse("")).toThrow();
+  });
+
+  it("rejects AppleScript injection attempts", () => {
+    expect(() => MESSAGE_ID_SCHEMA.parse('1" & do shell script "rm -rf /')).toThrow();
+    expect(() => MESSAGE_ID_SCHEMA.parse("1; drop table")).toThrow();
+    expect(() => MESSAGE_ID_SCHEMA.parse("-1")).toThrow();
+  });
+
+  it("rejects template-style IDs (tmpl_N format)", () => {
+    expect(() => MESSAGE_ID_SCHEMA.parse("tmpl_1")).toThrow();
+  });
+});
+
+describe("BATCH_IDS_SCHEMA", () => {
+  it("accepts valid batch of numeric IDs", () => {
+    const result = BATCH_IDS_SCHEMA.parse(["1", "2", "3"]);
+    expect(result).toEqual(["1", "2", "3"]);
+  });
+
+  it("rejects empty array", () => {
+    expect(() => BATCH_IDS_SCHEMA.parse([])).toThrow("At least one message ID");
+  });
+
+  it("rejects more than 100 IDs", () => {
+    const ids = Array.from({ length: 101 }, (_, i) => String(i));
+    expect(() => BATCH_IDS_SCHEMA.parse(ids)).toThrow("Cannot process more than 100");
+  });
+
+  it("accepts exactly 100 IDs", () => {
+    const ids = Array.from({ length: 100 }, (_, i) => String(i));
+    expect(BATCH_IDS_SCHEMA.parse(ids)).toHaveLength(100);
+  });
+
+  it("rejects batch containing non-numeric IDs", () => {
+    expect(() => BATCH_IDS_SCHEMA.parse(["123", "abc", "456"])).toThrow();
+  });
+});
+
+describe("DATE_FILTER_SCHEMA", () => {
+  it("accepts valid date strings", () => {
+    expect(DATE_FILTER_SCHEMA.parse("January 1, 2026")).toBe("January 1, 2026");
+    expect(DATE_FILTER_SCHEMA.parse("March 15, 2026")).toBe("March 15, 2026");
+    expect(DATE_FILTER_SCHEMA.parse("2026-03-15")).toBe("2026-03-15");
+    expect(DATE_FILTER_SCHEMA.parse("3/15/2026")).toBe("3/15/2026");
+  });
+
+  it("accepts undefined (optional)", () => {
+    expect(DATE_FILTER_SCHEMA.parse(undefined)).toBeUndefined();
+  });
+
+  it("rejects strings with quotes (AppleScript injection)", () => {
+    expect(() => DATE_FILTER_SCHEMA.parse('"January 1" & do shell script "evil"')).toThrow();
+  });
+
+  it("rejects strings with backslashes", () => {
+    expect(() => DATE_FILTER_SCHEMA.parse("January\\1")).toThrow();
+  });
+
+  it("rejects strings with parentheses", () => {
+    expect(() => DATE_FILTER_SCHEMA.parse("date(2026)")).toThrow();
+  });
+});
+
+describe("saveAttachment input validation", () => {
+  // Test the validation logic that lives in appleMailManager.saveAttachment
+  // by checking the same regex/logic used there
+
+  const isInvalidAttachmentName = (name: string): boolean => {
+    return /[/\\\0]/.test(name) || name.includes("..");
+  };
+
+  it("blocks forward slash in attachment name", () => {
+    expect(isInvalidAttachmentName("../../etc/passwd")).toBe(true);
+    expect(isInvalidAttachmentName("path/to/file.txt")).toBe(true);
+  });
+
+  it("blocks backslash in attachment name", () => {
+    expect(isInvalidAttachmentName("file\\name.txt")).toBe(true);
+  });
+
+  it("blocks null bytes in attachment name", () => {
+    expect(isInvalidAttachmentName("file\0name.txt")).toBe(true);
+  });
+
+  it("blocks directory traversal in attachment name", () => {
+    expect(isInvalidAttachmentName("..")).toBe(true);
+    expect(isInvalidAttachmentName("../secret")).toBe(true);
+  });
+
+  it("allows normal attachment names", () => {
+    expect(isInvalidAttachmentName("report.pdf")).toBe(false);
+    expect(isInvalidAttachmentName("Q1 Budget (Final).xlsx")).toBe(false);
+    expect(isInvalidAttachmentName("résumé.docx")).toBe(false);
+  });
+
+  const isAllowedPath = (savePath: string): boolean => {
+    const resolvedPath = resolve(savePath);
+    const allowedPrefixes = [homedir(), "/tmp", "/private/tmp", "/Volumes"];
+    return allowedPrefixes.some((prefix: string) => resolvedPath.startsWith(prefix));
+  };
+
+  it("allows paths under home directory", () => {
+    expect(isAllowedPath(`${homedir()}/Downloads`)).toBe(true);
+  });
+
+  it("allows /tmp", () => {
+    expect(isAllowedPath("/tmp")).toBe(true);
+    expect(isAllowedPath("/tmp/attachments")).toBe(true);
+  });
+
+  it("blocks traversal out of allowed directories", () => {
+    expect(isAllowedPath("/tmp/../../etc")).toBe(false);
+  });
+
+  it("blocks /etc directly", () => {
+    expect(isAllowedPath("/etc")).toBe(false);
+  });
+
+  it("blocks /usr paths", () => {
+    expect(isAllowedPath("/usr/local")).toBe(false);
+  });
+});
+
+describe("buildAttachmentCommands validation", () => {
+  // Mirror the logic from appleMailManager.ts buildAttachmentCommands()
+  // to test the validation in isolation without needing real files.
+
+  function escapeForAppleScript(text: string): string {
+    if (!text) return "";
+    return text.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
+  }
+
+  function buildAttachmentCommands(attachments?: string[]): string {
+    if (!attachments || attachments.length === 0) return "";
+    for (const filePath of attachments) {
+      if (!isAbsolute(filePath)) {
+        throw new Error(`Attachment path must be absolute: "${filePath}"`);
+      }
+      if (!existsSync(filePath)) {
+        throw new Error(`Attachment file not found: "${filePath}"`);
+      }
+    }
+    let commands = "";
+    for (const filePath of attachments) {
+      const safePath = escapeForAppleScript(filePath);
+      commands += `make new attachment with properties {file name:POSIX file "${safePath}"} at after the last paragraph\n`;
+    }
+    return commands;
+  }
+
+  it("returns empty string for undefined", () => {
+    expect(buildAttachmentCommands(undefined)).toBe("");
+  });
+
+  it("returns empty string for empty array", () => {
+    expect(buildAttachmentCommands([])).toBe("");
+  });
+
+  it("rejects relative paths", () => {
+    expect(() => buildAttachmentCommands(["relative/path.pdf"])).toThrow("must be absolute");
+  });
+
+  it("rejects paths starting with ./", () => {
+    expect(() => buildAttachmentCommands(["./file.pdf"])).toThrow("must be absolute");
+  });
+
+  it("rejects nonexistent files", () => {
+    expect(() => buildAttachmentCommands(["/nonexistent/file.pdf"])).toThrow("not found");
+  });
+
+  it("generates correct AppleScript for valid files", () => {
+    // Use a file we know exists
+    const testFile = "/usr/bin/env";
+    const result = buildAttachmentCommands([testFile]);
+    expect(result).toContain("make new attachment");
+    expect(result).toContain("POSIX file");
+    expect(result).toContain(testFile);
+  });
+
+  it("escapes double quotes in file paths", () => {
+    // Test the escaping function directly since we can't easily mock existsSync
+    const escaped = escapeForAppleScript('/Users/test/file "name".pdf');
+    expect(escaped).toContain('file \\"name\\"');
+  });
+
+  it("handles multiple attachments", () => {
+    const testFile = "/usr/bin/env";
+    const result = buildAttachmentCommands([testFile, testFile]);
+    const matches = result.match(/make new attachment/g);
+    expect(matches).toHaveLength(2);
+  });
+
+  // Schema-level test: attachment array cap
+  const ATTACHMENTS_SCHEMA = z
+    .array(z.string())
+    .max(20, "Cannot attach more than 20 files")
+    .optional();
+
+  it("rejects more than 20 attachments at schema level", () => {
+    const paths = Array.from({ length: 21 }, (_, i) => `/tmp/file${i}.pdf`);
+    expect(() => ATTACHMENTS_SCHEMA.parse(paths)).toThrow("Cannot attach more than 20");
+  });
+
+  it("accepts exactly 20 attachments at schema level", () => {
+    const paths = Array.from({ length: 20 }, (_, i) => `/tmp/file${i}.pdf`);
+    expect(ATTACHMENTS_SCHEMA.parse(paths)).toHaveLength(20);
+  });
+});

+ 36 - 13
src/services/appleMailManager.ts

@@ -15,7 +15,8 @@
 
 import { spawnSync } from "child_process";
 import { existsSync } from "fs";
-import { isAbsolute } from "path";
+import { isAbsolute, resolve } from "path";
+import { homedir } from "os";
 import { executeAppleScript } from "@/utils/applescript.js";
 import type {
   Message,
@@ -374,15 +375,18 @@ export class AppleMailManager {
       searchCondition = `whose subject contains "${safeQuery}" or sender contains "${safeQuery}"`;
     }
 
-    // Build date filter AppleScript
+    // Build date filter AppleScript.
+    // Note: dateFrom/dateTo are already validated by DATE_FILTER_SCHEMA (alphanumeric + safe
+    // punctuation only), so escapeForAppleScript() below is belt-and-suspenders — it won't
+    // alter valid date strings but guards against future schema changes.
     let dateFilter = "";
     if (dateFrom || dateTo) {
       const dateChecks: string[] = [];
       if (dateFrom) {
-        dateChecks.push(`date received of msg >= date "${dateFrom}"`);
+        dateChecks.push(`date received of msg >= date "${escapeForAppleScript(dateFrom)}"`);
       }
       if (dateTo) {
-        dateChecks.push(`date received of msg <= date "${dateTo}"`);
+        dateChecks.push(`date received of msg <= date "${escapeForAppleScript(dateTo)}"`);
       }
       dateFilter = dateChecks.join(" and ");
     }
@@ -437,7 +441,7 @@ export class AppleMailManager {
         repeat with acct in accounts
           repeat with mb in mailboxes of acct
             try
-              set matchingMsgs to (messages of mb whose id is ${id})
+              set matchingMsgs to (messages of mb whose id is ${Number(id)})
               if (count of matchingMsgs) > 0 then
                 set msg to item 1 of matchingMsgs
                 set msgSubject to subject of msg
@@ -496,7 +500,7 @@ export class AppleMailManager {
         repeat with acct in accounts
           repeat with mb in mailboxes of acct
             try
-              set matchingMsgs to (messages of mb whose id is ${id})
+              set matchingMsgs to (messages of mb whose id is ${Number(id)})
               if (count of matchingMsgs) > 0 then
                 set msg to item 1 of matchingMsgs
                 set msgSubject to subject of msg
@@ -868,7 +872,7 @@ export class AppleMailManager {
         repeat with acct in accounts
           repeat with mb in mailboxes of acct
             try
-              set matchingMsgs to (messages of mb whose id is ${id})
+              set matchingMsgs to (messages of mb whose id is ${Number(id)})
               if (count of matchingMsgs) > 0 then
                 set msg to item 1 of matchingMsgs
                 set theReply to reply msg with opening window${replyAllClause}
@@ -919,7 +923,7 @@ export class AppleMailManager {
         repeat with acct in accounts
           repeat with mb in mailboxes of acct
             try
-              set matchingMsgs to (messages of mb whose id is ${id})
+              set matchingMsgs to (messages of mb whose id is ${Number(id)})
               if (count of matchingMsgs) > 0 then
                 set msg to item 1 of matchingMsgs
                 set theForward to forward msg with opening window
@@ -956,7 +960,7 @@ export class AppleMailManager {
         repeat with acct in accounts
           repeat with mb in mailboxes of acct
             try
-              set matchingMsgs to (messages of mb whose id is ${id})
+              set matchingMsgs to (messages of mb whose id is ${Number(id)})
               if (count of matchingMsgs) > 0 then
                 set msg to item 1 of matchingMsgs
                 ${operation}
@@ -1061,7 +1065,7 @@ export class AppleMailManager {
         repeat with acct in accounts
           repeat with mb in mailboxes of acct
             try
-              set matchingMsgs to (messages of mb whose id is ${id})
+              set matchingMsgs to (messages of mb whose id is ${Number(id)})
               if (count of matchingMsgs) > 0 then
                 set msg to item 1 of matchingMsgs
                 set destMailbox to mailbox "${safeMailbox}" of account "${safeAccount}"
@@ -1196,7 +1200,7 @@ export class AppleMailManager {
         repeat with acct in accounts
           repeat with mb in mailboxes of acct
             try
-              set matchingMsgs to (messages of mb whose id is ${id})
+              set matchingMsgs to (messages of mb whose id is ${Number(id)})
               if (count of matchingMsgs) > 0 then
                 set msg to item 1 of matchingMsgs
                 set outputText to ""
@@ -1248,15 +1252,34 @@ export class AppleMailManager {
    * Save an attachment from a message to disk.
    */
   saveAttachment(id: string, attachmentName: string, savePath: string): boolean {
+    // Validate attachment name: block path separators, traversal, null bytes, and backslashes
+    if (/[/\\\0]/.test(attachmentName) || attachmentName.includes("..")) {
+      console.error(`Invalid attachment name: "${attachmentName}"`);
+      return false;
+    }
+
+    // Resolve the save path to prevent symlink / ".." traversal bypass
+    const resolvedPath = resolve(savePath);
+    const allowedPrefixes = [homedir(), "/tmp", "/private/tmp", "/Volumes"];
+    const isAllowed = allowedPrefixes.some((prefix) => resolvedPath.startsWith(prefix));
+    if (!isAllowed) {
+      console.error(`Save path "${savePath}" is outside allowed directories`);
+      return false;
+    }
+
     const safeName = escapeForAppleScript(attachmentName);
-    const safePath = escapeForAppleScript(savePath);
+    const safePath = escapeForAppleScript(resolvedPath);
+
+    // Use Number(id) as defense-in-depth — the Zod schema already enforces numeric IDs,
+    // but this ensures raw interpolation into AppleScript is safe even if validation changes.
+    const numericId = Number(id);
 
     const script = buildAppLevelScript(`
       try
         repeat with acct in accounts
           repeat with mb in mailboxes of acct
             try
-              set matchingMsgs to (messages of mb whose id is ${id})
+              set matchingMsgs to (messages of mb whose id is ${numericId})
               if (count of matchingMsgs) > 0 then
                 set msg to item 1 of matchingMsgs
                 repeat with att in mail attachments of msg

+ 271 - 0
test/integration.test.ts

@@ -0,0 +1,271 @@
+/**
+ * Integration tests for apple-mail-mcp
+ *
+ * These tests run against REAL Apple Mail data — no mocks.
+ * They exercise the full stack: Zod schemas → AppleMailManager → AppleScript → Mail.app.
+ *
+ * Prerequisites:
+ *   - macOS with Mail.app configured and at least one account
+ *   - Automation permission granted to the terminal running the tests
+ *   - At least one message in INBOX
+ *
+ * Run via: npm run test:integration
+ */
+
+import { describe, it, expect, beforeAll } from "vitest";
+import { z } from "zod";
+import { resolve } from "path";
+import { homedir } from "os";
+import { AppleMailManager } from "../src/services/appleMailManager.js";
+
+// ---------------------------------------------------------------------------
+// Shared schemas (mirrored from index.ts so we can test them directly)
+// ---------------------------------------------------------------------------
+
+const MESSAGE_ID_SCHEMA = z.string().regex(/^\d+$/, "Message ID must be numeric");
+
+const BATCH_IDS_SCHEMA = z
+  .array(MESSAGE_ID_SCHEMA)
+  .min(1, "At least one message ID is required")
+  .max(100, "Cannot process more than 100 messages in a single batch");
+
+const DATE_FILTER_SCHEMA = z
+  .string()
+  .regex(
+    /^[a-zA-Z0-9 ,/\-:]+$/,
+    "Date must contain only alphanumeric characters, spaces, commas, slashes, hyphens, and colons"
+  )
+  .optional();
+
+// ---------------------------------------------------------------------------
+// Test state shared across describe blocks
+// ---------------------------------------------------------------------------
+
+let mgr: AppleMailManager;
+let realMessageId: string | null = null;
+let realAccount: string | null = null;
+
+beforeAll(() => {
+  mgr = new AppleMailManager();
+});
+
+// ===========================================================================
+// Schema validation (no Mail.app interaction)
+// ===========================================================================
+
+describe("schema validation", () => {
+  describe("MESSAGE_ID_SCHEMA", () => {
+    it.each(["12345", "0", "999999999"])("accepts valid numeric ID: %s", (id) => {
+      expect(MESSAGE_ID_SCHEMA.parse(id)).toBe(id);
+    });
+
+    it.each([
+      ["AppleScript injection", '1" & do shell script "rm -rf /'],
+      ["non-numeric", "abc"],
+      ["mixed", "123abc"],
+      ["negative", "-1"],
+      ["spaces", "12 34"],
+      ["empty", ""],
+      ["template ID", "tmpl_1"],
+    ])("rejects %s: %s", (_label, id) => {
+      expect(() => MESSAGE_ID_SCHEMA.parse(id)).toThrow();
+    });
+  });
+
+  describe("BATCH_IDS_SCHEMA", () => {
+    it("accepts 1-100 valid numeric IDs", () => {
+      expect(BATCH_IDS_SCHEMA.parse(["1", "2", "3"])).toEqual(["1", "2", "3"]);
+    });
+
+    it("accepts exactly 100 IDs", () => {
+      const ids = Array.from({ length: 100 }, (_, i) => String(i));
+      expect(BATCH_IDS_SCHEMA.parse(ids)).toHaveLength(100);
+    });
+
+    it("rejects empty array", () => {
+      expect(() => BATCH_IDS_SCHEMA.parse([])).toThrow();
+    });
+
+    it("rejects more than 100 IDs", () => {
+      const ids = Array.from({ length: 101 }, (_, i) => String(i));
+      expect(() => BATCH_IDS_SCHEMA.parse(ids)).toThrow();
+    });
+
+    it("rejects batch containing non-numeric IDs", () => {
+      expect(() => BATCH_IDS_SCHEMA.parse(["123", "abc", "456"])).toThrow();
+    });
+  });
+
+  describe("DATE_FILTER_SCHEMA", () => {
+    it.each(["January 1, 2026", "March 15, 2026", "2026-03-15", "3/15/2026", "12:30:00"])(
+      "accepts valid date string: %s",
+      (d) => {
+        expect(DATE_FILTER_SCHEMA.parse(d)).toBe(d);
+      }
+    );
+
+    it("accepts undefined (optional)", () => {
+      expect(DATE_FILTER_SCHEMA.parse(undefined)).toBeUndefined();
+    });
+
+    it.each([
+      ["quotes (injection)", '"January 1" & do shell script "evil"'],
+      ["backslash", "January\\1"],
+      ["parentheses", "date(2026)"],
+      ["semicolon", "Jan 1; evil"],
+      ["ampersand", "Jan & evil"],
+    ])("rejects %s: %s", (_label, d) => {
+      expect(() => DATE_FILTER_SCHEMA.parse(d)).toThrow();
+    });
+  });
+});
+
+// ===========================================================================
+// saveAttachment input validation (no Mail.app interaction for bad inputs)
+// ===========================================================================
+
+describe("saveAttachment path safety", () => {
+  // These all use a bogus message ID so even if validation passes,
+  // the AppleScript would just return "message not found".
+  const BOGUS_ID = "99999999";
+
+  it("blocks path traversal in savePath", () => {
+    const result = mgr.saveAttachment(BOGUS_ID, "test.pdf", "/tmp/../../etc");
+    expect(result).toBe(false);
+  });
+
+  it("blocks directory traversal in attachment name", () => {
+    const result = mgr.saveAttachment(BOGUS_ID, "../../etc/passwd", "/tmp");
+    expect(result).toBe(false);
+  });
+
+  it("blocks backslash in attachment name", () => {
+    const result = mgr.saveAttachment(BOGUS_ID, "file\\name.txt", "/tmp");
+    expect(result).toBe(false);
+  });
+
+  it("blocks null byte in attachment name", () => {
+    const result = mgr.saveAttachment(BOGUS_ID, "file\0name.txt", "/tmp");
+    expect(result).toBe(false);
+  });
+
+  it("blocks forward slash in attachment name", () => {
+    const result = mgr.saveAttachment(BOGUS_ID, "path/to/file.txt", "/tmp");
+    expect(result).toBe(false);
+  });
+
+  it("blocks save path outside allowed directories", () => {
+    const result = mgr.saveAttachment(BOGUS_ID, "test.pdf", "/etc");
+    expect(result).toBe(false);
+  });
+
+  it("blocks save path to /usr", () => {
+    const result = mgr.saveAttachment(BOGUS_ID, "test.pdf", "/usr/local");
+    expect(result).toBe(false);
+  });
+
+  it("allows save path under home directory", () => {
+    // Will fail at "message not found" stage, but should pass path validation
+    // (returns false because the message doesn't exist, not because of path)
+    const result = mgr.saveAttachment(BOGUS_ID, "test.pdf", `${homedir()}/Downloads`);
+    // We can't distinguish path-fail from message-not-found via boolean alone,
+    // but this at least confirms it doesn't throw.
+    expect(typeof result).toBe("boolean");
+  });
+});
+
+// ===========================================================================
+// Live Mail.app operations (read-only)
+// ===========================================================================
+
+describe("live Mail.app operations", { timeout: 120_000 }, () => {
+  it("lists at least one account and finds one with INBOX", () => {
+    const accounts = mgr.listAccounts();
+    expect(accounts.length).toBeGreaterThan(0);
+
+    // Some accounts (e.g. iCloud) may not have a standard INBOX.
+    // Find the first account that has messages in INBOX.
+    for (const acct of accounts) {
+      const messages = mgr.listMessages("INBOX", acct.name, 1);
+      if (messages.length > 0) {
+        realAccount = acct.name;
+        break;
+      }
+    }
+
+    expect(realAccount).not.toBeNull();
+  });
+
+  it("lists messages from INBOX", () => {
+    expect(realAccount).not.toBeNull();
+    const messages = mgr.listMessages("INBOX", realAccount!, 5);
+    expect(messages.length).toBeGreaterThan(0);
+
+    // Capture a real message ID for subsequent tests
+    realMessageId = messages[0].id;
+
+    // Verify the real ID is numeric (critical for Number(id) defense)
+    expect(realMessageId).toMatch(/^\d+$/);
+  });
+
+  it("retrieves a message by numeric ID", () => {
+    expect(realMessageId).not.toBeNull();
+    const msg = mgr.getMessageById(realMessageId!);
+    expect(msg).not.toBeNull();
+    expect(msg!.id).toBe(realMessageId);
+  });
+
+  it("gets message content by ID", () => {
+    expect(realMessageId).not.toBeNull();
+    const content = mgr.getMessageContent(realMessageId!);
+    expect(content).not.toBeNull();
+    expect(content!.subject).toBeDefined();
+  });
+
+  it("lists attachments for a message (may be empty)", () => {
+    expect(realMessageId).not.toBeNull();
+    const attachments = mgr.listAttachments(realMessageId!);
+    expect(Array.isArray(attachments)).toBe(true);
+  });
+
+  it("searches messages with date range filter", () => {
+    // This exercises the DATE_FILTER_SCHEMA → AppleScript date literal path
+    const messages = mgr.searchMessages(
+      undefined,
+      "INBOX",
+      realAccount ?? undefined,
+      5,
+      "January 1, 2025",
+      "December 31, 2026"
+    );
+    // May find 0 messages but should not error
+    expect(Array.isArray(messages)).toBe(true);
+  });
+
+  it("lists mailboxes for an account", () => {
+    expect(realAccount).not.toBeNull();
+    const mailboxes = mgr.listMailboxes(realAccount!);
+    expect(mailboxes.length).toBeGreaterThan(0);
+  });
+
+  it("gets unread count without error", () => {
+    const count = mgr.getUnreadCount(undefined, realAccount ?? undefined);
+    expect(typeof count).toBe("number");
+    expect(count).toBeGreaterThanOrEqual(0);
+  });
+
+  it("runs health check", () => {
+    const result = mgr.healthCheck();
+    expect(result).toBeDefined();
+    expect(typeof result.healthy).toBe("boolean");
+    expect(Array.isArray(result.checks)).toBe(true);
+  });
+
+  it("confirms real message IDs are safe for Number() cast", () => {
+    expect(realMessageId).not.toBeNull();
+    const num = Number(realMessageId);
+    expect(Number.isNaN(num)).toBe(false);
+    expect(Number.isFinite(num)).toBe(true);
+    expect(num).toBeGreaterThan(0);
+  });
+});

+ 16 - 0
vitest.integration.config.ts

@@ -0,0 +1,16 @@
+import { defineConfig } from "vitest/config";
+import path from "path";
+
+export default defineConfig({
+  test: {
+    globals: true,
+    environment: "node",
+    include: ["test/**/*.test.ts"],
+    testTimeout: 120_000,
+  },
+  resolve: {
+    alias: {
+      "@": path.resolve(__dirname, "./src"),
+    },
+  },
+});