@n0zer0d4y/vulcan-file-ops 1.2.14 → 1.3.0

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.
@@ -1,14 +1,67 @@
1
1
  import fs from "fs/promises";
2
+ import os from "os";
2
3
  import path from "path";
3
4
  import { minimatch } from "minimatch";
4
5
  import { zodToJsonSchema } from "zod-to-json-schema";
5
6
  import { ToolSchema } from "@modelcontextprotocol/sdk/types.js";
6
7
  import { expandHome, normalizePath } from "../utils/path-utils.js";
7
- import { isPathWithinAllowedDirectories } from "../utils/path-validation.js";
8
8
  import { MakeDirectoryArgsSchema, ListDirectoryArgsSchema, MoveFileArgsSchema, GetFileInfoArgsSchema, RegisterDirectoryArgsSchema, FileOperationsArgsSchema, DeleteFilesArgsSchema, } from "../types/index.js";
9
- import { validatePath, getFileStats, formatSize, getAllowedDirectories, setAllowedDirectories, shouldIgnoreFolder, } from "../utils/lib.js";
9
+ import { validatePath, getFileStats, formatSize, getAllowedDirectories, setAllowedDirectories, shouldIgnoreFolder, isPathCanonicallyAllowed, ensureDirectoryWithinAllowed, } from "../utils/lib.js";
10
10
  import { createEmptyObjectSchema, createPathArraySchema, sanitizeToolInputSchema, } from "../utils/tool-schema.js";
11
11
  const ToolInputSchema = ToolSchema.shape.inputSchema;
12
+ // ============================================================================
13
+ // RUNTIME DIRECTORY REGISTRATION POLICY (register_directory)
14
+ // ============================================================================
15
+ //
16
+ // Security (VFO-07): register_directory widens the sandbox, so by default it
17
+ // requires a human to confirm each registration through the MCP client
18
+ // (elicitation). The server entry point installs the consent handler; when
19
+ // none is installed (or the client cannot prompt), registration is refused.
20
+ export const RUNTIME_REGISTRATION_POLICIES = [
21
+ "confirm",
22
+ "allow",
23
+ "deny",
24
+ ];
25
+ let runtimeRegistrationPolicy = "confirm";
26
+ let directoryRegistrationConsentHandler = null;
27
+ export function isRuntimeRegistrationPolicy(value) {
28
+ return RUNTIME_REGISTRATION_POLICIES.includes(value);
29
+ }
30
+ export function setRuntimeRegistrationPolicy(policy) {
31
+ if (!isRuntimeRegistrationPolicy(policy)) {
32
+ throw new Error(`Invalid runtime registration policy: ${policy}. Expected one of: ${RUNTIME_REGISTRATION_POLICIES.join(", ")}`);
33
+ }
34
+ runtimeRegistrationPolicy = policy;
35
+ }
36
+ export function getRuntimeRegistrationPolicy() {
37
+ return runtimeRegistrationPolicy;
38
+ }
39
+ export function setDirectoryRegistrationConsentHandler(handler) {
40
+ directoryRegistrationConsentHandler = handler;
41
+ }
42
+ function samePath(a, b) {
43
+ const left = path.resolve(a);
44
+ const right = path.resolve(b);
45
+ return process.platform === "win32"
46
+ ? left.toLowerCase() === right.toLowerCase()
47
+ : left === right;
48
+ }
49
+ async function describeSensitiveDirectory(realPath) {
50
+ if (path.parse(realPath).root === realPath) {
51
+ return "a filesystem root";
52
+ }
53
+ let home = os.homedir();
54
+ try {
55
+ home = await fs.realpath(home);
56
+ }
57
+ catch {
58
+ // Fall back to the lexical home directory
59
+ }
60
+ if (home && samePath(realPath, home)) {
61
+ return "the user's home directory";
62
+ }
63
+ return null;
64
+ }
12
65
  export function getFileSystemTools() {
13
66
  // Get current allowed directories for dynamic descriptions
14
67
  const currentAllowedDirs = getAllowedDirectories();
@@ -65,7 +118,10 @@ export function getFileSystemTools() {
65
118
  name: "register_directory",
66
119
  description: "Register a directory for access. This allows the AI to dynamically gain access " +
67
120
  "to directories specified by the human user during conversation. The directory " +
68
- "and all its subdirectories will become accessible for all filesystem operations." +
121
+ "and all its subdirectories will become accessible for all filesystem operations. " +
122
+ "By default the user must confirm each registration in their MCP client " +
123
+ "(registration is refused if the client cannot show a confirmation prompt); " +
124
+ "filesystem roots and the home directory cannot be registered at runtime." +
69
125
  generateApprovedDirsText(),
70
126
  inputSchema: sanitizeToolInputSchema(zodToJsonSchema(RegisterDirectoryArgsSchema)),
71
127
  },
@@ -456,25 +512,26 @@ export async function handleFileSystemTool(name, args) {
456
512
  const pathsToCreate = Array.isArray(pathsInput)
457
513
  ? pathsInput
458
514
  : [pathsInput];
459
- // Validate all paths first (atomic - fail before any creation)
460
- const allowedDirs = getAllowedDirectories();
461
- const validatedPaths = pathsToCreate.map((dirPath) => {
515
+ // Validate all paths first (atomic - fail before any creation).
516
+ // Security: the check is canonical (lexical + realpath + physical), so a
517
+ // symlink or junction inside an allowed directory that points outside it
518
+ // is rejected here instead of being followed by mkdir (VFO-05).
519
+ const validatedPaths = [];
520
+ for (const dirPath of pathsToCreate) {
462
521
  const expandedPath = expandHome(dirPath);
463
- // Resolve to absolute path - required by isPathWithinAllowedDirectories
464
522
  const absolutePath = path.isAbsolute(expandedPath)
465
523
  ? path.resolve(expandedPath)
466
524
  : path.resolve(process.cwd(), expandedPath);
467
- const normalized = normalizePath(absolutePath);
468
- // Use secure path validation function to prevent prefix collision attacks
469
- // (CVE-2025-54794 pattern: ensures path separator is required, not just prefix match)
470
- if (!isPathWithinAllowedDirectories(normalized, allowedDirs)) {
525
+ if (!(await isPathCanonicallyAllowed(absolutePath, process.cwd()))) {
471
526
  throw new Error(`Access denied: Path ${dirPath} is not within allowed directories`);
472
527
  }
473
- return { original: dirPath, normalized };
474
- });
475
- // All validated - now create them concurrently
476
- const results = await Promise.all(validatedPaths.map(async ({ original, normalized }) => {
477
- await fs.mkdir(normalized, { recursive: true });
528
+ validatedPaths.push({ original: dirPath, absolutePath });
529
+ }
530
+ // All validated - now create them. ensureDirectoryWithinAllowed creates
531
+ // missing segments one at a time and re-checks the realpath of each, so
532
+ // a link swapped in after validation cannot redirect creation.
533
+ const results = await Promise.all(validatedPaths.map(async ({ original, absolutePath }) => {
534
+ await ensureDirectoryWithinAllowed(absolutePath);
478
535
  return original;
479
536
  }));
480
537
  // Format response based on single vs batch
@@ -506,6 +563,35 @@ export async function handleFileSystemTool(name, args) {
506
563
  }
507
564
  const validSourcePath = await validatePath(parsed.data.source);
508
565
  const validDestPath = await validatePath(parsed.data.destination);
566
+ // fs.rename silently replaces an existing destination; move_file is
567
+ // documented to never overwrite.
568
+ try {
569
+ const destStats = await fs.lstat(validDestPath, { bigint: true });
570
+ const sourceStats = await fs.lstat(validSourcePath, { bigint: true });
571
+ const sameFile = destStats.ino === sourceStats.ino && destStats.dev === sourceStats.dev;
572
+ // A case-only rename on a case-insensitive file system "finds" the
573
+ // source itself at the destination; that is not an overwrite.
574
+ // validatePath returns the existing (old-case) real path, so rename to
575
+ // the requested name inside the same, already-validated directory.
576
+ if (sameFile) {
577
+ await fs.rename(validSourcePath, path.join(path.dirname(validDestPath), path.basename(parsed.data.destination)));
578
+ return {
579
+ content: [
580
+ {
581
+ type: "text",
582
+ text: `Successfully moved ${parsed.data.source} to ${parsed.data.destination}`,
583
+ },
584
+ ],
585
+ };
586
+ }
587
+ throw new Error(`Destination already exists: ${parsed.data.destination}. ` +
588
+ `move_file never overwrites; use file_operations with onConflict: "overwrite" to replace it.`);
589
+ }
590
+ catch (error) {
591
+ if (error.code !== "ENOENT") {
592
+ throw error;
593
+ }
594
+ }
509
595
  await fs.rename(validSourcePath, validDestPath);
510
596
  return {
511
597
  content: [
@@ -541,43 +627,82 @@ export async function handleFileSystemTool(name, args) {
541
627
  }
542
628
  const expandedPath = expandHome(parsed.data.path);
543
629
  const absolutePath = path.resolve(expandedPath);
544
- const normalizedPath = normalizePath(absolutePath);
545
- // Validate that the path exists and is a directory
630
+ // Resolve links so the directory that is shown to the user and stored
631
+ // in the allowed list is the one that will actually be accessed.
632
+ let realPath;
546
633
  try {
547
- const stats = await fs.stat(absolutePath);
634
+ realPath = await fs.realpath(absolutePath);
635
+ const stats = await fs.stat(realPath);
548
636
  if (!stats.isDirectory()) {
549
637
  throw new Error(`Path ${absolutePath} is not a directory`);
550
638
  }
551
639
  }
552
640
  catch (error) {
553
- if (error.code === "ENOENT") {
641
+ const code = error.code;
642
+ if (code === "ENOENT" || code === "ENOTDIR") {
554
643
  throw new Error(`Directory ${absolutePath} does not exist`);
555
644
  }
556
645
  throw error;
557
646
  }
558
- // Add to allowed directories
559
- const currentDirs = getAllowedDirectories();
560
- if (!currentDirs.includes(normalizedPath)) {
561
- setAllowedDirectories([...currentDirs, normalizedPath]);
647
+ const normalizedPath = normalizePath(realPath) || realPath;
648
+ // Already accessible: nothing to widen, so no prompt is needed
649
+ if (getAllowedDirectories().includes(normalizedPath)) {
562
650
  return {
563
651
  content: [
564
652
  {
565
653
  type: "text",
566
- text: `Successfully registered directory: ${parsed.data.path} (${normalizedPath})`,
654
+ text: `Directory already registered: ${parsed.data.path} (${normalizedPath})`,
567
655
  },
568
656
  ],
569
657
  };
570
658
  }
571
- else {
659
+ if (await isPathCanonicallyAllowed(realPath, process.cwd())) {
572
660
  return {
573
661
  content: [
574
662
  {
575
663
  type: "text",
576
- text: `Directory already registered: ${parsed.data.path} (${normalizedPath})`,
664
+ text: `Directory already accessible: ${parsed.data.path} (${normalizedPath}) is inside an allowed directory`,
577
665
  },
578
666
  ],
579
667
  };
580
668
  }
669
+ const policy = getRuntimeRegistrationPolicy();
670
+ if (policy === "deny") {
671
+ throw new Error(`Runtime directory registration is disabled on this server (--runtime-registration deny). ` +
672
+ `To grant access to ${normalizedPath}, add it to --approved-folders in the MCP server configuration.`);
673
+ }
674
+ if (policy !== "allow") {
675
+ const sensitive = await describeSensitiveDirectory(realPath);
676
+ if (sensitive) {
677
+ throw new Error(`Refusing to register ${normalizedPath}: it is ${sensitive}. ` +
678
+ `Register a more specific folder, add it to --approved-folders, ` +
679
+ `or start the server with --runtime-registration allow.`);
680
+ }
681
+ const consent = directoryRegistrationConsentHandler
682
+ ? await directoryRegistrationConsentHandler(normalizedPath)
683
+ : "unsupported";
684
+ if (consent === "unsupported") {
685
+ throw new Error(`Cannot register ${normalizedPath}: registering directories at runtime requires user confirmation, ` +
686
+ `but this MCP client does not support confirmation prompts (MCP elicitation). ` +
687
+ `Add the folder via --approved-folders, or start the server with --runtime-registration allow.`);
688
+ }
689
+ if (consent !== "accepted") {
690
+ throw new Error(`User declined access to ${normalizedPath}`);
691
+ }
692
+ }
693
+ // Re-read: the list may have changed while waiting for the user
694
+ const currentDirs = getAllowedDirectories();
695
+ if (!currentDirs.includes(normalizedPath)) {
696
+ setAllowedDirectories([...currentDirs, normalizedPath]);
697
+ }
698
+ return {
699
+ content: [
700
+ {
701
+ type: "text",
702
+ text: `Successfully registered directory: ${parsed.data.path} (${normalizedPath})`,
703
+ },
704
+ ],
705
+ };
581
706
  }
582
707
  case "list_allowed_directories": {
583
708
  return {
@@ -666,6 +791,11 @@ export async function handleFileSystemTool(name, args) {
666
791
  await fs.rename(file.validSource, file.validDest);
667
792
  break;
668
793
  case "copy":
794
+ // validSource is the realpath returned by validatePath, so a
795
+ // top-level source that is itself a symlink/junction has already
796
+ // been resolved and its real target checked against the allowed
797
+ // directories. Links *inside* a copied directory are refused by
798
+ // copyDirectoryRecursive.
669
799
  const stats = await fs.stat(file.validSource);
670
800
  if (stats.isDirectory()) {
671
801
  await copyDirectoryRecursive(file.validSource, file.validDest);
@@ -903,22 +1033,75 @@ export async function handleFileSystemTool(name, args) {
903
1033
  throw new Error(`Unknown filesystem tool: ${name}`);
904
1034
  }
905
1035
  }
906
- // Helper function for recursive directory copying
907
- async function copyDirectoryRecursive(source, destination) {
908
- // Create destination directory
909
- await fs.mkdir(destination, { recursive: true });
910
- // Read source directory
911
- const entries = await fs.readdir(source, { withFileTypes: true });
912
- // Copy all entries
1036
+ function symlinkCopyError(entryPath) {
1037
+ return new Error(`Refusing to copy symbolic link inside directory: ${entryPath} ` +
1038
+ `(symbolic links are not copied for security reasons)`);
1039
+ }
1040
+ async function collectDirectoryCopyPlan(root, relativeDir = "", plan = []) {
1041
+ const entries = await fs.readdir(path.join(root, relativeDir), {
1042
+ withFileTypes: true,
1043
+ });
913
1044
  for (const entry of entries) {
914
- const sourcePath = path.join(source, entry.name);
915
- const destPath = path.join(destination, entry.name);
916
- if (entry.isDirectory()) {
917
- await copyDirectoryRecursive(sourcePath, destPath);
1045
+ const relativePath = path.join(relativeDir, entry.name);
1046
+ const entryPath = path.join(root, relativePath);
1047
+ // lstat never follows links; Node reports junctions as symbolic links too
1048
+ const stats = await fs.lstat(entryPath);
1049
+ if (entry.isSymbolicLink() || stats.isSymbolicLink()) {
1050
+ throw symlinkCopyError(entryPath);
1051
+ }
1052
+ if (stats.isDirectory()) {
1053
+ plan.push({ relativePath, type: "directory" });
1054
+ await collectDirectoryCopyPlan(root, relativePath, plan);
1055
+ }
1056
+ else if (stats.isFile()) {
1057
+ plan.push({ relativePath, type: "file" });
918
1058
  }
919
1059
  else {
920
- await fs.copyFile(sourcePath, destPath);
1060
+ throw new Error(`Refusing to copy special file inside directory: ${entryPath} ` +
1061
+ `(only regular files and directories are copied)`);
1062
+ }
1063
+ }
1064
+ return plan;
1065
+ }
1066
+ async function copyDirectoryRecursive(source, destination) {
1067
+ const relativeDest = path.relative(source, destination);
1068
+ if (relativeDest === "" ||
1069
+ (!relativeDest.startsWith("..") && !path.isAbsolute(relativeDest))) {
1070
+ throw new Error(`Cannot copy a directory into itself: ${source} to ${destination}`);
1071
+ }
1072
+ // Validate the entire source tree before creating or copying anything
1073
+ const plan = await collectDirectoryCopyPlan(source);
1074
+ // Destination directories are created segment by segment with their
1075
+ // realpath re-checked, so an existing link at the destination cannot
1076
+ // redirect the copy outside the allowed directories.
1077
+ await ensureDirectoryWithinAllowed(destination);
1078
+ for (const entry of plan) {
1079
+ const sourcePath = path.join(source, entry.relativePath);
1080
+ const destPath = path.join(destination, entry.relativePath);
1081
+ if (entry.type === "directory") {
1082
+ await ensureDirectoryWithinAllowed(destPath);
1083
+ continue;
1084
+ }
1085
+ // Re-check the source right before copying in case it was swapped for a
1086
+ // link after the tree was validated.
1087
+ const sourceStats = await fs.lstat(sourcePath);
1088
+ if (sourceStats.isSymbolicLink() || !sourceStats.isFile()) {
1089
+ throw symlinkCopyError(sourcePath);
1090
+ }
1091
+ // Never write through an existing link at the destination
1092
+ let destIsLink = false;
1093
+ try {
1094
+ destIsLink = (await fs.lstat(destPath)).isSymbolicLink();
1095
+ }
1096
+ catch (error) {
1097
+ if (error.code !== "ENOENT") {
1098
+ throw error;
1099
+ }
1100
+ }
1101
+ if (destIsLink) {
1102
+ throw new Error(`Refusing to overwrite symbolic link at destination: ${destPath}`);
921
1103
  }
1104
+ await fs.copyFile(sourcePath, destPath);
922
1105
  }
923
1106
  }
924
1107
  //# sourceMappingURL=filesystem-tools.js.map
@@ -1,29 +1,72 @@
1
- import { createReadStream } from "fs";
1
+ import { createReadStream, promises as fs } from "fs";
2
2
  import path from "path";
3
3
  import { zodToJsonSchema } from "zod-to-json-schema";
4
4
  import { ToolSchema } from "@modelcontextprotocol/sdk/types.js";
5
5
  import { ReadFileArgsSchema, AttachImageArgsSchema, ReadMultipleFilesArgsSchema, } from "../types/index.js";
6
- import { validatePath, readFileContent, tailFile, headFile, rangeFile, } from "../utils/lib.js";
6
+ import { validatePath, tailFile, headFile, rangeFile, } from "../utils/lib.js";
7
7
  import { isDocumentFile, parseDocument } from "../utils/document-parser.js";
8
+ import { MAX_IMAGE_ATTACH_BYTES, MAX_IMAGE_ATTACH_TOTAL_BYTES, MAX_TEXT_READ_BYTES, } from "../utils/limits.js";
8
9
  import { createPathArraySchema, sanitizeToolInputSchema, } from "../utils/tool-schema.js";
9
10
  const ToolInputSchema = ToolSchema.shape.inputSchema;
11
+ const formatMB = (bytes) => `${(bytes / 1024 / 1024).toFixed(1)} MB`;
10
12
  // Reads a file as a stream of buffers, concatenates them, and then encodes
11
- // the result to a Base64 string. This is a memory-efficient way to handle
12
- // binary data from a stream before the final encoding.
13
- async function readFileAsBase64Stream(filePath) {
13
+ // the result to a Base64 string. At most `maxBytes` are read: a file that
14
+ // grew past the limit after it was checked is rejected rather than read.
15
+ async function readFileAsBase64Stream(filePath, maxBytes) {
14
16
  return new Promise((resolve, reject) => {
15
- const stream = createReadStream(filePath);
17
+ const stream = createReadStream(filePath, { start: 0, end: maxBytes });
16
18
  const chunks = [];
19
+ let total = 0;
17
20
  stream.on("data", (chunk) => {
21
+ total += chunk.length;
18
22
  chunks.push(chunk);
19
23
  });
20
24
  stream.on("end", () => {
25
+ if (total > maxBytes) {
26
+ reject(new Error(`Image ${path.basename(filePath)} exceeds the ${formatMB(maxBytes)} limit`));
27
+ return;
28
+ }
21
29
  const finalBuffer = Buffer.concat(chunks);
22
30
  resolve(finalBuffer.toString("base64"));
23
31
  });
24
32
  stream.on("error", (err) => reject(err));
25
33
  });
26
34
  }
35
+ /**
36
+ * Read a whole text file ("full" mode), refusing files larger than
37
+ * MAX_TEXT_READ_BYTES (VFO-15). The read itself is bounded too, so a file
38
+ * that grows after the size check (or a device/FIFO that reports size 0)
39
+ * cannot exhaust memory.
40
+ */
41
+ async function readFullTextFile(filePath) {
42
+ const tooLarge = (size) => new Error(`File is too large to read in full mode (${size}; limit ` +
43
+ `${formatMB(MAX_TEXT_READ_BYTES)}). Use mode "head", "tail" or "range" ` +
44
+ `to read part of it.`);
45
+ const handle = await fs.open(filePath, "r");
46
+ try {
47
+ const { size } = await handle.stat();
48
+ if (size > MAX_TEXT_READ_BYTES) {
49
+ throw tooLarge(formatMB(size));
50
+ }
51
+ const buffer = Buffer.alloc(64 * 1024);
52
+ const chunks = [];
53
+ let total = 0;
54
+ for (;;) {
55
+ const { bytesRead } = await handle.read(buffer, 0, buffer.length, null);
56
+ if (bytesRead === 0)
57
+ break;
58
+ total += bytesRead;
59
+ if (total > MAX_TEXT_READ_BYTES) {
60
+ throw tooLarge(`more than ${formatMB(MAX_TEXT_READ_BYTES)}`);
61
+ }
62
+ chunks.push(Buffer.from(buffer.subarray(0, bytesRead)));
63
+ }
64
+ return Buffer.concat(chunks).toString("utf-8");
65
+ }
66
+ finally {
67
+ await handle.close();
68
+ }
69
+ }
27
70
  export function getReadTools() {
28
71
  return [
29
72
  {
@@ -43,7 +86,8 @@ export function getReadTools() {
43
86
  "to the AI model as if uploaded directly by the user, enabling the AI to see " +
44
87
  "and describe visual content, read text in images, analyze diagrams, etc. " +
45
88
  "Supports attaching a single image or multiple images at once. " +
46
- "Supports PNG, JPEG, GIF, WebP, BMP, and SVG formats. " +
89
+ "Supports PNG, JPEG, GIF and WebP (the formats vision models accept). " +
90
+ "SVG files are returned as their XML markup text; BMP is not supported (convert to PNG). " +
47
91
  "Note: This requires the MCP client to support vision capabilities. " +
48
92
  "Only works within allowed directories.",
49
93
  inputSchema: {
@@ -122,7 +166,7 @@ export async function handleReadTool(name, args) {
122
166
  }
123
167
  case "full":
124
168
  default: {
125
- const content = await readFileContent(validPath);
169
+ const content = await readFullTextFile(validPath);
126
170
  return {
127
171
  content: [{ type: "text", text: content }],
128
172
  };
@@ -138,29 +182,58 @@ export async function handleReadTool(name, args) {
138
182
  const paths = Array.isArray(parsed.data.path)
139
183
  ? parsed.data.path
140
184
  : [parsed.data.path];
141
- // Supported image formats only (no audio)
185
+ // Raster formats accepted by vision models. SVG is vector markup (XML),
186
+ // which vision APIs reject as an image, so it is returned as text.
142
187
  const mimeTypes = {
143
188
  ".png": "image/png",
144
189
  ".jpg": "image/jpeg",
145
190
  ".jpeg": "image/jpeg",
146
191
  ".gif": "image/gif",
147
192
  ".webp": "image/webp",
148
- ".bmp": "image/bmp",
149
193
  ".svg": "image/svg+xml",
150
194
  };
151
- // Process all images
152
- const imageContents = await Promise.all(paths.map(async (imagePath) => {
195
+ // Validate all images and check sizes before reading any of them
196
+ const images = await Promise.all(paths.map(async (imagePath) => {
153
197
  const validPath = await validatePath(imagePath);
154
198
  const extension = path.extname(validPath).toLowerCase();
155
199
  const mimeType = mimeTypes[extension];
200
+ if (extension === ".bmp") {
201
+ throw new Error(`BMP images are not accepted by vision models: ${imagePath}. ` +
202
+ `Convert it to PNG or JPEG first.`);
203
+ }
156
204
  if (!mimeType) {
157
205
  throw new Error(`Unsupported image format: ${extension}. ` +
158
- `Supported formats: PNG, JPEG, GIF, WebP, BMP, SVG`);
206
+ `Supported formats: PNG, JPEG, GIF, WebP (SVG is returned as markup text)`);
207
+ }
208
+ const { size } = await fs.stat(validPath);
209
+ return { imagePath, validPath, mimeType, size };
210
+ }));
211
+ // Size caps (VFO-15): per image and per call
212
+ const oversized = images.filter((i) => i.size > MAX_IMAGE_ATTACH_BYTES);
213
+ if (oversized.length > 0) {
214
+ throw new Error(`Image(s) exceed the ${formatMB(MAX_IMAGE_ATTACH_BYTES)} per-image limit: ` +
215
+ oversized
216
+ .map((i) => `${i.imagePath} (${formatMB(i.size)})`)
217
+ .join(", "));
218
+ }
219
+ const totalSize = images.reduce((sum, i) => sum + i.size, 0);
220
+ if (totalSize > MAX_IMAGE_ATTACH_TOTAL_BYTES) {
221
+ throw new Error(`Images total ${formatMB(totalSize)}, which exceeds the ` +
222
+ `${formatMB(MAX_IMAGE_ATTACH_TOTAL_BYTES)} limit per attach_image call. ` +
223
+ `Attach fewer images per call.`);
224
+ }
225
+ const imageContents = await Promise.all(images.map(async ({ imagePath, validPath, mimeType }) => {
226
+ if (mimeType === "image/svg+xml") {
227
+ const markup = await fs.readFile(validPath, "utf-8");
228
+ return {
229
+ type: "text",
230
+ text: `SVG image ${imagePath} (vector markup; vision models accept only PNG, JPEG, GIF and WebP, so the SVG source is provided as text):\n` +
231
+ markup,
232
+ };
159
233
  }
160
- const data = await readFileAsBase64Stream(validPath);
161
234
  return {
162
235
  type: "image",
163
- data: data,
236
+ data: await readFileAsBase64Stream(validPath, MAX_IMAGE_ATTACH_BYTES),
164
237
  mimeType: mimeType,
165
238
  };
166
239
  }));
@@ -219,7 +292,7 @@ export async function handleReadTool(name, args) {
219
292
  break;
220
293
  case "full":
221
294
  default:
222
- content = await readFileContent(validPath);
295
+ content = await readFullTextFile(validPath);
223
296
  break;
224
297
  }
225
298
  return `${fileRequest.path}:\n${content}\n`;