@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.
- package/CHANGELOG.md +62 -0
- package/README.md +64 -29
- package/dist/cli.js +16 -0
- package/dist/server/index.js +136 -15
- 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/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
|