@n0zer0d4y/vulcan-file-ops 1.2.13 → 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.
- package/CHANGELOG.md +99 -27
- package/README.md +153 -118
- package/dist/cli.js +16 -0
- package/dist/server/index.js +150 -39
- package/dist/tools/filesystem-tools.js +222 -39
- package/dist/tools/read-tools.js +89 -16
- package/dist/tools/shell-tool.js +48 -62
- package/dist/tools/write-tools.js +19 -17
- package/dist/types/index.js +6 -3
- package/dist/utils/command-path-extraction.js +169 -174
- package/dist/utils/command-validation.js +62 -10
- package/dist/utils/document-parser.js +43 -5
- package/dist/utils/html-image-sanitizer.js +478 -0
- package/dist/utils/html-to-document.js +75 -11
- package/dist/utils/lib.js +357 -80
- package/dist/utils/limits.js +47 -0
- package/dist/utils/regex-worker.js +262 -0
- package/dist/utils/shell-parser.js +225 -0
- package/dist/utils/zip-guard.js +266 -0
- package/package.json +11 -8
package/dist/tools/read-tools.js
CHANGED
|
@@ -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,
|
|
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.
|
|
12
|
-
//
|
|
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
|
|
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
|
|
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
|
-
//
|
|
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
|
-
//
|
|
152
|
-
const
|
|
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
|
|
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:
|
|
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
|
|
295
|
+
content = await readFullTextFile(validPath);
|
|
223
296
|
break;
|
|
224
297
|
}
|
|
225
298
|
return `${fileRequest.path}:\n${content}\n`;
|
package/dist/tools/shell-tool.js
CHANGED
|
@@ -1,21 +1,28 @@
|
|
|
1
1
|
import { zodToJsonSchema } from "zod-to-json-schema";
|
|
2
2
|
import { ToolSchema } from "@modelcontextprotocol/sdk/types.js";
|
|
3
3
|
import { ShellCommandArgsSchema, } from "../types/index.js";
|
|
4
|
-
import { validateCommand,
|
|
4
|
+
import { validateCommand, getDangerousRoots, getShellConfig, } from "../utils/command-validation.js";
|
|
5
5
|
import { executeShellCommand, } from "../utils/shell-execution.js";
|
|
6
6
|
import { validatePath, getAllowedDirectories } from "../utils/lib.js";
|
|
7
|
-
import {
|
|
8
|
-
import {
|
|
7
|
+
import { findDisallowedCommandPaths } from "../utils/command-path-extraction.js";
|
|
8
|
+
import { parseShellCommand, getRootCommands } from "../utils/shell-parser.js";
|
|
9
9
|
import { sanitizeToolInputSchema } from "../utils/tool-schema.js";
|
|
10
10
|
const ToolInputSchema = ToolSchema.shape.inputSchema;
|
|
11
11
|
// Global state for approved commands
|
|
12
12
|
let approvedCommands = new Set();
|
|
13
13
|
let alwaysApprovedCommands = new Set();
|
|
14
|
+
// Root commands the operator allows to match dangerous patterns
|
|
15
|
+
let dangerousCommandAllowlist = new Set();
|
|
14
16
|
/**
|
|
15
|
-
* Initialize the shell tool with approved commands
|
|
17
|
+
* Initialize the shell tool with approved commands and (optionally) the root
|
|
18
|
+
* commands that may run even when they match a dangerous pattern.
|
|
16
19
|
*/
|
|
17
|
-
export function initializeShellTool(commands) {
|
|
20
|
+
export function initializeShellTool(commands, dangerousCommands = []) {
|
|
18
21
|
approvedCommands = new Set(commands);
|
|
22
|
+
dangerousCommandAllowlist = new Set(dangerousCommands);
|
|
23
|
+
}
|
|
24
|
+
export function getDangerousCommandAllowlist() {
|
|
25
|
+
return Array.from(dangerousCommandAllowlist);
|
|
19
26
|
}
|
|
20
27
|
/**
|
|
21
28
|
* Get the list of approved commands
|
|
@@ -57,8 +64,13 @@ export function getShellTools() {
|
|
|
57
64
|
`\n\n⚠️ SECURITY REQUIREMENTS:\n` +
|
|
58
65
|
`- At least ONE approved directory must be configured before executing any shell commands\n` +
|
|
59
66
|
`- Working directory (workdir parameter or process.cwd()) MUST be within allowed directories\n` +
|
|
60
|
-
`- All file/directory paths in
|
|
61
|
-
`-
|
|
67
|
+
`- All file/directory paths in arguments and redirections are validated against allowed directories (symlinks are resolved)\n` +
|
|
68
|
+
`- Every command in a chain must be approved; chain with ';', '&&', '||' or '|' on a single line` +
|
|
69
|
+
(shellConfig.platform === "Windows"
|
|
70
|
+
? ` (on Windows use ';' or '|': Windows PowerShell 5.1 does not support '&&' or '||')\n`
|
|
71
|
+
: `\n`) +
|
|
72
|
+
`- Not allowed: newlines, command substitution, backticks, a lone '&', ( ) { } grouping or script blocks, heredocs, escaped quotes (\\" \\')\n` +
|
|
73
|
+
`- Commands matching dangerous patterns are blocked unless the server operator allowed them\n` +
|
|
62
74
|
`\n` +
|
|
63
75
|
`If no workdir is specified, the server's current working directory will be used and validated.` +
|
|
64
76
|
approvedCommandsText +
|
|
@@ -107,19 +119,16 @@ export async function handleShellTool(name, args) {
|
|
|
107
119
|
}
|
|
108
120
|
// Validate arguments
|
|
109
121
|
const validatedArgs = ShellCommandArgsSchema.parse(args);
|
|
110
|
-
// Validate command security
|
|
122
|
+
// Validate command security (substitution, control characters, grammar)
|
|
111
123
|
const commandValidation = validateCommand(validatedArgs.command, false);
|
|
112
124
|
if (!commandValidation.allowed) {
|
|
113
|
-
throw new Error(`Command validation failed: ${commandValidation.reason}`);
|
|
114
|
-
}
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
const
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
// This allowed unapproved non-dangerous commands to execute by default
|
|
121
|
-
if (!allApproved) {
|
|
122
|
-
const unapprovedCommands = rootCommands.filter((cmd) => !approvedCommands.has(cmd) && !alwaysApprovedCommands.has(cmd));
|
|
125
|
+
throw new Error(`Access denied: Command validation failed: ${commandValidation.reason}`);
|
|
126
|
+
}
|
|
127
|
+
const parsed = parseShellCommand(validatedArgs.command);
|
|
128
|
+
// Every command in the chain must be approved
|
|
129
|
+
const rootCommands = getRootCommands(parsed);
|
|
130
|
+
const unapprovedCommands = rootCommands.filter((cmd) => !approvedCommands.has(cmd) && !alwaysApprovedCommands.has(cmd));
|
|
131
|
+
if (rootCommands.length === 0 || unapprovedCommands.length > 0) {
|
|
123
132
|
const approvedList = Array.from(approvedCommands).join(", ");
|
|
124
133
|
throw new Error(`Access denied: Command not in approved list.\n` +
|
|
125
134
|
`Unapproved commands: ${unapprovedCommands.join(", ")}\n` +
|
|
@@ -127,19 +136,18 @@ export async function handleShellTool(name, args) {
|
|
|
127
136
|
`Approved commands: ${approvedList || "(none configured)"}\n\n` +
|
|
128
137
|
`To execute this command, add it to --approved-commands configuration.`);
|
|
129
138
|
}
|
|
130
|
-
//
|
|
131
|
-
//
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
139
|
+
// Dangerous patterns are blocked even for approved commands unless the
|
|
140
|
+
// server operator explicitly allowed that root command. The caller cannot
|
|
141
|
+
// opt in: requiresApproval is ignored.
|
|
142
|
+
const blockedDangerousRoots = getDangerousRoots(parsed).filter((cmd) => !dangerousCommandAllowlist.has(cmd));
|
|
143
|
+
if (blockedDangerousRoots.length > 0) {
|
|
144
|
+
throw new Error(`Access denied: Dangerous command pattern detected.\n` +
|
|
135
145
|
`Command: ${validatedArgs.command}\n` +
|
|
136
|
-
`
|
|
137
|
-
`
|
|
146
|
+
`Blocked commands: ${blockedDangerousRoots.join(", ")}\n` +
|
|
147
|
+
`The server operator can allow these with --allow-dangerous-commands.`);
|
|
138
148
|
}
|
|
139
|
-
//
|
|
140
|
-
// This prevents bypass via process.cwd() when workdir is omitted
|
|
149
|
+
// Validate working directory ALWAYS (not just if provided)
|
|
141
150
|
const allowedDirs = getAllowedDirectories();
|
|
142
|
-
// Require at least one approved directory for shell execution
|
|
143
151
|
if (allowedDirs.length === 0) {
|
|
144
152
|
throw new Error(`Access denied: Shell execution requires at least one approved directory.\n` +
|
|
145
153
|
`No allowed directories are currently configured.\n` +
|
|
@@ -150,7 +158,6 @@ export async function handleShellTool(name, args) {
|
|
|
150
158
|
`\n` +
|
|
151
159
|
`Example: register_directory with path "C:/path/to/your/project"`);
|
|
152
160
|
}
|
|
153
|
-
// Always validate working directory against allowed directories
|
|
154
161
|
let workdir = validatedArgs.workdir || process.cwd();
|
|
155
162
|
try {
|
|
156
163
|
workdir = await validatePath(workdir);
|
|
@@ -167,44 +174,23 @@ export async function handleShellTool(name, args) {
|
|
|
167
174
|
` 1. Register the directory using register_directory tool, OR\n` +
|
|
168
175
|
` 2. Specify a workdir parameter within an approved directory`);
|
|
169
176
|
}
|
|
170
|
-
//
|
|
177
|
+
// Validate every file system operand (arguments and redirection targets),
|
|
178
|
+
// resolving symlinks/junctions
|
|
179
|
+
let deniedPaths;
|
|
171
180
|
try {
|
|
172
|
-
|
|
173
|
-
if (extractedPaths.length > 0) {
|
|
174
|
-
const allowedDirs = getAllowedDirectories();
|
|
175
|
-
// If no allowed directories are configured, block all paths for security
|
|
176
|
-
if (allowedDirs.length === 0) {
|
|
177
|
-
throw new Error(`Access denied: Command contains paths but no allowed directories are configured.\n` +
|
|
178
|
-
`Extracted paths:\n` +
|
|
179
|
-
extractedPaths.map((p) => ` - ${p}`).join("\n") +
|
|
180
|
-
`\n\nPlease configure allowed directories using --approved-folders or register_directory tool.`);
|
|
181
|
-
}
|
|
182
|
-
// Validate each extracted path
|
|
183
|
-
const invalidPaths = [];
|
|
184
|
-
for (const extractedPath of extractedPaths) {
|
|
185
|
-
if (!isPathWithinAllowedDirectories(extractedPath, allowedDirs)) {
|
|
186
|
-
invalidPaths.push(extractedPath);
|
|
187
|
-
}
|
|
188
|
-
}
|
|
189
|
-
if (invalidPaths.length > 0) {
|
|
190
|
-
throw new Error(`Access denied: Command contains paths outside allowed directories:\n` +
|
|
191
|
-
invalidPaths.map((p) => ` - ${p}`).join("\n") +
|
|
192
|
-
`\n\nAllowed directories:\n` +
|
|
193
|
-
allowedDirs.map((d) => ` - ${d}`).join("\n") +
|
|
194
|
-
`\n\nTo access these paths, register their parent directories using register_directory tool.`);
|
|
195
|
-
}
|
|
196
|
-
}
|
|
181
|
+
deniedPaths = await findDisallowedCommandPaths(parsed, workdir);
|
|
197
182
|
}
|
|
198
183
|
catch (error) {
|
|
199
|
-
|
|
200
|
-
// (better to block than allow potentially unsafe commands)
|
|
201
|
-
if (error instanceof Error && error.message.includes("Access denied")) {
|
|
202
|
-
throw error;
|
|
203
|
-
}
|
|
204
|
-
// For extraction errors, block the command to be safe
|
|
205
|
-
throw new Error(`Path validation failed: ${error instanceof Error ? error.message : String(error)}\n` +
|
|
184
|
+
throw new Error(`Access denied: Path validation failed: ${error instanceof Error ? error.message : String(error)}\n` +
|
|
206
185
|
`Command blocked for security. Please ensure all paths in the command are within allowed directories.`);
|
|
207
186
|
}
|
|
187
|
+
if (deniedPaths.length > 0) {
|
|
188
|
+
throw new Error(`Access denied: Command contains paths outside allowed directories:\n` +
|
|
189
|
+
deniedPaths.map((p) => ` - ${p}`).join("\n") +
|
|
190
|
+
`\n\nAllowed directories:\n` +
|
|
191
|
+
allowedDirs.map((d) => ` - ${d}`).join("\n") +
|
|
192
|
+
`\n\nTo access these paths, register their parent directories using register_directory tool.`);
|
|
193
|
+
}
|
|
208
194
|
// Execute command
|
|
209
195
|
try {
|
|
210
196
|
const result = await executeShellCommand(validatedArgs.command, {
|
|
@@ -1,9 +1,8 @@
|
|
|
1
1
|
import { zodToJsonSchema } from "zod-to-json-schema";
|
|
2
2
|
import { ToolSchema } from "@modelcontextprotocol/sdk/types.js";
|
|
3
3
|
import path from "path";
|
|
4
|
-
import { promises as fs } from "fs";
|
|
5
4
|
import { WriteFileArgsSchema, WriteMultipleFilesArgsSchema, EditFileArgsSchema, } from "../types/index.js";
|
|
6
|
-
import { validatePath, writeFileContent, readFileContent, applyFileEdits, } from "../utils/lib.js";
|
|
5
|
+
import { validatePath, writeFileContent, writeBinaryFileAtomic, readFileContent, applyFileEdits, getFileStats, } from "../utils/lib.js";
|
|
7
6
|
import { isHTMLContent, convertHTMLToPDF, convertHTMLToDOCX, } from "../utils/html-to-document.js";
|
|
8
7
|
import { sanitizeToolInputSchema } from "../utils/tool-schema.js";
|
|
9
8
|
const ToolInputSchema = ToolSchema.shape.inputSchema;
|
|
@@ -28,15 +27,15 @@ async function writeFileBasedOnExtension(validPath, content) {
|
|
|
28
27
|
title: fileTitle,
|
|
29
28
|
author: "vulcan-file-ops",
|
|
30
29
|
});
|
|
31
|
-
// SECURITY: validPath pre-validated by validatePath() -
|
|
32
|
-
await
|
|
30
|
+
// SECURITY: validPath pre-validated by validatePath(); atomic, symlink-safe write (VFO-17)
|
|
31
|
+
await writeBinaryFileAtomic(validPath, pdfBuffer);
|
|
33
32
|
}
|
|
34
33
|
else {
|
|
35
34
|
// Fallback to simple text PDF for plain text
|
|
36
35
|
const { createSimpleTextPDF } = await import("../utils/pdf-writer.js");
|
|
37
36
|
const pdfBuffer = await createSimpleTextPDF(content);
|
|
38
|
-
// SECURITY: validPath pre-validated by validatePath() -
|
|
39
|
-
await
|
|
37
|
+
// SECURITY: validPath pre-validated by validatePath(); atomic, symlink-safe write (VFO-17)
|
|
38
|
+
await writeBinaryFileAtomic(validPath, pdfBuffer);
|
|
40
39
|
}
|
|
41
40
|
}
|
|
42
41
|
else if (ext === ".docx") {
|
|
@@ -46,15 +45,15 @@ async function writeFileBasedOnExtension(validPath, content) {
|
|
|
46
45
|
title: fileTitle,
|
|
47
46
|
author: "vulcan-file-ops",
|
|
48
47
|
});
|
|
49
|
-
// SECURITY: validPath pre-validated by validatePath() -
|
|
50
|
-
await
|
|
48
|
+
// SECURITY: validPath pre-validated by validatePath(); atomic, symlink-safe write (VFO-17)
|
|
49
|
+
await writeBinaryFileAtomic(validPath, docxBuffer);
|
|
51
50
|
}
|
|
52
51
|
else {
|
|
53
52
|
// Fallback to simple text DOCX for plain text
|
|
54
53
|
const { createSimpleDOCX } = await import("../utils/docx-writer.js");
|
|
55
54
|
const docxBuffer = await createSimpleDOCX(content);
|
|
56
|
-
// SECURITY: validPath pre-validated by validatePath() -
|
|
57
|
-
await
|
|
55
|
+
// SECURITY: validPath pre-validated by validatePath(); atomic, symlink-safe write (VFO-17)
|
|
56
|
+
await writeBinaryFileAtomic(validPath, docxBuffer);
|
|
58
57
|
}
|
|
59
58
|
}
|
|
60
59
|
else {
|
|
@@ -391,25 +390,25 @@ export async function handleWriteTool(name, args) {
|
|
|
391
390
|
createParentIfMissing: true,
|
|
392
391
|
});
|
|
393
392
|
return {
|
|
393
|
+
success: true,
|
|
394
394
|
path: file.path,
|
|
395
395
|
validPath,
|
|
396
396
|
content: file.content,
|
|
397
|
-
success: true,
|
|
398
397
|
};
|
|
399
398
|
}
|
|
400
399
|
catch (error) {
|
|
401
400
|
return {
|
|
402
|
-
path: file.path,
|
|
403
|
-
content: file.content,
|
|
404
401
|
success: false,
|
|
402
|
+
path: file.path,
|
|
405
403
|
error: error instanceof Error ? error.message : String(error),
|
|
406
404
|
};
|
|
407
405
|
}
|
|
408
406
|
});
|
|
409
407
|
const validatedFiles = await Promise.all(validationPromises);
|
|
410
|
-
// Separate valid and invalid files
|
|
411
|
-
|
|
412
|
-
const
|
|
408
|
+
// Separate valid and invalid files. The type guard (no cast) ensures
|
|
409
|
+
// only entries carrying a validatePath() result can reach the writer.
|
|
410
|
+
const validFiles = validatedFiles.flatMap((f) => (f.success ? [f] : []));
|
|
411
|
+
const invalidFiles = validatedFiles.flatMap((f) => f.success ? [] : [f]);
|
|
413
412
|
// If any paths are invalid, fail the entire operation
|
|
414
413
|
if (invalidFiles.length > 0) {
|
|
415
414
|
const errorMessages = invalidFiles
|
|
@@ -421,10 +420,13 @@ export async function handleWriteTool(name, args) {
|
|
|
421
420
|
const writePromises = validFiles.map(async (file) => {
|
|
422
421
|
try {
|
|
423
422
|
await writeFileBasedOnExtension(file.validPath, file.content);
|
|
423
|
+
// Report the size of the written file: for PDF/DOCX this is the
|
|
424
|
+
// generated document, not the length of the HTML input.
|
|
425
|
+
const { size } = await getFileStats(file.validPath);
|
|
424
426
|
return {
|
|
425
427
|
path: file.path,
|
|
426
428
|
success: true,
|
|
427
|
-
size
|
|
429
|
+
size,
|
|
428
430
|
};
|
|
429
431
|
}
|
|
430
432
|
catch (error) {
|
package/dist/types/index.js
CHANGED
|
@@ -342,7 +342,10 @@ export const GrepArgsSchema = z.object({
|
|
|
342
342
|
pattern: z.string().describe("Regex pattern to search for"),
|
|
343
343
|
path: z.string().optional().describe("Directory/file to search (optional)"),
|
|
344
344
|
type: z.string().optional().describe("File type filter: js, py, ts, etc"),
|
|
345
|
-
glob: z
|
|
345
|
+
glob: z
|
|
346
|
+
.string()
|
|
347
|
+
.optional()
|
|
348
|
+
.describe("Glob filter. Without a slash it matches file names at any depth (*.md, *.test.ts); with a slash it matches the path relative to the search root (src/**/*.ts)"),
|
|
346
349
|
"-i": z.boolean().optional().default(false).describe("Case insensitive"),
|
|
347
350
|
"-A": z.number().optional().describe("Lines after match"),
|
|
348
351
|
"-B": z.number().optional().describe("Lines before match"),
|
|
@@ -429,7 +432,7 @@ export const ShellCommandArgsSchema = z.object({
|
|
|
429
432
|
.boolean()
|
|
430
433
|
.optional()
|
|
431
434
|
.default(false)
|
|
432
|
-
.describe("
|
|
433
|
-
"
|
|
435
|
+
.describe("Deprecated and ignored. Kept for backward compatibility. " +
|
|
436
|
+
"Commands matching dangerous patterns can only be allowed by the server operator (--allow-dangerous-commands)."),
|
|
434
437
|
});
|
|
435
438
|
//# sourceMappingURL=index.js.map
|