proton-drive-mcp 1.0.32 → 1.0.34

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/CHANGELOG.md CHANGED
@@ -1,5 +1,36 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.0.34 — 2026-09-03
4
+
5
+ Continued the live-testing bug sweep from v1.0.33. 7 more real, confirmed bugs across two rounds — every one reproduced live against the real Proton Drive CLI and account before being called a bug, and every fix re-verified live afterward.
6
+
7
+ ### Fixed
8
+ - **Flag injection via `--password`/`--expiration` on `drive_share_set_url`.** Confirmed live: `sharing set-url ... --password "-x"` makes the real CLI's arg parser treat `-x` as an unknown flag and dump usage instead of setting the password — the same flag-injection class fixed for other params in v1.0.33, just missed here. Added `validateFlagValue()` and applied it in both the MCP dispatch and the CLI companion.
9
+ - **`drive_upload` silently disabled thumbnail generation on every upload**, since v1.0.0. `upload()` hardcoded `--skip-thumbnails` unconditionally — never exposed as an option, never documented. Every file uploaded through this tool got no Drive thumbnail, degrading grid/preview views in the Proton apps, with no way to opt out. Removed the hardcoded flag.
10
+ - **`photos_list_timeline` with `loadDetails=true` did nothing useful.** The tool description promises "full node metadata" when `loadDetails=true`. Confirmed live that `photo timeline --load-details` returns rich objects (`name`, `mediaType`, `creationTime`, `totalStorageSize`, `photo.captureTime`, `photo.tags`, ...) — but the code discarded everything except `nodeUid`, identical to the cheap default-mode output. `loadDetails=true` paid for a slower, memory-buffered CLI call and threw the result away. Now extracts and returns the full set of fields.
11
+ - **`drive_list` didn't escape a literal `/` in an item's name when building its path.** The CLI's own path syntax requires escaping `/` in a name with a backslash. Our `validateName()` prevents *our* tools from creating such names, but an item created by any other Proton Drive client (web, desktop, mobile) can still have a literal `/` in its name. Confirmed live: creating such an item via the raw CLI, then naively joining its name into a path made every path-based tool (`drive_info`, `drive_download`, `drive_move`, `drive_delete`, ...) fail with "Node not found", while the correctly escaped form resolved. `drive_list` now escapes `/` in names before building the path it returns.
12
+ - **`drive_invitation_accept`/`drive_invitation_reject` had no flag-injection guard on `uid`**, in both the MCP tool and the CLI. Confirmed live: `invitation accept "-fakeuid"` was misparsed as a CLI flag by the underlying binary ("Unknown option 'f'") instead of a clean rejection. Now validated with `validateFlagValue()`.
13
+ - **`drive_download` silently swallowed partial-download failures.** `download()` narrowed the CLI's real `TransferSummary` response down to `{path, localPath, downloaded}`, dropping `skippedItems`/`failedItems` entirely — and the MCP dispatch never checked for failure, unlike `drive_upload`'s equivalent check. A download where some files failed would report success. `DownloadResult` now includes `skipped`/`failed`, and `drive_download` fails the call when `failed > 0`, mirroring `drive_upload`.
14
+ - **`drive_read_file`/`drive_write_file` echoed the raw, untrimmed input path** instead of the validated one, inconsistent with every other tool in the codebase (which always echo the validated variable). Minor but real: a path with leading/trailing whitespace would show up unchanged in the response even though the actual operation used the trimmed path.
15
+
16
+ ## 1.0.33 — 2026-09-02
17
+
18
+ A full skeptical re-read of every source file (`drive.ts`, `index.ts`, `cli.ts`, `subprocess.ts`, `validation.ts`, `syncfs.ts`, `errors.ts`, `logger.ts`) plus live testing of every candidate finding. 7 real, confirmed bugs — every one reproduced live before being called a bug, and every fix re-verified live afterward, through both the MCP JSON-RPC protocol and the companion CLI.
19
+
20
+ ### Fixed
21
+ - **`validateName()` didn't reject `/`.** Confirmed live: renaming a file to a name containing `/` (e.g. `"evil/nested.txt"`) succeeded on the CLI side, but the resulting item's real Drive name literally contains the slash — it is not two path segments. `drive_list`'s path is computed by joining parent + name, so it then looks like a nested folder that doesn't exist; every other tool (`drive_info`, `drive_download`, `drive_move`, `drive_delete`, ...) fails with "Node not found" on that path, and the item becomes unreachable except by re-listing its real parent. `validateName()` now rejects `/` anywhere in the name.
22
+ - **`photos_create_album` had no flag-injection protection at all**, in both the MCP tool and the CLI (`album create <name>`) — unlike every other user-typed name/path/email in this codebase, it was never run through a validator. Confirmed live: `album create "--help"` was misparsed as a CLI flag by the underlying binary, producing a confusing parse error instead of a clean rejection. Now validated with `validateName()`.
23
+ - **`photos_update_album`'s `--name` and `--cover-photo-uid` had the same gap**, in both the MCP tool and the CLI (`album update`). Same fix.
24
+ - **Large `drive_copy`/`drive_move` operations could be killed mid-flight.** The extended 30-minute timeout was only applied to `upload`/`download`; `copy`/`move` on a large folder tree — which can take just as long, re-encrypting or re-sharing many files server-side — got only the 60-second default and would be `SIGKILL`'d if it ran long, potentially leaving Drive in a partially-copied/moved state. `copy` and `move` now get the same extended timeout.
25
+
26
+ ### Investigated, confirmed NOT bugs (avoided introducing a regression)
27
+ - `photos_download` was missing a `/photos/`-only path check that its sibling tools (`photos_add_to_album` etc.) have. Checked the CLI source first: `photo download` explicitly supports `/photos/`, `/albums/`, and `/photos-shared-with-me/` roots (`SUPPORTED_REMOTE_PATH_TYPES`). Adding the restriction would have been a regression, not a fix — left as-is.
28
+ - Suspected `photos_add_to_album`/`remove_from_album`'s `/photos/`-only restriction was too narrow, since the underlying CLI command imposes no path-type restriction at all. Tested live: a compound `/albums/<name>/<photo>` path does **not** actually resolve through the CLI's path resolver — the existing restriction is correct as shipped.
29
+ - Suspected the MCP-mode logger (stdout vs. stderr gating via `PROTON_DRIVE_MCP=1`) could leak a stray `console.log` into the JSON-RPC stream under some import order. Traced the actual call graph: `logger` is only ever used from within `index.ts`'s own functions, and the env var is set at that same module's top level before those functions can be invoked — the ordering is guaranteed correct by construction, not a bug.
30
+
31
+ ### Testing
32
+ 122 tests passing (was 121).
33
+
3
34
  ## 1.0.32 — 2026-08-21
4
35
 
5
36
  Closed the last disclosed gap from v1.0.31 — tested `drive_share_invite` for real, against the user's own Gmail address, through the actual MCP JSON-RPC server. Found a real bug in the process.
package/dist/cli.js CHANGED
@@ -9,7 +9,7 @@
9
9
  import { DriveService, } from "./services/drive.js";
10
10
  import { DriveCliError, DriveCliNotFoundError, DriveNotAuthenticatedError, DriveParseError } from "./utils/errors.js";
11
11
  import { checkCliAvailable } from "./utils/subprocess.js";
12
- import { validateRemotePath, validateLocalPath, validateEmail, validateMessage, validateName } from "./utils/validation.js";
12
+ import { validateRemotePath, validateLocalPath, validateEmail, validateMessage, validateName, validateFlagValue } from "./utils/validation.js";
13
13
  const drive = new DriveService();
14
14
  const args = process.argv.slice(2);
15
15
  const jsonMode = args.includes("--json");
@@ -288,8 +288,10 @@ async function run() {
288
288
  console.error(`Invalid --role value: ${roleRaw}. Must be viewer or editor.`);
289
289
  process.exit(1);
290
290
  }
291
- const password = getFlag("--password");
292
- const expiration = getFlag("--expiration");
291
+ const rawPassword = getFlag("--password");
292
+ const rawExpiration = getFlag("--expiration");
293
+ const password = rawPassword ? validateFlagValue(rawPassword, "password") : undefined;
294
+ const expiration = rawExpiration ? validateFlagValue(rawExpiration, "expiration") : undefined;
293
295
  print(await drive.shareSetUrl(setUrlPath, roleRaw, password, expiration));
294
296
  }
295
297
  else if (sub === "remove-url") {
@@ -344,20 +346,38 @@ async function run() {
344
346
  print(await drive.listInvitations());
345
347
  }
346
348
  else if (sub === "accept") {
347
- const acceptUid = rest[0];
348
- if (!acceptUid) {
349
+ const rawAcceptUid = rest[0];
350
+ if (!rawAcceptUid) {
349
351
  console.error("Usage: invitation accept <uid>");
350
352
  process.exit(1);
351
353
  }
354
+ let acceptUid;
355
+ try {
356
+ acceptUid = validateFlagValue(rawAcceptUid, "uid");
357
+ }
358
+ catch (e) {
359
+ console.error(e instanceof Error ? e.message : String(e));
360
+ process.exit(1);
361
+ return;
362
+ }
352
363
  await drive.invitationAccept(acceptUid);
353
364
  console.log("Invitation accepted.");
354
365
  }
355
366
  else if (sub === "reject") {
356
- const rejectUid = rest[0];
357
- if (!rejectUid) {
367
+ const rawRejectUid = rest[0];
368
+ if (!rawRejectUid) {
358
369
  console.error("Usage: invitation reject <uid>");
359
370
  process.exit(1);
360
371
  }
372
+ let rejectUid;
373
+ try {
374
+ rejectUid = validateFlagValue(rawRejectUid, "uid");
375
+ }
376
+ catch (e) {
377
+ console.error(e instanceof Error ? e.message : String(e));
378
+ process.exit(1);
379
+ return;
380
+ }
361
381
  await drive.invitationReject(rejectUid);
362
382
  console.log("Invitation rejected.");
363
383
  }
@@ -371,21 +391,43 @@ async function run() {
371
391
  print(await drive.listAlbums());
372
392
  }
373
393
  else if (sub === "create") {
374
- const albumName = rest[0];
375
- if (!albumName) {
394
+ const rawAlbumName = rest[0];
395
+ if (!rawAlbumName) {
376
396
  console.error("Usage: album create <name>");
377
397
  process.exit(1);
398
+ return;
399
+ }
400
+ let albumName;
401
+ try {
402
+ albumName = validateName(rawAlbumName);
403
+ }
404
+ catch (e) {
405
+ console.error(e instanceof Error ? e.message : String(e));
406
+ process.exit(1);
407
+ return;
378
408
  }
379
409
  await drive.createAlbum(albumName);
380
410
  console.log(`Album created: ${albumName}`);
381
411
  }
382
412
  else if (sub === "update") {
383
413
  const albumPath = requirePath(rest[0], "album update <path> [--name] [--cover-photo-uid]");
384
- const newName = getFlag("--name");
385
- const coverPhotoUid = getFlag("--cover-photo-uid");
386
- if (!newName && !coverPhotoUid) {
414
+ const rawNewName = getFlag("--name");
415
+ const rawCoverPhotoUid = getFlag("--cover-photo-uid");
416
+ if (!rawNewName && !rawCoverPhotoUid) {
387
417
  console.error("Usage: album update <path> [--name <name>] [--cover-photo-uid <uid>] (at least one required)");
388
418
  process.exit(1);
419
+ return;
420
+ }
421
+ let newName;
422
+ let coverPhotoUid;
423
+ try {
424
+ newName = rawNewName ? validateName(rawNewName) : undefined;
425
+ coverPhotoUid = rawCoverPhotoUid ? validateName(rawCoverPhotoUid) : undefined;
426
+ }
427
+ catch (e) {
428
+ console.error(e instanceof Error ? e.message : String(e));
429
+ process.exit(1);
430
+ return;
389
431
  }
390
432
  await drive.updateAlbum(albumPath, newName, coverPhotoUid);
391
433
  console.log(`Album updated: ${albumPath}`);
package/dist/index.js CHANGED
@@ -7,7 +7,7 @@ import { DriveService, } from "./services/drive.js";
7
7
  import { isMainModule } from "./utils/isMainModule.js";
8
8
  import { checkCliAvailable } from "./utils/subprocess.js";
9
9
  import { DriveCliNotFoundError, DriveCliError, DriveNotAuthenticatedError, DriveParseError, } from "./utils/errors.js";
10
- import { validateRemotePath, validateLocalPath, validateEmail, validateMessage, validateName } from "./utils/validation.js";
10
+ import { validateRemotePath, validateLocalPath, validateEmail, validateMessage, validateName, validateFlagValue } from "./utils/validation.js";
11
11
  import { logger } from "./utils/logger.js";
12
12
  import { getSyncRoot, readSyncFile, writeSyncFile } from "./utils/syncfs.js";
13
13
  const require = createRequire(import.meta.url);
@@ -158,7 +158,7 @@ const TOOLS = [
158
158
  "E.g. downloading /my-files/report.pdf with localPath '/tmp/out' produces /tmp/out/report.pdf, not /tmp/out itself as a file — confirmed live against the real CLI (v0.8.0). " +
159
159
  "For folders, downloads recursively. " +
160
160
  "Conflict strategies are set separately for files and folders (CLI v0.8.0+) — both default to 'skip'. " +
161
- "Returns {downloaded} count — not the actual local path; construct it as localPath + the remote item's basename if you need it. " +
161
+ "Returns {downloaded, skipped, failed} counts — not the actual local path; construct it as localPath + the remote item's basename if you need it. Fails the call if any file failed to download. " +
162
162
  "Do not use to move files within Drive (use drive_move) or to read a small text file's contents (use drive_read_file if PROTON_DRIVE_SYNC_PATH is set).",
163
163
  annotations: { openWorldHint: true },
164
164
  inputSchema: {
@@ -743,7 +743,7 @@ const TOOLS = [
743
743
  {
744
744
  name: "photos_list_timeline",
745
745
  description: "List photos in your Proton Photos timeline (your full photo library, not scoped to an album). Requires authentication. " +
746
- "Returns [{nodeUid}] by default — pass loadDetails=true for full node metadata (slower; buffers the whole list in memory first). " +
746
+ "Returns [{nodeUid}] by default — pass loadDetails=true for [{nodeUid, name, mediaType, creationTime, totalStorageSize, captureTime, tags}] (slower; buffers the whole list in memory first). " +
747
747
  "Use photos_download to download items by path, or photos_add_to_album to add them to an album.",
748
748
  annotations: { readOnlyHint: true, idempotentHint: true },
749
749
  inputSchema: {
@@ -926,7 +926,11 @@ export async function main() {
926
926
  if (!["skip", "merge", "rename", "remove"].includes(fodcs)) {
927
927
  return fail(`folderConflictStrategy must be skip, merge, rename, or remove`);
928
928
  }
929
- return ok(await drive.download(validateRemotePath(a.remotePath), validateLocalPath(a.localPath), fdcs, fodcs));
929
+ const downloadResult = await drive.download(validateRemotePath(a.remotePath), validateLocalPath(a.localPath), fdcs, fodcs);
930
+ if (downloadResult.failed > 0) {
931
+ return fail(`Download completed with ${downloadResult.failed} failed file(s). downloaded=${downloadResult.downloaded} skipped=${downloadResult.skipped}`);
932
+ }
933
+ return ok(downloadResult);
930
934
  }
931
935
  case "drive_rename": {
932
936
  const renamePath = validateRemotePath(a.path);
@@ -999,13 +1003,15 @@ export async function main() {
999
1003
  case "drive_invitation_accept": {
1000
1004
  if (typeof a.uid !== "string" || !a.uid)
1001
1005
  return fail("uid must be a non-empty string");
1002
- await drive.invitationAccept(a.uid);
1006
+ const acceptUid = validateFlagValue(a.uid, "uid");
1007
+ await drive.invitationAccept(acceptUid);
1003
1008
  return ok({ message: "Invitation accepted." });
1004
1009
  }
1005
1010
  case "drive_invitation_reject": {
1006
1011
  if (typeof a.uid !== "string" || !a.uid)
1007
1012
  return fail("uid must be a non-empty string");
1008
- await drive.invitationReject(a.uid);
1013
+ const rejectUid = validateFlagValue(a.uid, "uid");
1014
+ await drive.invitationReject(rejectUid);
1009
1015
  return ok({ message: "Invitation rejected." });
1010
1016
  }
1011
1017
  case "drive_share_leave": {
@@ -1018,8 +1024,8 @@ export async function main() {
1018
1024
  const role = typeof a.role === "string" ? a.role : "viewer";
1019
1025
  if (!["viewer", "editor"].includes(role))
1020
1026
  return fail("role must be viewer or editor");
1021
- const password = typeof a.password === "string" ? a.password : undefined;
1022
- const expiration = typeof a.expiration === "string" ? a.expiration : undefined;
1027
+ const password = typeof a.password === "string" && a.password ? validateFlagValue(a.password, "password") : undefined;
1028
+ const expiration = typeof a.expiration === "string" && a.expiration ? validateFlagValue(a.expiration, "expiration") : undefined;
1023
1029
  const link = await drive.shareSetUrl(setUrlPath, role, password, expiration);
1024
1030
  return ok(link);
1025
1031
  }
@@ -1041,15 +1047,16 @@ export async function main() {
1041
1047
  case "photos_create_album": {
1042
1048
  if (typeof a.name !== "string" || !a.name.trim())
1043
1049
  return fail("name must be a non-empty string");
1044
- await drive.createAlbum(a.name.trim());
1045
- return ok({ message: `Album created: ${a.name.trim()}` });
1050
+ const albumName = validateName(a.name);
1051
+ await drive.createAlbum(albumName);
1052
+ return ok({ message: `Album created: ${albumName}` });
1046
1053
  }
1047
1054
  case "photos_update_album": {
1048
1055
  const updateAlbumPath = validateRemotePath(a.albumPath);
1049
1056
  if (!updateAlbumPath.startsWith("/albums/"))
1050
1057
  return fail("albumPath must start with /albums/");
1051
- const newName = typeof a.name === "string" && a.name.trim() ? a.name.trim() : undefined;
1052
- const coverPhotoUid = typeof a.coverPhotoUid === "string" && a.coverPhotoUid.trim() ? a.coverPhotoUid.trim() : undefined;
1058
+ const newName = typeof a.name === "string" && a.name.trim() ? validateName(a.name) : undefined;
1059
+ const coverPhotoUid = typeof a.coverPhotoUid === "string" && a.coverPhotoUid.trim() ? validateName(a.coverPhotoUid) : undefined;
1053
1060
  if (!newName && !coverPhotoUid)
1054
1061
  return fail("At least one of name or coverPhotoUid must be provided");
1055
1062
  await drive.updateAlbum(updateAlbumPath, newName, coverPhotoUid);
@@ -1123,8 +1130,9 @@ export async function main() {
1123
1130
  const syncRoot = getSyncRoot();
1124
1131
  if (!syncRoot)
1125
1132
  return fail("PROTON_DRIVE_SYNC_PATH is not set. Set it to the root of your Proton Drive sync folder.");
1126
- const content = await readSyncFile(syncRoot, validateRemotePath(a.path));
1127
- return ok({ path: a.path, content });
1133
+ const readPath = validateRemotePath(a.path);
1134
+ const content = await readSyncFile(syncRoot, readPath);
1135
+ return ok({ path: readPath, content });
1128
1136
  }
1129
1137
  case "drive_write_file": {
1130
1138
  const syncRoot = getSyncRoot();
@@ -1132,8 +1140,9 @@ export async function main() {
1132
1140
  return fail("PROTON_DRIVE_SYNC_PATH is not set. Set it to the root of your Proton Drive sync folder.");
1133
1141
  if (typeof a.content !== "string")
1134
1142
  return fail("content must be a string");
1135
- await writeSyncFile(syncRoot, validateRemotePath(a.path), a.content);
1136
- return ok({ message: `Written: ${a.path}` });
1143
+ const writePath = validateRemotePath(a.path);
1144
+ await writeSyncFile(syncRoot, writePath, a.content);
1145
+ return ok({ message: `Written: ${writePath}` });
1137
1146
  }
1138
1147
  default:
1139
1148
  throw new McpError(ErrorCode.MethodNotFound, `Unknown tool: ${name}`);
@@ -17,6 +17,18 @@ function unwrapResult(value, fallback = "") {
17
17
  }
18
18
  return typeof value === "string" ? value : fallback;
19
19
  }
20
+ // The CLI's own path syntax requires escaping a literal '/' inside a node
21
+ // name with a backslash (confirmed in `filesystem info --help`: "Escape /
22
+ // in node names with a backslash"). Our own validateName() rejects '/' in
23
+ // names WE create, but an item created by any other Proton Drive client
24
+ // (web, desktop, mobile) can still have a literal '/' in its name. Confirmed
25
+ // live: naively joining such a name into a path ("/parent/raw/slash") makes
26
+ // every path-based tool (info, download, move, rename, delete, ...) fail
27
+ // with "Node not found", while the correctly escaped form ("/parent/raw\/slash")
28
+ // resolves. list() must escape names before building the path it returns.
29
+ function escapeNameForPath(name) {
30
+ return name.replace(/\//g, "\\/");
31
+ }
20
32
  // Strips "<package-name>@" and "+<hash>" from a version token like
21
33
  // "cli-drive@0.8.0+06e8c605", leaving "0.8.0". Falls back to the raw
22
34
  // token if it doesn't match, so an unexpected future format still shows
@@ -87,7 +99,7 @@ export class DriveService {
87
99
  const name = unwrapResult(item.name, "[unnamed]");
88
100
  return {
89
101
  name,
90
- path: posixPath.join(remotePath, name),
102
+ path: posixPath.join(remotePath, escapeNameForPath(name)),
91
103
  type: item.type === "folder" || item.type === "album" ? "folder" : "file",
92
104
  size: typeof item.totalStorageSize === "number" ? item.totalStorageSize : undefined,
93
105
  modifiedAt: typeof item.modificationTime === "string" ? item.modificationTime : undefined,
@@ -105,7 +117,6 @@ export class DriveService {
105
117
  fileConflictStrategy,
106
118
  "--folder-conflict-strategy",
107
119
  folderConflictStrategy,
108
- "--skip-thumbnails",
109
120
  ]);
110
121
  // Real shape is TransferSummary: {transferredItems, transferredBytes,
111
122
  // skippedItems, failedItems, failures}. Confirmed live — the old
@@ -125,12 +136,17 @@ export class DriveService {
125
136
  "--file-conflict-strategy", fileConflictStrategy,
126
137
  "--folder-conflict-strategy", folderConflictStrategy,
127
138
  ]);
128
- // Same real shape as upload — TransferSummary, not {downloaded}.
139
+ // Same real shape as upload — TransferSummary, not {downloaded}. Previously
140
+ // this dropped skippedItems/failedItems entirely, and the MCP dispatch
141
+ // never checked for partial failure the way drive_upload's does — a
142
+ // download where some files failed silently reported success.
129
143
  const summary = this.parseTransferSummary(result);
130
144
  return {
131
145
  path: remotePath,
132
146
  localPath,
133
147
  downloaded: summary.transferredItems,
148
+ skipped: summary.skippedItems,
149
+ failed: summary.failedItems,
134
150
  };
135
151
  }
136
152
  // The CLI has no `mkdir` — it's `create-folder <parentPath> <name>`.
@@ -376,9 +392,24 @@ export class DriveService {
376
392
  return [];
377
393
  if (!Array.isArray(result))
378
394
  throw new DriveParseError(`Expected array from photo timeline, got: ${JSON.stringify(result).slice(0, 100)}`);
379
- return result.map((item) => ({
380
- nodeUid: String(item.nodeUid ?? item.uid ?? ""),
381
- }));
395
+ // Without --load-details the CLI returns {nodeUid, captureTime, tags}.
396
+ // With --load-details it returns full node objects ({uid, name, mediaType,
397
+ // creationTime, totalStorageSize, photo: {captureTime, tags}, ...}) —
398
+ // confirmed live. Previously this always extracted only nodeUid, so
399
+ // loadDetails=true paid the CLI's slower buffered call for nothing: the
400
+ // "full node metadata" the tool description promises was thrown away.
401
+ return result.map((item) => {
402
+ const photo = (item.photo ?? {});
403
+ return {
404
+ nodeUid: String(item.nodeUid ?? item.uid ?? ""),
405
+ name: item.name !== undefined ? unwrapResult(item.name) || undefined : undefined,
406
+ mediaType: typeof item.mediaType === "string" ? item.mediaType : undefined,
407
+ creationTime: typeof item.creationTime === "string" ? item.creationTime : undefined,
408
+ totalStorageSize: typeof item.totalStorageSize === "number" ? item.totalStorageSize : undefined,
409
+ captureTime: typeof (item.captureTime ?? photo.captureTime) === "string" ? (item.captureTime ?? photo.captureTime) : undefined,
410
+ tags: Array.isArray(item.tags ?? photo.tags) ? (item.tags ?? photo.tags) : undefined,
411
+ };
412
+ });
382
413
  }
383
414
  async photoDownload(remotePaths, localFolder, conflictStrategy = "skip") {
384
415
  const result = await this.run([
@@ -6,7 +6,12 @@ const CLI_BINARY = process.env["PROTON_DRIVE_BIN"] ?? "proton-drive";
6
6
  const DEFAULT_TIMEOUT_MS = 60_000;
7
7
  // Upload and download transfer actual file bytes — use a much longer timeout.
8
8
  const TRANSFER_TIMEOUT_MS = 30 * 60_000; // 30 minutes
9
- const TRANSFER_COMMANDS = new Set(["upload", "download"]);
9
+ // copy/move can process large folder trees server-side (re-encrypting or
10
+ // re-sharing many files), just like upload/download — they need the same
11
+ // extended timeout. Previously only upload/download got it; a large
12
+ // drive_copy or drive_move would be SIGKILL'd after the 60s default,
13
+ // potentially leaving Drive in a partially-copied/moved state.
14
+ const TRANSFER_COMMANDS = new Set(["upload", "download", "copy", "move"]);
10
15
  const TRANSFER_GROUPS = new Set(["filesystem", "photo"]);
11
16
  function timeoutFor(args) {
12
17
  // args[0] is the group (e.g. "filesystem"), args[1] is the subcommand
@@ -31,18 +31,42 @@ export function validateMessage(message) {
31
31
  return m;
32
32
  }
33
33
  // For bare filename/name arguments passed as a positional CLI arg (not a
34
- // path) — e.g. rename's newName. Guards against flag injection the same way
35
- // validatePath/validateEmail/validateMessage do.
34
+ // path) — e.g. rename's newName, album create/update's name. Guards against
35
+ // flag injection the same way validatePath/validateEmail/validateMessage do.
36
+ //
37
+ // Also rejects '/': confirmed live that renaming a file to a name containing
38
+ // '/' (e.g. "evil/nested.txt") succeeds on the CLI side, but the resulting
39
+ // item's actual Drive name contains the slash literally — it is not two path
40
+ // segments. Every other tool computes this item's path by joining parent +
41
+ // name (see list()), producing a path that LOOKS like a nested folder but
42
+ // isn't, and that path then fails to resolve ("Node not found") in every
43
+ // other tool (info, download, move, delete, ...). The item becomes
44
+ // unreachable except by listing its real parent and matching by prefix.
36
45
  export function validateName(name) {
37
46
  const n = String(name ?? "").trim();
38
47
  if (!n)
39
48
  throw new Error("name must not be empty");
40
49
  if (n.startsWith("-"))
41
50
  throw new Error(`name must not start with '-': ${n}`);
51
+ if (n.includes("/"))
52
+ throw new Error(`name must not contain '/': ${n}`);
42
53
  if (CONTROL_RE.test(n))
43
54
  throw new Error("name contains control characters");
44
55
  return n;
45
56
  }
57
+ // Guards a value passed to a bare CLI flag (e.g. --password, --expiration)
58
+ // against flag injection — confirmed live: `sharing set-url --password "-x"`
59
+ // makes the real CLI's arg parser treat "-x" as an unknown flag and print
60
+ // usage instead of setting the password. Unlike validateName, allows '/'
61
+ // since these aren't Drive item names.
62
+ export function validateFlagValue(value, label) {
63
+ const v = String(value ?? "").trim();
64
+ if (v.startsWith("-"))
65
+ throw new Error(`${label} must not start with '-': ${v}`);
66
+ if (CONTROL_RE.test(v))
67
+ throw new Error(`${label} contains control characters`);
68
+ return v;
69
+ }
46
70
  export function validateLocalPath(path) {
47
71
  const p = validatePath(path);
48
72
  if (!isAbsolute(p))
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "proton-drive-mcp",
3
- "version": "1.0.32",
3
+ "version": "1.0.34",
4
4
  "description": "MCP server and CLI that gives Claude full access to Proton Drive — upload, download, share, and manage your end-to-end encrypted files without leaving the conversation.",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",