@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.
@@ -0,0 +1,262 @@
1
+ /**
2
+ * Time-bounded regular expression evaluation for grep_files (audit VFO-14).
3
+ *
4
+ * Patterns passed to grep_files come from the model and file contents may be
5
+ * attacker-authored. JavaScript's backtracking engine is exponential on
6
+ * patterns like `^(a+)+$`, and running such a match on the main event loop
7
+ * freezes the whole single-threaded server. All regex evaluation against file
8
+ * content therefore runs inside a `worker_threads` Worker with a hard time
9
+ * budget; when the budget is exceeded the worker is terminated and the grep
10
+ * call fails with a clear error.
11
+ *
12
+ * The worker is created from an inline plain-JavaScript source string with
13
+ * `{ eval: true }` instead of a separate file. A file path would have to be
14
+ * resolved through `import.meta.url` in the compiled ESM build and through a
15
+ * different mechanism under ts-jest; the eval form behaves identically in both.
16
+ */
17
+ import { Worker } from "worker_threads";
18
+ /** Default time budget for evaluating the pattern against a single file. */
19
+ export const DEFAULT_GREP_REGEX_FILE_TIMEOUT_MS = 5_000;
20
+ /** Default cumulative regex evaluation budget for one grep_files call. */
21
+ export const DEFAULT_GREP_REGEX_TOTAL_TIMEOUT_MS = 30_000;
22
+ /**
23
+ * Self-contained worker source. Plain JavaScript only; the sole dependency is
24
+ * `worker_threads`. It mirrors the matching logic grepFilesWithValidation used
25
+ * to run on the main thread so results stay identical:
26
+ *
27
+ * - single-line mode: test every line of the CRLF-normalized content with the
28
+ * non-global regex (flags + nothing) and report matching line indices;
29
+ * - multiline mode: count matches of the global regex (flags + "g", no dotAll)
30
+ * over the raw content, then, if requested and there was a match, report the
31
+ * indices of normalized lines on which the dotAll regex (flags + "s") tests
32
+ * true.
33
+ *
34
+ * `maxLineMatches` lets the caller stop line scanning once it has as many
35
+ * matching lines as it will consume (head_limit); null means "all".
36
+ */
37
+ const WORKER_SOURCE = `
38
+ "use strict";
39
+ const { parentPort } = require("worker_threads");
40
+ const cache = new Map();
41
+ function getRegex(pattern, flags) {
42
+ const key = flags + "\\u0000" + pattern;
43
+ let re = cache.get(key);
44
+ if (re === undefined) {
45
+ re = new RegExp(pattern, flags);
46
+ cache.set(key, re);
47
+ }
48
+ return re;
49
+ }
50
+ function collectLines(content, re, maxLineMatches) {
51
+ const indices = [];
52
+ if (maxLineMatches !== null && maxLineMatches <= 0) return indices;
53
+ const lines = content.replace(/\\r\\n/g, "\\n").split("\\n");
54
+ for (let i = 0; i < lines.length; i++) {
55
+ if (re.test(lines[i])) {
56
+ indices.push(i);
57
+ if (maxLineMatches !== null && indices.length >= maxLineMatches) break;
58
+ }
59
+ }
60
+ return indices;
61
+ }
62
+ parentPort.on("message", (msg) => {
63
+ const { id, content, pattern, flags, multiline, needLines, maxLineMatches } = msg;
64
+ try {
65
+ const limit = typeof maxLineMatches === "number" ? maxLineMatches : null;
66
+ if (multiline) {
67
+ const globalRe = getRegex(pattern, flags + "g");
68
+ const matches = content.match(globalRe);
69
+ const globalMatchCount = matches ? matches.length : 0;
70
+ let lineIndices = [];
71
+ if (matches && needLines) {
72
+ lineIndices = collectLines(content, getRegex(pattern, flags + "s"), limit);
73
+ }
74
+ parentPort.postMessage({ id, ok: true, hasGlobalMatch: matches !== null, globalMatchCount, lineIndices });
75
+ } else {
76
+ const lineIndices = collectLines(content, getRegex(pattern, flags), limit);
77
+ parentPort.postMessage({ id, ok: true, hasGlobalMatch: false, globalMatchCount: 0, lineIndices });
78
+ }
79
+ } catch (error) {
80
+ parentPort.postMessage({ id, ok: false, error: error && error.message ? String(error.message) : String(error) });
81
+ }
82
+ });
83
+ `;
84
+ /**
85
+ * Base class for failures of the regex evaluation machinery (timeouts, worker
86
+ * crashes). grepFilesWithValidation rethrows these instead of treating them as
87
+ * "skip this file", so a timed-out search fails loudly rather than silently
88
+ * returning partial results.
89
+ */
90
+ export class RegexEvaluationError extends Error {
91
+ constructor(message) {
92
+ super(message);
93
+ this.name = "RegexEvaluationError";
94
+ }
95
+ }
96
+ export class RegexTimeoutError extends RegexEvaluationError {
97
+ constructor(message) {
98
+ super(message);
99
+ this.name = "RegexTimeoutError";
100
+ }
101
+ }
102
+ /**
103
+ * One worker reused for every file of a single grep call. Requests are
104
+ * evaluated strictly one at a time. Always call dispose() when done.
105
+ */
106
+ export class RegexEvaluationSession {
107
+ worker = null;
108
+ online = null;
109
+ pending = null;
110
+ nextId = 1;
111
+ spentMs = 0;
112
+ fileTimeoutMs;
113
+ totalTimeoutMs;
114
+ constructor(options = {}) {
115
+ this.fileTimeoutMs =
116
+ options.fileTimeoutMs ?? DEFAULT_GREP_REGEX_FILE_TIMEOUT_MS;
117
+ this.totalTimeoutMs =
118
+ options.totalTimeoutMs ?? DEFAULT_GREP_REGEX_TOTAL_TIMEOUT_MS;
119
+ }
120
+ startWorker() {
121
+ if (this.worker && this.online)
122
+ return this.online;
123
+ const worker = new Worker(WORKER_SOURCE, { eval: true });
124
+ this.worker = worker;
125
+ this.online = new Promise((resolve, reject) => {
126
+ const cleanup = () => {
127
+ worker.off("online", onOnline);
128
+ worker.off("error", onEarlyError);
129
+ worker.off("exit", onEarlyExit);
130
+ };
131
+ const onOnline = () => {
132
+ cleanup();
133
+ resolve();
134
+ };
135
+ const onEarlyError = (error) => {
136
+ cleanup();
137
+ reject(new RegexEvaluationError(`Regex worker failed to start: ${error.message}`));
138
+ };
139
+ const onEarlyExit = (code) => {
140
+ cleanup();
141
+ reject(new RegexEvaluationError(`Regex worker exited during start-up (code ${code})`));
142
+ };
143
+ worker.once("online", onOnline);
144
+ worker.once("error", onEarlyError);
145
+ worker.once("exit", onEarlyExit);
146
+ });
147
+ worker.on("message", (message) => {
148
+ const pending = this.pending;
149
+ if (!pending || !message || message.id !== pending.id)
150
+ return;
151
+ this.pending = null;
152
+ if (message.ok) {
153
+ pending.resolve({
154
+ hasGlobalMatch: message.hasGlobalMatch,
155
+ globalMatchCount: message.globalMatchCount,
156
+ lineIndices: message.lineIndices,
157
+ });
158
+ }
159
+ else {
160
+ pending.reject(new RegexEvaluationError(`Regex evaluation failed: ${String(message.error)}`));
161
+ }
162
+ });
163
+ worker.on("error", (error) => {
164
+ this.failPending(new RegexEvaluationError(`Regex worker crashed: ${error.message}`));
165
+ this.discardWorker(worker);
166
+ });
167
+ worker.on("exit", (code) => {
168
+ this.failPending(new RegexEvaluationError(`Regex worker exited unexpectedly (code ${code})`));
169
+ this.discardWorker(worker);
170
+ });
171
+ return this.online;
172
+ }
173
+ failPending(error) {
174
+ const pending = this.pending;
175
+ if (pending) {
176
+ this.pending = null;
177
+ pending.reject(error);
178
+ }
179
+ }
180
+ discardWorker(worker) {
181
+ if (this.worker === worker) {
182
+ this.worker = null;
183
+ this.online = null;
184
+ }
185
+ }
186
+ /**
187
+ * Evaluate one file's content. `label` (usually the file path) is used in
188
+ * error messages only. Rejects with RegexTimeoutError when the per-file or
189
+ * cumulative budget is exhausted; the worker is terminated in that case.
190
+ */
191
+ async evaluate(request, label) {
192
+ const remainingTotal = this.totalTimeoutMs - this.spentMs;
193
+ if (remainingTotal <= 0) {
194
+ throw new RegexTimeoutError(this.totalTimeoutMessage(label));
195
+ }
196
+ const limitedByTotal = remainingTotal < this.fileTimeoutMs;
197
+ const timeoutMs = limitedByTotal ? remainingTotal : this.fileTimeoutMs;
198
+ // Worker start-up is not charged against the regex budget.
199
+ await this.startWorker();
200
+ const worker = this.worker;
201
+ if (!worker) {
202
+ throw new RegexEvaluationError("Regex worker is not available");
203
+ }
204
+ const id = this.nextId++;
205
+ const startedAt = performance.now();
206
+ let timer;
207
+ try {
208
+ return await new Promise((resolve, reject) => {
209
+ this.pending = { id, resolve, reject };
210
+ timer = setTimeout(() => {
211
+ if (!this.pending || this.pending.id !== id)
212
+ return;
213
+ this.pending = null;
214
+ this.discardWorker(worker);
215
+ // Fire-and-forget: terminate() stops the thread even while it is
216
+ // stuck inside a backtracking match.
217
+ void worker.terminate().catch(() => { });
218
+ reject(new RegexTimeoutError(limitedByTotal
219
+ ? this.totalTimeoutMessage(label)
220
+ : `Regex evaluation timed out after ${this.fileTimeoutMs}ms in ${label} ` +
221
+ `(the pattern may cause catastrophic backtracking); simplify the pattern`));
222
+ }, timeoutMs);
223
+ const message = {
224
+ id,
225
+ content: request.content,
226
+ pattern: request.pattern,
227
+ flags: request.flags,
228
+ multiline: request.multiline,
229
+ needLines: request.needLines,
230
+ maxLineMatches: request.maxLineMatches === undefined
231
+ ? null
232
+ : request.maxLineMatches,
233
+ };
234
+ worker.postMessage(message);
235
+ });
236
+ }
237
+ finally {
238
+ if (timer)
239
+ clearTimeout(timer);
240
+ this.spentMs += performance.now() - startedAt;
241
+ }
242
+ }
243
+ totalTimeoutMessage(label) {
244
+ return (`Regex evaluation exceeded the total time budget of ${this.totalTimeoutMs}ms ` +
245
+ `for this search (stopped at ${label}); the pattern may cause catastrophic ` +
246
+ `backtracking - simplify the pattern or narrow the search path`);
247
+ }
248
+ /** Terminate the worker (if any). Safe to call multiple times. */
249
+ async dispose() {
250
+ const worker = this.worker;
251
+ this.worker = null;
252
+ this.online = null;
253
+ this.failPending(new RegexEvaluationError("Regex worker disposed"));
254
+ if (worker) {
255
+ worker.removeAllListeners("exit");
256
+ worker.removeAllListeners("error");
257
+ worker.on("error", () => { });
258
+ await worker.terminate().catch(() => { });
259
+ }
260
+ }
261
+ }
262
+ //# sourceMappingURL=regex-worker.js.map
@@ -0,0 +1,225 @@
1
+ /**
2
+ * Conservative command-line parser for execute_shell.
3
+ *
4
+ * Commands run under `bash -c` (POSIX) or `powershell -Command` (Windows). The
5
+ * approval and path checks are only sound if this parser sees at least every
6
+ * command and every file operand the real shell will see. So it accepts a
7
+ * small grammar both shells agree on and rejects everything else instead of
8
+ * guessing:
9
+ *
10
+ * - simple commands joined by `;`, `&&`, `||` and `|`
11
+ * - single- and double-quoted strings (backslash is never an escape)
12
+ * - redirections, whose targets are reported so they can be path-validated
13
+ *
14
+ * Rejected: newlines and control characters, backticks, `\"` / `\'`, Unicode
15
+ * quotes, a lone `&`, `(`/`)`/`{`/`}` grouping and script blocks, heredocs,
16
+ * process substitution, and PowerShell's `--%` stop-parsing token.
17
+ */
18
+ export class ShellSyntaxError extends Error {
19
+ constructor(message) {
20
+ super(message);
21
+ this.name = "ShellSyntaxError";
22
+ }
23
+ }
24
+ // Tab is allowed; everything else below 0x20, DEL, NEL and the Unicode line
25
+ // and paragraph separators can act as command separators.
26
+ const CONTROL_CHARS = /[\x00-\x08\x0a-\x1f\x7f\u0085\u2028\u2029]/;
27
+ // PowerShell treats these as quote characters; this parser does not.
28
+ const UNICODE_QUOTES = /[\u2018\u2019\u201a\u201b\u201c\u201d\u201e\u201f]/;
29
+ const NULL_DEVICES = new Set(["/dev/null", "$null", "nul"]);
30
+ export function parseShellCommand(command) {
31
+ if (CONTROL_CHARS.test(command)) {
32
+ throw new ShellSyntaxError("newlines and control characters are not allowed in commands (chain commands with ';' instead; '&&' also works in bash)");
33
+ }
34
+ if (UNICODE_QUOTES.test(command)) {
35
+ throw new ShellSyntaxError("typographic (Unicode) quote characters are not allowed; use plain ' or \" quotes");
36
+ }
37
+ if (command.includes("`")) {
38
+ throw new ShellSyntaxError("backticks are not allowed (command substitution in bash, escape character in PowerShell)");
39
+ }
40
+ if (command.includes('\\"') || command.includes("\\'")) {
41
+ throw new ShellSyntaxError("backslash-escaped quotes are not allowed because bash and PowerShell interpret them differently");
42
+ }
43
+ if (/(^|\s)--%(\s|$)/.test(command)) {
44
+ throw new ShellSyntaxError("the PowerShell stop-parsing token '--%' is not allowed");
45
+ }
46
+ const segments = [];
47
+ let words = [];
48
+ let redirects = [];
49
+ let segmentStart = 0;
50
+ let i = 0;
51
+ const isWhitespace = (c) => c === " " || c === "\t";
52
+ const isOperatorChar = (c) => ";|&<>(){}".includes(c);
53
+ const endSegment = (end) => {
54
+ const raw = command.slice(segmentStart, end).trim();
55
+ if (words.length > 0 || redirects.length > 0) {
56
+ segments.push({ words, redirects, raw });
57
+ }
58
+ words = [];
59
+ redirects = [];
60
+ };
61
+ // Reads one word starting at i (quotes allowed); stops at whitespace or an
62
+ // operator character outside quotes.
63
+ const readWord = () => {
64
+ let value = "";
65
+ let quoted = false;
66
+ let singleQuoted = false;
67
+ let started = false;
68
+ while (i < command.length) {
69
+ const c = command[i];
70
+ if (isWhitespace(c) || isOperatorChar(c)) {
71
+ // `{}` as a standalone word (find -exec ... {} ;) is a literal.
72
+ if (c === "{" &&
73
+ command[i + 1] === "}" &&
74
+ !started &&
75
+ (i + 2 >= command.length || isWhitespace(command[i + 2]))) {
76
+ value += "{}";
77
+ started = true;
78
+ i += 2;
79
+ continue;
80
+ }
81
+ // `${NAME}` parameter expansion stays part of the word.
82
+ if (c === "{" && value.endsWith("$")) {
83
+ const close = command.indexOf("}", i);
84
+ if (close === -1) {
85
+ throw new ShellSyntaxError("unterminated ${...} expansion");
86
+ }
87
+ value += command.slice(i, close + 1);
88
+ i = close + 1;
89
+ continue;
90
+ }
91
+ break;
92
+ }
93
+ if (c === "'" || c === '"') {
94
+ const close = command.indexOf(c, i + 1);
95
+ if (close === -1) {
96
+ throw new ShellSyntaxError(`unterminated ${c} quote`);
97
+ }
98
+ value += command.slice(i + 1, close);
99
+ quoted = true;
100
+ started = true;
101
+ if (c === "'") {
102
+ singleQuoted = true;
103
+ }
104
+ i = close + 1;
105
+ continue;
106
+ }
107
+ value += c;
108
+ started = true;
109
+ i++;
110
+ }
111
+ return started ? { value, quoted, singleQuoted } : null;
112
+ };
113
+ const readRedirect = () => {
114
+ // A preceding unquoted all-digit word or `*` is the redirect's fd, not an argument.
115
+ const previous = words[words.length - 1];
116
+ const previousEnd = i > 0 ? command[i - 1] : " ";
117
+ if (previous &&
118
+ !previous.quoted &&
119
+ !isWhitespace(previousEnd) &&
120
+ /^(\d+|\*)$/.test(previous.value)) {
121
+ words.pop();
122
+ }
123
+ let operator = command[i];
124
+ i++;
125
+ if (operator === "<" && command[i] === "<") {
126
+ throw new ShellSyntaxError("heredocs and here-strings are not allowed");
127
+ }
128
+ if (operator === "<" && command[i] === ">") {
129
+ throw new ShellSyntaxError("'<>' redirection is not allowed");
130
+ }
131
+ if (operator === ">" && (command[i] === ">" || command[i] === "|")) {
132
+ operator += command[i];
133
+ i++;
134
+ }
135
+ // fd duplication: 2>&1, >&2, <&0, >&-
136
+ if (command[i] === "&") {
137
+ const fd = /^&(\d+|-)/.exec(command.slice(i));
138
+ if (fd) {
139
+ i += fd[0].length;
140
+ redirects.push({ operator: `${operator}${fd[0]}`, target: null });
141
+ return;
142
+ }
143
+ operator += "&";
144
+ i++;
145
+ }
146
+ while (i < command.length && isWhitespace(command[i])) {
147
+ i++;
148
+ }
149
+ const target = readWord();
150
+ if (!target || !target.value) {
151
+ throw new ShellSyntaxError(`redirection '${operator}' has no target`);
152
+ }
153
+ redirects.push({
154
+ operator,
155
+ target: NULL_DEVICES.has(target.value.toLowerCase()) ? null : target,
156
+ });
157
+ };
158
+ while (i < command.length) {
159
+ const c = command[i];
160
+ if (isWhitespace(c)) {
161
+ i++;
162
+ continue;
163
+ }
164
+ if (c === ";") {
165
+ endSegment(i);
166
+ i++;
167
+ segmentStart = i;
168
+ continue;
169
+ }
170
+ if (c === "|") {
171
+ endSegment(i);
172
+ i += command[i + 1] === "|" ? 2 : 1;
173
+ segmentStart = i;
174
+ continue;
175
+ }
176
+ if (c === "&") {
177
+ if (command[i + 1] === "&") {
178
+ endSegment(i);
179
+ i += 2;
180
+ segmentStart = i;
181
+ continue;
182
+ }
183
+ if (command[i + 1] === ">") {
184
+ // bash `&>file` / `&>>file`: both streams to a file.
185
+ i++;
186
+ readRedirect();
187
+ continue;
188
+ }
189
+ throw new ShellSyntaxError("a single '&' (background job / PowerShell call operator) is not allowed; use ';' to chain commands ('&&' also works in bash)");
190
+ }
191
+ if (c === ">" || c === "<") {
192
+ if (command[i + 1] === "(") {
193
+ throw new ShellSyntaxError("process substitution is not allowed");
194
+ }
195
+ readRedirect();
196
+ continue;
197
+ }
198
+ if ("(){}".includes(c)) {
199
+ const word = c === "{" ? readWord() : null;
200
+ if (word) {
201
+ words.push(word);
202
+ continue;
203
+ }
204
+ throw new ShellSyntaxError("grouping, subshells and script blocks ('(', ')', '{', '}') are not allowed outside quotes");
205
+ }
206
+ const word = readWord();
207
+ if (word) {
208
+ words.push(word);
209
+ }
210
+ }
211
+ endSegment(command.length);
212
+ return { segments };
213
+ }
214
+ /** First word of every segment, in order, without duplicates. */
215
+ export function getRootCommands(parsed) {
216
+ const roots = [];
217
+ for (const segment of parsed.segments) {
218
+ const root = segment.words[0]?.value;
219
+ if (root && !roots.includes(root)) {
220
+ roots.push(root);
221
+ }
222
+ }
223
+ return roots;
224
+ }
225
+ //# sourceMappingURL=shell-parser.js.map