@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.
@@ -1,200 +1,195 @@
1
1
  import path from "path";
2
- import { expandHome } from "./path-utils.js";
2
+ import os from "os";
3
+ import fs from "fs/promises";
4
+ import { isPathCanonicallyAllowed } from "./lib.js";
3
5
  /**
4
- * Extract file and directory paths from shell command arguments
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
- * This function parses shell commands to identify file/directory paths
7
- * that need to be validated against allowed directories. It handles:
8
- * - Windows paths (C:\path, \\server\share\path)
9
- * - Unix paths (/path, ~/path)
10
- * - Relative paths (./path, ../path) - resolved to absolute
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
- export function extractPathsFromCommand(command, workdir) {
19
- if (!command || !command.trim()) {
20
- return [];
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
- const paths = [];
23
- const tokens = tokenizeCommand(command);
24
- for (let i = 0; i < tokens.length; i++) {
25
- const token = tokens[i];
26
- // Skip command name itself (first token)
27
- if (i === 0) {
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
- return paths;
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 (char === "'" && !inDoubleQuotes) {
83
- inSingleQuotes = !inSingleQuotes;
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
- if (current.trim()) {
97
- tokens.push(current.trim());
57
+ return { value, unresolved };
58
+ }
59
+ function looksLikePath(value) {
60
+ if (!value || URL_PATTERN.test(value)) {
61
+ return false;
98
62
  }
99
- return tokens;
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
- * Check if a token is a flag or option (not a path)
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
- // Unix: --long-flag or -s
112
- if (cleanToken.match(/^--?[a-zA-Z]/)) {
113
- return true;
80
+ catch {
81
+ return false;
114
82
  }
115
- return false;
116
83
  }
117
- /**
118
- * Check if a token is likely a file/directory path argument
119
- */
120
- function isLikelyPathArgument(token) {
121
- // Remove surrounding quotes
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
- // Unix absolute path: /path
128
- if (cleanToken.startsWith("/") && !cleanToken.match(/^\/[a-zA-Z]\//)) {
129
- // Exclude Windows-style paths like /c/path
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
- // Home directory: ~/path or ~
133
- if (cleanToken.startsWith("~/") || cleanToken === "~") {
134
- return true;
94
+ const short = /^-[A-Za-z](.+)$/.exec(flag);
95
+ if (short && looksLikePath(short[1])) {
96
+ return short[1];
135
97
  }
136
- // Relative path: ./path or ../path
137
- if (cleanToken.startsWith("./") || cleanToken.startsWith("../")) {
138
- return true;
139
- }
140
- // Path with environment variable: $HOME/path or %USERPROFILE%\path
141
- if (cleanToken.includes("$") || cleanToken.includes("%")) {
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
- // If it contains path separators, might be a path
145
- if (cleanToken.includes("/") || cleanToken.includes("\\")) {
146
- // But exclude URLs and other non-path strings
147
- if (!cleanToken.match(/^https?:\/\//) &&
148
- !cleanToken.match(/^[a-zA-Z]+:\/\//) &&
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
- return false;
155
- }
156
- /**
157
- * Resolve a path token to an absolute path
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
- else {
173
- absolute = path.resolve(workdir, cleanToken);
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 that should trigger approval
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
- /\bformat\b/i,
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 from shell command string
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", "npm"]
40
- * "sudo apt install" -> ["sudo", "apt"]
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
- // Remove leading/trailing whitespace and extract first word
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
- const MAX_SIZE = 50 * 1024 * 1024; // 50MB
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: 50MB`);
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.parseOfficeAsync;
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 text = await officeParser(filePath, config);
169
+ const ast = await officeParser(filePath, config);
170
+ const text = ast.toText();
133
171
  return {
134
172
  text,
135
173
  metadata: {