@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
|
@@ -1,200 +1,195 @@
|
|
|
1
1
|
import path from "path";
|
|
2
|
-
import
|
|
2
|
+
import os from "os";
|
|
3
|
+
import fs from "fs/promises";
|
|
4
|
+
import { isPathCanonicallyAllowed } from "./lib.js";
|
|
3
5
|
/**
|
|
4
|
-
*
|
|
6
|
+
* Finds file system operands of a parsed shell command that resolve outside
|
|
7
|
+
* the allowed directories (lexically, through symlinks/junctions, or after
|
|
8
|
+
* physical "..").
|
|
5
9
|
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
* - Quoted paths ("path with spaces")
|
|
12
|
-
* - Environment variables ($HOME, %USERPROFILE%)
|
|
13
|
-
*
|
|
14
|
-
* @param command - The shell command string
|
|
15
|
-
* @param workdir - Working directory for resolving relative paths
|
|
16
|
-
* @returns Array of absolute paths found in the command
|
|
10
|
+
* An argument is treated as a path if it looks like one (absolute, drive,
|
|
11
|
+
* UNC, ~, ./ ../, contains a separator) or if it names something that exists
|
|
12
|
+
* relative to the working directory. Relative operands are checked against
|
|
13
|
+
* every working directory the command might be in, because a cd/pushd can
|
|
14
|
+
* fail or (in a pipeline) not persist.
|
|
17
15
|
*/
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
16
|
+
const CD_COMMANDS = new Set([
|
|
17
|
+
"cd",
|
|
18
|
+
"chdir",
|
|
19
|
+
"pushd",
|
|
20
|
+
"set-location",
|
|
21
|
+
"sl",
|
|
22
|
+
"push-location",
|
|
23
|
+
]);
|
|
24
|
+
// PowerShell provider drives that are not the file system.
|
|
25
|
+
const POWERSHELL_PROVIDER_PATH = /^(env|hklm|hkcu|hkcr|hku|hkcc|cert|function|variable|alias|wsman|temp):/i;
|
|
26
|
+
const URL_PATTERN = /^[A-Za-z][A-Za-z0-9+.-]*:\/\//;
|
|
27
|
+
function expandVariables(word) {
|
|
28
|
+
// Single quotes suppress expansion in both bash and PowerShell.
|
|
29
|
+
if (word.singleQuoted) {
|
|
30
|
+
return { value: word.value, unresolved: false };
|
|
21
31
|
}
|
|
22
|
-
|
|
23
|
-
const
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
continue;
|
|
29
|
-
}
|
|
30
|
-
// Skip flags and options
|
|
31
|
-
if (isFlagOrOption(token)) {
|
|
32
|
-
// Some flags take arguments (like -o output.txt)
|
|
33
|
-
// Check if next token might be a path argument
|
|
34
|
-
if (i + 1 < tokens.length) {
|
|
35
|
-
const nextToken = tokens[i + 1];
|
|
36
|
-
if (isLikelyPathArgument(nextToken)) {
|
|
37
|
-
const resolvedPath = resolvePath(nextToken, workdir);
|
|
38
|
-
if (resolvedPath) {
|
|
39
|
-
paths.push(resolvedPath);
|
|
40
|
-
i++; // Skip the next token since we processed it
|
|
41
|
-
}
|
|
42
|
-
}
|
|
43
|
-
}
|
|
44
|
-
continue;
|
|
45
|
-
}
|
|
46
|
-
// Check if token is a path
|
|
47
|
-
if (isLikelyPathArgument(token)) {
|
|
48
|
-
const resolvedPath = resolvePath(token, workdir);
|
|
49
|
-
if (resolvedPath) {
|
|
50
|
-
paths.push(resolvedPath);
|
|
51
|
-
}
|
|
32
|
+
let unresolved = false;
|
|
33
|
+
const lookup = (name) => {
|
|
34
|
+
const value = process.env[name];
|
|
35
|
+
if (value === undefined) {
|
|
36
|
+
unresolved = true;
|
|
37
|
+
return "";
|
|
52
38
|
}
|
|
39
|
+
return value;
|
|
40
|
+
};
|
|
41
|
+
let value = word.value
|
|
42
|
+
.replace(/\$env:([A-Za-z_][A-Za-z0-9_]*)/gi, (_, name) => lookup(name))
|
|
43
|
+
.replace(/\$\{([A-Za-z_][A-Za-z0-9_]*)\}/g, (_, name) => lookup(name))
|
|
44
|
+
.replace(/\$([A-Za-z_][A-Za-z0-9_]*)/g, (_, name) => lookup(name));
|
|
45
|
+
// Anything else that still looks like an expansion cannot be validated.
|
|
46
|
+
if (/\$[A-Za-z_{]/.test(value)) {
|
|
47
|
+
unresolved = true;
|
|
53
48
|
}
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
* Tokenize command string, handling quoted arguments
|
|
58
|
-
*/
|
|
59
|
-
function tokenizeCommand(command) {
|
|
60
|
-
const tokens = [];
|
|
61
|
-
let current = "";
|
|
62
|
-
let inDoubleQuotes = false;
|
|
63
|
-
let inSingleQuotes = false;
|
|
64
|
-
let escaped = false;
|
|
65
|
-
for (let i = 0; i < command.length; i++) {
|
|
66
|
-
const char = command[i];
|
|
67
|
-
if (escaped) {
|
|
68
|
-
current += char;
|
|
69
|
-
escaped = false;
|
|
70
|
-
continue;
|
|
71
|
-
}
|
|
72
|
-
if (char === "\\") {
|
|
73
|
-
escaped = true;
|
|
74
|
-
current += char;
|
|
75
|
-
continue;
|
|
76
|
-
}
|
|
77
|
-
if (char === '"' && !inSingleQuotes) {
|
|
78
|
-
inDoubleQuotes = !inDoubleQuotes;
|
|
79
|
-
current += char;
|
|
80
|
-
continue;
|
|
49
|
+
if (!word.quoted) {
|
|
50
|
+
if (value === "~") {
|
|
51
|
+
value = os.homedir();
|
|
81
52
|
}
|
|
82
|
-
if (
|
|
83
|
-
|
|
84
|
-
current += char;
|
|
85
|
-
continue;
|
|
53
|
+
else if (value.startsWith("~/") || value.startsWith("~\\")) {
|
|
54
|
+
value = path.join(os.homedir(), value.slice(2));
|
|
86
55
|
}
|
|
87
|
-
if ((char === " " || char === "\t") && !inDoubleQuotes && !inSingleQuotes) {
|
|
88
|
-
if (current.trim()) {
|
|
89
|
-
tokens.push(current.trim());
|
|
90
|
-
current = "";
|
|
91
|
-
}
|
|
92
|
-
continue;
|
|
93
|
-
}
|
|
94
|
-
current += char;
|
|
95
56
|
}
|
|
96
|
-
|
|
97
|
-
|
|
57
|
+
return { value, unresolved };
|
|
58
|
+
}
|
|
59
|
+
function looksLikePath(value) {
|
|
60
|
+
if (!value || URL_PATTERN.test(value)) {
|
|
61
|
+
return false;
|
|
98
62
|
}
|
|
99
|
-
return
|
|
63
|
+
return (path.isAbsolute(value) ||
|
|
64
|
+
/^[A-Za-z]:/.test(value) ||
|
|
65
|
+
value.startsWith("\\\\") ||
|
|
66
|
+
value === "." ||
|
|
67
|
+
value === ".." ||
|
|
68
|
+
value.startsWith("./") ||
|
|
69
|
+
value.startsWith("../") ||
|
|
70
|
+
value.startsWith(".\\") ||
|
|
71
|
+
value.startsWith("..\\") ||
|
|
72
|
+
value.includes("/") ||
|
|
73
|
+
value.includes("\\"));
|
|
100
74
|
}
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
function isFlagOrOption(token) {
|
|
105
|
-
// Remove surrounding quotes
|
|
106
|
-
const cleanToken = token.replace(/^["']|["']$/g, "");
|
|
107
|
-
// Windows: -flag or /flag
|
|
108
|
-
if (cleanToken.match(/^[-/][^-/]/)) {
|
|
75
|
+
async function exists(p) {
|
|
76
|
+
try {
|
|
77
|
+
await fs.lstat(p);
|
|
109
78
|
return true;
|
|
110
79
|
}
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
return true;
|
|
80
|
+
catch {
|
|
81
|
+
return false;
|
|
114
82
|
}
|
|
115
|
-
return false;
|
|
116
83
|
}
|
|
117
|
-
/**
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
const cleanToken = token.replace(/^["']|["']$/g, "");
|
|
123
|
-
// Windows absolute path: C:\path or \\server\share\path
|
|
124
|
-
if (cleanToken.match(/^[A-Za-z]:[\\/]/) || cleanToken.startsWith("\\\\")) {
|
|
125
|
-
return true;
|
|
84
|
+
/** Value attached to an option, e.g. --out=X, -Path:X, -C:\X, -I../X. */
|
|
85
|
+
function attachedOptionValue(flag) {
|
|
86
|
+
const drive = /^-[A-Za-z]([A-Za-z]:[\\/].*)$/.exec(flag);
|
|
87
|
+
if (drive) {
|
|
88
|
+
return drive[1];
|
|
126
89
|
}
|
|
127
|
-
|
|
128
|
-
if (
|
|
129
|
-
|
|
130
|
-
return true;
|
|
90
|
+
const assigned = /^-{1,2}[A-Za-z][\w-]*[=:](.+)$/.exec(flag);
|
|
91
|
+
if (assigned && looksLikePath(assigned[1])) {
|
|
92
|
+
return assigned[1];
|
|
131
93
|
}
|
|
132
|
-
|
|
133
|
-
if (
|
|
134
|
-
return
|
|
94
|
+
const short = /^-[A-Za-z](.+)$/.exec(flag);
|
|
95
|
+
if (short && looksLikePath(short[1])) {
|
|
96
|
+
return short[1];
|
|
135
97
|
}
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
98
|
+
return null;
|
|
99
|
+
}
|
|
100
|
+
export async function findDisallowedCommandPaths(parsed, workdir) {
|
|
101
|
+
const denied = new Set();
|
|
102
|
+
const possibleCwds = [workdir];
|
|
103
|
+
const checkPath = async (candidate) => {
|
|
104
|
+
const bases = path.isAbsolute(candidate) ? [workdir] : possibleCwds;
|
|
105
|
+
for (const base of bases) {
|
|
106
|
+
if (!(await isPathCanonicallyAllowed(candidate, base))) {
|
|
107
|
+
denied.add(path.isAbsolute(candidate) ? candidate : path.join(base, candidate));
|
|
108
|
+
return false;
|
|
109
|
+
}
|
|
110
|
+
}
|
|
142
111
|
return true;
|
|
143
|
-
}
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
!cleanToken.match(/^[a-zA-Z]+:/) // Exclude single-letter drive-like patterns
|
|
150
|
-
) {
|
|
151
|
-
return true;
|
|
112
|
+
};
|
|
113
|
+
const resolveWord = (word) => {
|
|
114
|
+
const expanded = expandVariables(word);
|
|
115
|
+
if (expanded.unresolved) {
|
|
116
|
+
denied.add(`${word.value} (contains a variable reference that cannot be resolved for validation)`);
|
|
117
|
+
return null;
|
|
152
118
|
}
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
*/
|
|
159
|
-
function resolvePath(token, workdir) {
|
|
160
|
-
try {
|
|
161
|
-
// Remove surrounding quotes
|
|
162
|
-
let cleanToken = token.replace(/^["']|["']$/g, "");
|
|
163
|
-
// Expand environment variables
|
|
164
|
-
cleanToken = expandEnvironmentVariables(cleanToken);
|
|
165
|
-
// Expand home directory
|
|
166
|
-
cleanToken = expandHome(cleanToken);
|
|
167
|
-
// Resolve to absolute path
|
|
168
|
-
let absolute;
|
|
169
|
-
if (path.isAbsolute(cleanToken)) {
|
|
170
|
-
absolute = path.resolve(cleanToken);
|
|
119
|
+
if (process.platform === "win32" &&
|
|
120
|
+
(POWERSHELL_PROVIDER_PATH.test(expanded.value) ||
|
|
121
|
+
expanded.value.includes("::"))) {
|
|
122
|
+
denied.add(`${expanded.value} (PowerShell provider paths are not allowed)`);
|
|
123
|
+
return null;
|
|
171
124
|
}
|
|
172
|
-
|
|
173
|
-
|
|
125
|
+
return expanded.value;
|
|
126
|
+
};
|
|
127
|
+
for (const segment of parsed.segments) {
|
|
128
|
+
const [rootWord, ...args] = segment.words;
|
|
129
|
+
const root = rootWord ? rootWord.value.toLowerCase() : "";
|
|
130
|
+
const candidates = [];
|
|
131
|
+
const operands = [];
|
|
132
|
+
for (const word of args) {
|
|
133
|
+
const value = resolveWord(word);
|
|
134
|
+
if (value === null || value === "" || URL_PATTERN.test(value)) {
|
|
135
|
+
continue;
|
|
136
|
+
}
|
|
137
|
+
if (value.startsWith("-")) {
|
|
138
|
+
const attached = attachedOptionValue(value);
|
|
139
|
+
if (attached) {
|
|
140
|
+
candidates.push(attached);
|
|
141
|
+
}
|
|
142
|
+
continue;
|
|
143
|
+
}
|
|
144
|
+
operands.push(value);
|
|
145
|
+
// On Windows "/x" is either a switch or a path on the current drive.
|
|
146
|
+
if (process.platform === "win32" &&
|
|
147
|
+
value.startsWith("/") &&
|
|
148
|
+
!/[\\/]/.test(value.slice(1))) {
|
|
149
|
+
if (await exists(path.resolve(value))) {
|
|
150
|
+
candidates.push(value);
|
|
151
|
+
}
|
|
152
|
+
continue;
|
|
153
|
+
}
|
|
154
|
+
if (looksLikePath(value)) {
|
|
155
|
+
candidates.push(value);
|
|
156
|
+
continue;
|
|
157
|
+
}
|
|
158
|
+
for (const base of possibleCwds) {
|
|
159
|
+
if (await exists(path.join(base, value))) {
|
|
160
|
+
candidates.push(value);
|
|
161
|
+
break;
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
for (const redirect of segment.redirects) {
|
|
166
|
+
if (!redirect.target) {
|
|
167
|
+
continue;
|
|
168
|
+
}
|
|
169
|
+
const value = resolveWord(redirect.target);
|
|
170
|
+
if (value) {
|
|
171
|
+
candidates.push(value);
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
for (const candidate of candidates) {
|
|
175
|
+
await checkPath(candidate);
|
|
176
|
+
}
|
|
177
|
+
if (CD_COMMANDS.has(root)) {
|
|
178
|
+
const target = operands[0] ?? os.homedir();
|
|
179
|
+
if (target === "-") {
|
|
180
|
+
denied.add("cd - (previous directory cannot be validated)");
|
|
181
|
+
continue;
|
|
182
|
+
}
|
|
183
|
+
if (await checkPath(target)) {
|
|
184
|
+
for (const base of [...possibleCwds]) {
|
|
185
|
+
const next = path.resolve(base, target);
|
|
186
|
+
if (!possibleCwds.includes(next)) {
|
|
187
|
+
possibleCwds.push(next);
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
}
|
|
174
191
|
}
|
|
175
|
-
// Normalize the path
|
|
176
|
-
return path.normalize(absolute);
|
|
177
|
-
}
|
|
178
|
-
catch {
|
|
179
|
-
// If resolution fails, return null (don't block, but don't validate)
|
|
180
|
-
return null;
|
|
181
192
|
}
|
|
182
|
-
|
|
183
|
-
/**
|
|
184
|
-
* Expand environment variables in a path string
|
|
185
|
-
*/
|
|
186
|
-
function expandEnvironmentVariables(pathStr) {
|
|
187
|
-
// Windows: %VAR%
|
|
188
|
-
pathStr = pathStr.replace(/%([^%]+)%/g, (match, varName) => {
|
|
189
|
-
return process.env[varName] || match;
|
|
190
|
-
});
|
|
191
|
-
// Unix: $VAR or ${VAR}
|
|
192
|
-
pathStr = pathStr.replace(/\$([A-Za-z_][A-Za-z0-9_]*)/g, (match, varName) => {
|
|
193
|
-
return process.env[varName] || match;
|
|
194
|
-
});
|
|
195
|
-
pathStr = pathStr.replace(/\${([^}]+)}/g, (match, varName) => {
|
|
196
|
-
return process.env[varName] || match;
|
|
197
|
-
});
|
|
198
|
-
return pathStr;
|
|
193
|
+
return [...denied];
|
|
199
194
|
}
|
|
200
195
|
//# sourceMappingURL=command-path-extraction.js.map
|
|
@@ -1,12 +1,18 @@
|
|
|
1
1
|
import os from "os";
|
|
2
|
+
import { parseShellCommand, getRootCommands, ShellSyntaxError, } from "./shell-parser.js";
|
|
2
3
|
/**
|
|
3
|
-
* Dangerous command patterns
|
|
4
|
+
* Dangerous command patterns. Matching commands are blocked unless the
|
|
5
|
+
* operator allowed the root command with --allow-dangerous-commands.
|
|
4
6
|
*/
|
|
5
7
|
const DANGEROUS_PATTERNS = [
|
|
6
8
|
// Destructive operations
|
|
7
9
|
/\brm\b.*-rf?\b/i,
|
|
8
10
|
/\bdel\b.*\/s\b/i,
|
|
9
|
-
/\
|
|
11
|
+
/\b(rd|rmdir)\b.*\/s\b/i,
|
|
12
|
+
/\bremove-item\b.*-recurse\b/i,
|
|
13
|
+
/\bformat(\.com)?\s+[a-z]:/i,
|
|
14
|
+
/\bformat-volume\b/i,
|
|
15
|
+
/\bclear-disk\b/i,
|
|
10
16
|
/\bmkfs\b/i,
|
|
11
17
|
// System modifications
|
|
12
18
|
/\bsudo\b/i,
|
|
@@ -33,22 +39,32 @@ const COMMAND_SUBSTITUTION_PATTERNS = [
|
|
|
33
39
|
/>\([^)]*\)/, // >(command)
|
|
34
40
|
];
|
|
35
41
|
/**
|
|
36
|
-
* Extract root command
|
|
42
|
+
* Extract the root command of every segment of a shell command.
|
|
37
43
|
* Examples:
|
|
38
|
-
* "ls -la" -> "ls"
|
|
39
|
-
* "npm install && npm start" -> ["npm"
|
|
40
|
-
* "
|
|
44
|
+
* "ls -la" -> ["ls"]
|
|
45
|
+
* "npm install && npm start" -> ["npm"]
|
|
46
|
+
* 'echo "a; b"' -> ["echo"] (quoted text is an argument, not a command)
|
|
47
|
+
*
|
|
48
|
+
* Uses the conservative shell parser. If the command is outside the accepted
|
|
49
|
+
* grammar (validateCommand rejects those), falls back to splitting on every
|
|
50
|
+
* separator, including newlines, so the result never under-reports commands.
|
|
41
51
|
*/
|
|
42
52
|
export function extractRootCommands(command) {
|
|
53
|
+
try {
|
|
54
|
+
return getRootCommands(parseShellCommand(command));
|
|
55
|
+
}
|
|
56
|
+
catch (error) {
|
|
57
|
+
if (!(error instanceof ShellSyntaxError)) {
|
|
58
|
+
throw error;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
43
61
|
const roots = [];
|
|
44
|
-
// Split by common shell operators
|
|
45
62
|
const segments = command
|
|
46
|
-
.split(/[
|
|
63
|
+
.split(/[;&|\r\n\u0085\u2028\u2029]/)
|
|
47
64
|
.map((s) => s.trim())
|
|
48
65
|
.filter(Boolean);
|
|
49
66
|
for (const segment of segments) {
|
|
50
|
-
|
|
51
|
-
const firstWord = segment.trim().split(/\s+/)[0];
|
|
67
|
+
const firstWord = segment.split(/\s+/)[0];
|
|
52
68
|
if (firstWord && !roots.includes(firstWord)) {
|
|
53
69
|
roots.push(firstWord);
|
|
54
70
|
}
|
|
@@ -61,6 +77,26 @@ export function extractRootCommands(command) {
|
|
|
61
77
|
export function isDangerousCommand(command) {
|
|
62
78
|
return DANGEROUS_PATTERNS.some((pattern) => pattern.test(command));
|
|
63
79
|
}
|
|
80
|
+
/**
|
|
81
|
+
* Root commands of the segments that match a dangerous pattern. If the
|
|
82
|
+
* pattern only matches across segments (e.g. a download piped into a shell),
|
|
83
|
+
* every root command is returned.
|
|
84
|
+
*/
|
|
85
|
+
export function getDangerousRoots(parsed) {
|
|
86
|
+
const roots = new Set();
|
|
87
|
+
for (const segment of parsed.segments) {
|
|
88
|
+
if (isDangerousCommand(segment.raw) && segment.words[0]) {
|
|
89
|
+
roots.add(segment.words[0].value);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
if (roots.size === 0) {
|
|
93
|
+
const whole = parsed.segments.map((s) => s.raw).join(" | ");
|
|
94
|
+
if (isDangerousCommand(whole)) {
|
|
95
|
+
return getRootCommands(parsed);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
return [...roots];
|
|
99
|
+
}
|
|
64
100
|
/**
|
|
65
101
|
* Check if command contains command substitution
|
|
66
102
|
*/
|
|
@@ -81,6 +117,22 @@ export function validateCommand(command, allowCommandSubstitution = false) {
|
|
|
81
117
|
reason: "Command substitution using $(), ``, <(), or >() is not allowed for security reasons",
|
|
82
118
|
};
|
|
83
119
|
}
|
|
120
|
+
// Structural check: only the conservative grammar is accepted. (Skipped
|
|
121
|
+
// when substitution is explicitly allowed, which execute_shell never does.)
|
|
122
|
+
if (!allowCommandSubstitution) {
|
|
123
|
+
try {
|
|
124
|
+
parseShellCommand(command);
|
|
125
|
+
}
|
|
126
|
+
catch (error) {
|
|
127
|
+
if (error instanceof ShellSyntaxError) {
|
|
128
|
+
return {
|
|
129
|
+
allowed: false,
|
|
130
|
+
reason: `Unsupported shell syntax: ${error.message}`,
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
throw error;
|
|
134
|
+
}
|
|
135
|
+
}
|
|
84
136
|
// Extract root commands for approval checking
|
|
85
137
|
const roots = extractRootCommands(command);
|
|
86
138
|
if (roots.length === 0) {
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import path from "path";
|
|
2
2
|
import { promises as fs } from "fs";
|
|
3
|
+
import { MAX_DOCUMENT_FILE_BYTES, ZIP_MAX_TOTAL_UNCOMPRESSED_BYTES, ZIP_MAX_ENTRIES, } from "./limits.js";
|
|
4
|
+
import { assertSafeZipFile, UnsafeZipError } from "./zip-guard.js";
|
|
3
5
|
// Lazy-loaded parsers (imported only when needed)
|
|
4
6
|
let pdfParse = null;
|
|
5
7
|
let mammoth = null;
|
|
@@ -13,6 +15,15 @@ const DOCUMENT_EXTENSIONS = [
|
|
|
13
15
|
".odp",
|
|
14
16
|
".ods",
|
|
15
17
|
];
|
|
18
|
+
/** Document formats that are ZIP containers (guarded against zip bombs). */
|
|
19
|
+
const ZIP_DOCUMENT_EXTENSIONS = [
|
|
20
|
+
".docx",
|
|
21
|
+
".pptx",
|
|
22
|
+
".xlsx",
|
|
23
|
+
".odt",
|
|
24
|
+
".odp",
|
|
25
|
+
".ods",
|
|
26
|
+
];
|
|
16
27
|
/**
|
|
17
28
|
* Checks if file is a supported document format
|
|
18
29
|
*/
|
|
@@ -27,10 +38,25 @@ export async function parseDocument(filePath) {
|
|
|
27
38
|
const ext = path.extname(filePath).toLowerCase();
|
|
28
39
|
const stats = await fs.stat(filePath);
|
|
29
40
|
// File size validation
|
|
30
|
-
|
|
31
|
-
if (stats.size > MAX_SIZE) {
|
|
41
|
+
if (stats.size > MAX_DOCUMENT_FILE_BYTES) {
|
|
32
42
|
throw new Error(`Document too large (${(stats.size / 1024 / 1024).toFixed(1)}MB). ` +
|
|
33
|
-
`Maximum:
|
|
43
|
+
`Maximum: ${Math.round(MAX_DOCUMENT_FILE_BYTES / 1024 / 1024)}MB`);
|
|
44
|
+
}
|
|
45
|
+
// ZIP-based formats: reject decompression bombs before any parser (or the
|
|
46
|
+
// officeparser fallback below) inflates the archive (VFO-15).
|
|
47
|
+
if (ZIP_DOCUMENT_EXTENSIONS.includes(ext)) {
|
|
48
|
+
try {
|
|
49
|
+
await assertSafeZipFile(filePath);
|
|
50
|
+
}
|
|
51
|
+
catch (error) {
|
|
52
|
+
if (error instanceof UnsafeZipError) {
|
|
53
|
+
const label = ext.slice(1).toUpperCase();
|
|
54
|
+
throw new DocumentParseError(filePath, ext, error.reason === "invalid"
|
|
55
|
+
? `File appears to be corrupted or is not a valid ${label} document (${error.message}).`
|
|
56
|
+
: `Refusing to parse ${label} document: ${error.message}.`, error);
|
|
57
|
+
}
|
|
58
|
+
throw error;
|
|
59
|
+
}
|
|
34
60
|
}
|
|
35
61
|
// Check for legacy .doc format
|
|
36
62
|
if (ext === ".doc") {
|
|
@@ -121,15 +147,27 @@ async function parseOfficeDocument(filePath, ext) {
|
|
|
121
147
|
// Lazy load officeparser
|
|
122
148
|
if (!officeParser) {
|
|
123
149
|
const module = await import("officeparser");
|
|
124
|
-
officeParser = module.
|
|
150
|
+
officeParser = module.parseOffice;
|
|
125
151
|
}
|
|
126
152
|
const config = {
|
|
127
153
|
outputErrorToConsole: false,
|
|
128
154
|
newlineDelimiter: "\n",
|
|
129
155
|
ignoreNotes: false,
|
|
130
156
|
putNotesAtLast: false,
|
|
157
|
+
// Text extraction only: never run OCR (which would load tesseract.js and
|
|
158
|
+
// download language data), extract embedded files, or keep raw XML.
|
|
159
|
+
ocr: false,
|
|
160
|
+
extractAttachments: false,
|
|
161
|
+
includeRawContent: false,
|
|
162
|
+
// Same limits as the zip-guard pre-check; officeparser additionally caps
|
|
163
|
+
// ODF repeated table cells, which the ZIP headers cannot reveal.
|
|
164
|
+
decompressionLimits: {
|
|
165
|
+
maxUncompressedBytes: ZIP_MAX_TOTAL_UNCOMPRESSED_BYTES,
|
|
166
|
+
maxZipEntries: ZIP_MAX_ENTRIES,
|
|
167
|
+
},
|
|
131
168
|
};
|
|
132
|
-
const
|
|
169
|
+
const ast = await officeParser(filePath, config);
|
|
170
|
+
const text = ast.toText();
|
|
133
171
|
return {
|
|
134
172
|
text,
|
|
135
173
|
metadata: {
|