mcp-castor 2026.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.
Files changed (42) hide show
  1. package/README.md +487 -0
  2. package/bin/castor.js +706 -0
  3. package/index.js +206 -0
  4. package/package.json +97 -0
  5. package/skills/canary-test-staging/SKILL.md +24 -0
  6. package/skills/evo-mutation-rollback/SKILL.md +29 -0
  7. package/skills/hypothesis-generation/SKILL.md +26 -0
  8. package/skills/traceback-condensing/SKILL.md +26 -0
  9. package/src/castor_runner.js +469 -0
  10. package/src/config.js +1204 -0
  11. package/src/env.js +10 -0
  12. package/src/evo_engine.js +214 -0
  13. package/src/harness/core/events.js +75 -0
  14. package/src/harness/core/kernel.js +209 -0
  15. package/src/harness/evo/evaluator.js +156 -0
  16. package/src/harness/evo/evo_operator.js +550 -0
  17. package/src/harness/evo/lineage_dag.js +383 -0
  18. package/src/harness/evo/trace_repair.js +173 -0
  19. package/src/harness/evo/watchdog.js +72 -0
  20. package/src/harness/loop_detector.js +135 -0
  21. package/src/harness/runner.js +1216 -0
  22. package/src/harness/services/ast_service.js +1813 -0
  23. package/src/harness/services/event_logger.js +275 -0
  24. package/src/harness/services/mcp_bridge.js +408 -0
  25. package/src/harness/services/provider_vllm.js +728 -0
  26. package/src/harness/services/sandbox_fs.js +1238 -0
  27. package/src/harness/services/searxng_lifecycle.js +254 -0
  28. package/src/harness/services/shell_executor.js +264 -0
  29. package/src/harness/services/shell_validator.js +506 -0
  30. package/src/harness/services/web_service.js +828 -0
  31. package/src/platform.js +344 -0
  32. package/src/repetition_detector.js +139 -0
  33. package/src/semaphore.js +373 -0
  34. package/src/server_lifecycle.js +781 -0
  35. package/src/skills.js +400 -0
  36. package/src/state_pruner.js +392 -0
  37. package/src/task_registry.js +1357 -0
  38. package/src/telemetry.js +638 -0
  39. package/src/tools.js +997 -0
  40. package/src/wsl_bridge.js +629 -0
  41. package/src/wsl_env.js +171 -0
  42. package/stream_proxy.js +453 -0
@@ -0,0 +1,506 @@
1
+ /**
2
+ * ============================================================================
3
+ * ⚠️ DANGER DANGER DANGER — STRICT ISOLATION ENCLAVE — READ BEFORE EDITING ⚠️
4
+ * ============================================================================
5
+ *
6
+ * ATTENTION ALL FUTURE AGENTS & DEVELOPERS:
7
+ * 1. NEVER IMPORT `node:child_process` IN THIS FILE.
8
+ * 2. NEVER ADD ANY PROCESS SPAWNING, EXECUTION, OR SYSTEM-CALL PRIMITIVES HERE.
9
+ * 3. THIS FILE MUST REMAIN A PURE, ZERO-DEPENDENCY IN-MEMORY STRING PARSER.
10
+ * 4. ALL ADVERSARIAL AND DESTRUCTIVE TEST VECTORS (rm, del, format, etc.)
11
+ * MUST ONLY BE EVALUATED THROUGH THIS MODULE'S VALIDATOR FUNCTIONS.
12
+ *
13
+ * VIOLATION OF THIS INVARIANT CREATES A HOST ESCAPE & DESTRUCTION HAZARD.
14
+ * AUTOMATED AUDIT IN `tests/security.test.js` CATEGORY 5e ENFORCES THIS RULE.
15
+ * ============================================================================
16
+ *
17
+ * Pure In-Memory Shell Security Validator & Policy Enforcement Service
18
+ *
19
+ * ARCHITECTURAL SAFETY GUARANTEE:
20
+ * - ZERO imports of `node:child_process`
21
+ * - ZERO execution primitives (no exec, no spawn, no system calls)
22
+ * - Safe for direct import by test suites, security probes, and validation gates
23
+ *
24
+ * Provides:
25
+ * - Path-aware protected-roots analysis (blocks root, home, Windows/Users, raw dev)
26
+ * - Recursive command unwrapping (sudo, doas, env, nice, nohup, xargs, shell -c)
27
+ * - Chain/delimiter segment splitting (`;` `&&` `||` `|` newline) so every
28
+ * command segment is analyzed, not just the first
29
+ * - Fail-closed refusal of unexpanded shell references ($VAR, $(cmd), backticks)
30
+ * - Windows flag disambiguation (/s /q as flags, not path operands)
31
+ * - Hard-coded in-memory dead-man fuse with synthetic canary token support
32
+ */
33
+
34
+ import path from "node:path";
35
+ import { wslHome, winHome, winHomeWsl, toPosixWslPath } from "../../platform.js";
36
+
37
+ // ---------------------------------------------------------------------------
38
+ // Pattern-level blocks (verbatim, not path-aware).
39
+ // These match catastrophic commands by signature regardless of target path.
40
+ // ---------------------------------------------------------------------------
41
+ const PATTERN_LEVEL_BLOCKS = [
42
+ /\b(mkfs(\.[a-z0-9]+)?|fdisk|parted)\b/i,
43
+ /\bformat\s+[A-Za-z]:/i,
44
+ /\bdd\s+.*of=\/dev\/(sd[a-z]|nvme|hd[a-z]|vd[a-z])/i,
45
+ /:\(\)\s*\{\s*:\s*\|\s*:\s*&\s*\}\s*;/
46
+ ];
47
+
48
+ // Destructive commands that operate on path operands.
49
+ // Includes POSIX (rm) and Windows/PowerShell forms (del, rmdir, rd,
50
+ // remove-item, ri, erase) so PowerShell/cmd aliases are analyzed too.
51
+ const DESTRUCTIVE_COMMANDS = new Set([
52
+ "rm", "del", "rmdir", "rd", "remove-item", "ri", "erase",
53
+ ]);
54
+
55
+ // Transparent prefix commands that are skipped before the real command.
56
+ // `env` may be followed by VAR=value tokens, which are also skipped.
57
+ const TRANSPARENT_PREFIXES = new Set([
58
+ "sudo", "doas", "env", "nice", "nohup", "xargs",
59
+ ]);
60
+
61
+ // Shell wrappers: a shell command followed by its string-arg flag carries the
62
+ // real command as an inner string. "single" = inner is the next (quoted) token;
63
+ // "rest" = inner is the concatenation of all remaining tokens (cmd /c style).
64
+ const SHELL_WRAPPERS = {
65
+ bash: { flags: new Set(["-c"]), inner: "single" },
66
+ sh: { flags: new Set(["-c"]), inner: "single" },
67
+ dash: { flags: new Set(["-c"]), inner: "single" },
68
+ zsh: { flags: new Set(["-c"]), inner: "single" },
69
+ cmd: { flags: new Set(["/c"]), inner: "rest" },
70
+ "cmd.exe": { flags: new Set(["/c"]), inner: "rest" },
71
+ powershell: { flags: new Set(["-command"]), inner: "single" },
72
+ pwsh: { flags: new Set(["-command"]), inner: "single" },
73
+ };
74
+
75
+ // Bounded recursion depth for unwrapping nested shell wrappers.
76
+ const MAX_UNWRAP_DEPTH = 4;
77
+
78
+ // Known Windows flag tokens (slash-prefixed single-letter flags).
79
+ // On Windows, `del /s /q` uses slashes as flags, not path separators.
80
+ const WINDOWS_FLAG_TOKENS = new Set([
81
+ "/f", "/s", "/q", "/p", "/a", "/c", "/e", "/t", "/y", "/i",
82
+ ]);
83
+
84
+ // Dedicated non-destructive synthetic canary token for zero-risk fuse testing.
85
+ export const CANARY_DISASTER_FUSE_TOKEN = "__CANARY_TRIGGER_DISASTER_FUSE__";
86
+
87
+ /**
88
+ * Hard-coded in-memory dead-man fuse.
89
+ * Pure string verification designed to sit directly before any live execution boundary.
90
+ * Throws FatalDeadManFuseError if tripped.
91
+ */
92
+ export function assertDeadManFuse(command) {
93
+ if (!command || typeof command !== "string") return;
94
+
95
+ if (command.includes(CANARY_DISASTER_FUSE_TOKEN)) {
96
+ throw new Error("FatalDeadManFuseError: Execution halted by dead-man fuse (synthetic canary tripped)");
97
+ }
98
+
99
+ for (const pattern of PATTERN_LEVEL_BLOCKS) {
100
+ if (pattern.test(command)) {
101
+ throw new Error(`FatalDeadManFuseError: Execution halted by dead-man fuse. Matches catastrophic signature: ${pattern}`);
102
+ }
103
+ }
104
+ }
105
+
106
+ /**
107
+ * Quote-aware tokenizer: splits a command string into tokens,
108
+ * preserving quoted substrings (both single and double quotes).
109
+ */
110
+ function tokenizeCommand(cmd) {
111
+ const tokens = [];
112
+ let cur = "";
113
+ let quote = null;
114
+ for (let i = 0; i < cmd.length; i++) {
115
+ const ch = cmd[i];
116
+ if (quote) {
117
+ if (ch === quote) {
118
+ quote = null;
119
+ } else {
120
+ cur += ch;
121
+ }
122
+ } else {
123
+ if (ch === '"' || ch === "'") {
124
+ quote = ch;
125
+ } else if (ch === " " || ch === "\t") {
126
+ if (cur.length) tokens.push(cur);
127
+ cur = "";
128
+ } else {
129
+ cur += ch;
130
+ }
131
+ }
132
+ }
133
+ if (cur.length) tokens.push(cur);
134
+ return tokens;
135
+ }
136
+
137
+ /**
138
+ * Split a command line into its constituent command segments on the shell
139
+ * control operators `;`, `&&`, `||`, `|`, and newlines. Quoted regions are
140
+ * respected (a `;` inside quotes is data, not a separator).
141
+ *
142
+ * This is the key to closing the "destructive command hidden as a later
143
+ * segment" evasion: `cd /tmp && rm -rf /` must be analyzed as TWO commands
144
+ * (`cd /tmp` and `rm -rf /`), not one.
145
+ *
146
+ * Returns an array of segment strings (whitespace-trimmed, empties dropped).
147
+ */
148
+ function splitCommandSegments(cmd) {
149
+ const segments = [];
150
+ let cur = "";
151
+ let quote = null;
152
+ let pendingOp = null; // the operator that triggered the last split
153
+ const flush = () => {
154
+ const t = cur.trim();
155
+ if (t.length) segments.push(t);
156
+ cur = "";
157
+ };
158
+ for (let i = 0; i < cmd.length; i++) {
159
+ const ch = cmd[i];
160
+ if (quote) {
161
+ if (ch === quote) {
162
+ quote = null;
163
+ }
164
+ cur += ch;
165
+ continue;
166
+ }
167
+ if (ch === '"' || ch === "'") {
168
+ quote = ch;
169
+ cur += ch;
170
+ continue;
171
+ }
172
+ // Two-char operators first (&& and ||).
173
+ if (ch === "&" && cmd[i + 1] === "&") {
174
+ flush();
175
+ pendingOp = "&&";
176
+ i++; // skip the second &
177
+ continue;
178
+ }
179
+ if (ch === "|" && cmd[i + 1] === "|") {
180
+ flush();
181
+ pendingOp = "||";
182
+ i++; // skip the second |
183
+ continue;
184
+ }
185
+ if (ch === ";" || ch === "|" || ch === "\n" || ch === "\r") {
186
+ flush();
187
+ pendingOp = ch;
188
+ continue;
189
+ }
190
+ cur += ch;
191
+ }
192
+ flush();
193
+ return segments;
194
+ }
195
+
196
+ /** Extract the command name (basename, lowercased) from the first token. */
197
+ function commandNameOf(token) {
198
+ if (!token) return "";
199
+ const parts = token.split(/[\\/]/);
200
+ return parts[parts.length - 1].toLowerCase();
201
+ }
202
+
203
+ /** Determine whether a token is a flag/option (not a path operand). */
204
+ function isFlag(token) {
205
+ if (token.startsWith("-")) return true; // POSIX flag
206
+ if (WINDOWS_FLAG_TOKENS.has(token.toLowerCase())) return true; // Windows flag
207
+ return false;
208
+ }
209
+
210
+ /**
211
+ * Fail-closed guard: a destructive operand containing an unexpanded shell
212
+ * reference — a `$` variable/command substitution ($VAR, ${VAR}, $(...)) OR a
213
+ * backtick command substitution (`cmd`) — cannot be safely resolved, so it
214
+ * must be blocked. Literal paths (no $ and no backtick) are unaffected.
215
+ */
216
+ function hasUnexpandedReference(operand) {
217
+ return /[$`]/.test(operand);
218
+ }
219
+
220
+ /**
221
+ * Expand `~` using the platform resolvers.
222
+ * Windows-form commands (del/rmdir/rd) use winHome();
223
+ * POSIX-form commands (rm) use wslHome().
224
+ * Also handles `~user` (another user's home) so it can be checked against
225
+ * protected roots.
226
+ */
227
+ function expandTilde(operand, isWindowsCmd) {
228
+ if (operand === "~") {
229
+ return isWindowsCmd ? winHome() : wslHome();
230
+ }
231
+ if (operand.startsWith("~/") || operand.startsWith("~\\")) {
232
+ const home = isWindowsCmd ? winHome() : wslHome();
233
+ return home + operand.slice(1).replace(/\\/g, "/");
234
+ }
235
+ // `~user` / `~user/sub` — another user's home (POSIX) or `~\user` (Windows).
236
+ // Distinguished from `~/...` (home-relative) by the char right after `~`
237
+ // NOT being a path separator. Fail closed: resolve to that user's home so
238
+ // the protected-root check can see it.
239
+ const m = operand.match(/^~([^\\\/])(.*)$/);
240
+ if (m) {
241
+ const user = m[1] + m[2]; // e.g. "user" or "user/sub"
242
+ if (isWindowsCmd) {
243
+ // C:\Users\<user>
244
+ return `C:\\Users\\${user}`;
245
+ }
246
+ // /home/<user> (or /root for root)
247
+ return user === "root" ? "/root" : `/home/${user}`;
248
+ }
249
+ return operand;
250
+ }
251
+
252
+ /**
253
+ * Normalize a path operand to a canonical POSIX form (lowercased, no trailing slash).
254
+ * Uses the wsl_bridge translators (re-exported from platform.js) for Windows<->POSIX.
255
+ */
256
+ function normalizeOperand(operand, cwd, isWindowsCmd) {
257
+ let p = expandTilde(operand, isWindowsCmd);
258
+ // Unicode/homoglyph defense: fold fullwidth & compatibility forms to their
259
+ // canonical ASCII equivalents (e.g. fullwidth `W` U+FF37 -> `W`) so a
260
+ // homoglyph path cannot dodge the protected-root string comparison.
261
+ p = p.normalize("NFKC");
262
+ // Convert to POSIX form via the existing translator
263
+ let posix = toPosixWslPath(p);
264
+ // Preserve a trailing wildcard component (e.g. "/*", "C:\Users\*") so it is
265
+ // not collapsed away by path normalization.
266
+ let wildcard = "";
267
+ if (posix.endsWith("/*")) {
268
+ wildcard = "/*";
269
+ posix = posix.slice(0, -2);
270
+ } else if (posix.endsWith("*")) {
271
+ wildcard = "*";
272
+ posix = posix.slice(0, -1);
273
+ }
274
+ // A bare "/*" collapses to an empty string after stripping; treat as root.
275
+ if (posix === "") {
276
+ posix = "/";
277
+ }
278
+ // Resolve relative operands against the sandbox cwd
279
+ if (!posix.startsWith("/")) {
280
+ const cwdPosix = toPosixWslPath(cwd || process.cwd());
281
+ posix = path.posix.join(cwdPosix, posix);
282
+ }
283
+ // Normalize (resolve . and ..)
284
+ posix = path.posix.normalize(posix);
285
+ // Strip trailing slash (except for the filesystem root)
286
+ if (posix !== "/" && posix.endsWith("/")) {
287
+ posix = posix.replace(/\/+$/, "");
288
+ }
289
+ // Re-attach the preserved wildcard. For the filesystem root the direct
290
+ // wildcard is "/*" (not "//*"), so attach without a leading slash.
291
+ if (wildcard) {
292
+ posix = posix === "/" ? wildcard : posix + wildcard;
293
+ }
294
+ return posix.toLowerCase();
295
+ }
296
+
297
+ /**
298
+ * Build the set of protected roots in canonical POSIX form (lowercased).
299
+ * Includes: filesystem root, WSL home, Windows home (as /mnt/c/Users/<user>),
300
+ * all drive roots (/mnt/a/ through /mnt/z/), C:\Windows, C:\Users, C:\Program Files.
301
+ */
302
+ function buildProtectedRoots() {
303
+ const roots = new Set();
304
+ roots.add("/"); // filesystem root
305
+ // WSL home (~)
306
+ const wh = wslHome();
307
+ if (wh) roots.add(wh.toLowerCase());
308
+ // Windows home as seen from WSL
309
+ const whw = winHomeWsl();
310
+ if (whw) roots.add(whw.toLowerCase());
311
+ // Home directories are high-value targets: block the home tree root and the
312
+ // root user's home. `~user` expands to /home/<user> (or /root), which is a
313
+ // direct child of /home, so the /home direct-wildcard check catches it.
314
+ roots.add("/root");
315
+ roots.add("/home");
316
+ // Drive roots: /mnt/a through /mnt/z
317
+ for (let i = 0; i < 26; i++) {
318
+ const letter = String.fromCharCode(97 + i);
319
+ roots.add(`/mnt/${letter}`);
320
+ }
321
+ // C:\Windows, C:\Users, C:\Program Files
322
+ roots.add("/mnt/c/windows");
323
+ roots.add("/mnt/c/users");
324
+ roots.add("/mnt/c/program files");
325
+ return roots;
326
+ }
327
+
328
+ // Lazy cache for protected roots (platform resolvers are cached; this is cheap).
329
+ let _protectedRoots = null;
330
+ function getProtectedRoots() {
331
+ if (!_protectedRoots) {
332
+ _protectedRoots = buildProtectedRoots();
333
+ }
334
+ return _protectedRoots;
335
+ }
336
+
337
+ /**
338
+ * Check whether a normalized POSIX path is a protected root or its direct wildcard.
339
+ * Also blocks /dev/sd* and related raw-device targets.
340
+ */
341
+ function isProtectedRootOrWildcard(posixPath) {
342
+ const p = posixPath.toLowerCase();
343
+ // Raw device targets: /dev/sdX, /dev/nvmeXnY, /dev/hdX, /dev/vdX (and direct wildcards)
344
+ if (/^\/dev\/(sd[a-z]+|nvme\d+n\d+|hd[a-z]+|vd[a-z]+)(\/\*)?$/.test(p)) return true;
345
+ for (const root of getProtectedRoots()) {
346
+ if (p === root) return true;
347
+ // The filesystem root's direct wildcard is "/*" (not "//*").
348
+ const directWildcard = root === "/" ? "/*" : root + "/*";
349
+ if (p === directWildcard) return true;
350
+ }
351
+ // User home directories are high-value targets (same class as `~`): block
352
+ // /root and any /home/<user> (a user's home), but still allow deeper
353
+ // subpaths (e.g. /home/<user>/proj) consistent with the protected-root
354
+ // "block the root + direct wildcard, allow deeper subpaths" policy.
355
+ if (p === "/root" || /^\/home\/[^\/]+$/.test(p)) return true;
356
+ return false;
357
+ }
358
+
359
+ /**
360
+ * Analyze a single command segment (no control operators) for destructive
361
+ * intent. This is the per-segment core of the recursive analysis.
362
+ *
363
+ * At each level:
364
+ * 1. Run the verbatim pattern-level blocks (fork bomb, mkfs, format, dd).
365
+ * 2. Tokenize and skip transparent prefixes (sudo/doas/env+VAR=nice/nohup/xargs).
366
+ * 3. If the command is a shell wrapper (bash/sh/dash/zsh -c, cmd /c,
367
+ * powershell/pwsh -Command), recurse on the inner command string
368
+ * (bounded depth to avoid pathological nesting). The inner string is
369
+ * re-split into segments so a chain hidden inside a wrapper
370
+ * (e.g. `bash -c "cd /tmp && rm -rf /"`) is fully analyzed.
371
+ * 4. If the command is a destructive path command (rm/del/rmdir/rd/
372
+ * remove-item/ri/erase), fail closed on unexpanded shell references and
373
+ * block any operand that normalizes to a protected root or its wildcard.
374
+ */
375
+ function analyzeSegment(command, cwd, depth) {
376
+ const tokens = tokenizeCommand(command);
377
+ if (tokens.length === 0) return;
378
+
379
+ // Skip transparent prefix commands (and env's VAR=value tokens).
380
+ let i = 0;
381
+ while (i < tokens.length) {
382
+ const name = commandNameOf(tokens[i]);
383
+ if (name === "env") {
384
+ i++;
385
+ while (i < tokens.length && /^[A-Za-z_][A-Za-z0-9_]*=/.test(tokens[i])) {
386
+ i++;
387
+ }
388
+ continue;
389
+ }
390
+ if (TRANSPARENT_PREFIXES.has(name)) {
391
+ i++;
392
+ continue;
393
+ }
394
+ break;
395
+ }
396
+ if (i >= tokens.length) return;
397
+
398
+ const name = commandNameOf(tokens[i]);
399
+
400
+ // Shell wrapper: recurse on the inner command string (bounded depth).
401
+ const wrapper = SHELL_WRAPPERS[name];
402
+ if (wrapper) {
403
+ if (depth < MAX_UNWRAP_DEPTH && i + 1 < tokens.length && wrapper.flags.has(tokens[i + 1].toLowerCase())) {
404
+ let inner;
405
+ if (wrapper.inner === "single") {
406
+ if (i + 2 < tokens.length) inner = tokens[i + 2];
407
+ } else {
408
+ if (i + 2 < tokens.length) inner = tokens.slice(i + 2).join(" ");
409
+ }
410
+ if (inner !== undefined) {
411
+ // Re-split the inner string into segments so a chain hidden inside a
412
+ // wrapper (e.g. `bash -c "cd /tmp && rm -rf /"`) is fully analyzed.
413
+ analyzeCommand(inner, cwd, depth + 1);
414
+ }
415
+ }
416
+ return; // a shell wrapper is not itself a destructive path command
417
+ }
418
+
419
+ // Destructive path command: fail closed on unexpanded refs, then check roots.
420
+ if (DESTRUCTIVE_COMMANDS.has(name)) {
421
+ const isWindowsCmd = name !== "rm"; // rm is POSIX; the rest are Windows forms
422
+ const operands = [];
423
+ for (let j = i + 1; j < tokens.length; j++) {
424
+ const tok = tokens[j];
425
+ // `--name=value` / `-name=value` flags can carry a PATH as their value
426
+ // (e.g. `rm -rf --no-preserve-root=/`). Fail closed: treat the value as
427
+ // an operand so it is subject to the unexpanded-ref + protected-root
428
+ // checks. A bare flag (no `=`) is still skipped.
429
+ const eq = tok.indexOf("=");
430
+ if (eq > 0 && (tok.startsWith("-") || tok.startsWith("/"))) {
431
+ const value = tok.slice(eq + 1);
432
+ if (value.length) operands.push(value);
433
+ continue;
434
+ }
435
+ if (!isFlag(tok)) {
436
+ operands.push(tok);
437
+ }
438
+ }
439
+ for (const operand of operands) {
440
+ // Fail closed: an unexpanded shell reference cannot be safely resolved.
441
+ if (hasUnexpandedReference(operand)) {
442
+ throw new Error(
443
+ `CommandSecurityError: Execution blocked. Unexpanded shell reference in target '${operand}'`
444
+ );
445
+ }
446
+ const normalized = normalizeOperand(operand, cwd, isWindowsCmd);
447
+ if (isProtectedRootOrWildcard(normalized)) {
448
+ throw new Error(
449
+ `CommandSecurityError: Execution blocked. Target '${operand}' resolves to protected root: ${normalized}`
450
+ );
451
+ }
452
+ }
453
+ }
454
+ }
455
+
456
+ /**
457
+ * Recursively analyze a command string for destructive intent.
458
+ *
459
+ * The command line is first split into its constituent segments on the shell
460
+ * control operators (`;`, `&&`, `||`, `|`, newlines) and EVERY segment is
461
+ * analyzed — so a destructive command hidden as a later segment
462
+ * (e.g. `cd /tmp && rm -rf /`) cannot slip past a first-segment-only check.
463
+ * A single-segment command (no control operators) behaves exactly as before.
464
+ */
465
+ function analyzeCommand(command, cwd, depth) {
466
+ // Pattern-level blocks (verbatim, not path-aware) run on the FULL command
467
+ // string — NOT per-segment — because signatures like the fork bomb
468
+ // `:(){ :|:& };:` span multiple `|`/`;`-separated segments. Running them on
469
+ // the whole string (at every recursion level) catches both bare and
470
+ // wrapped forms (e.g. `bash -c "mkfs ..."`) without being broken by the
471
+ // segment split.
472
+ for (const pattern of PATTERN_LEVEL_BLOCKS) {
473
+ if (pattern.test(command)) {
474
+ throw new Error(`CommandSecurityError: Execution blocked. Command matches prohibited destructive pattern: ${pattern}`);
475
+ }
476
+ }
477
+
478
+ const segments = splitCommandSegments(command);
479
+ for (const seg of segments) {
480
+ analyzeSegment(seg, cwd, depth);
481
+ }
482
+ }
483
+
484
+ /**
485
+ * Top-level shell safety validator.
486
+ * Enforces CWD containment within the workspace and runs recursive path-aware analysis.
487
+ */
488
+ export function validateShellSafety(command, effectiveCwd, rootCwd) {
489
+ if (!command || typeof command !== "string" || command.trim().length === 0) {
490
+ throw new Error("InvalidCommandError: Shell command must be a non-empty string");
491
+ }
492
+
493
+ // CWD containment check: ensure working directory is within workspace
494
+ if (effectiveCwd && rootCwd) {
495
+ const normTarget = path.normalize(effectiveCwd);
496
+ const normRoot = path.normalize(rootCwd);
497
+ const rel = path.relative(normRoot, normTarget);
498
+ if (rel.startsWith("..") || (path.isAbsolute(rel) && !rel.startsWith(normRoot))) {
499
+ throw new Error(`PathEscapeError: Working directory '${effectiveCwd}' escapes workspace root '${rootCwd}'`);
500
+ }
501
+ }
502
+
503
+ // Path-aware protected-roots analysis (recursive: unwraps prefixes/wrappers).
504
+ const cwd = effectiveCwd || rootCwd || process.cwd();
505
+ analyzeCommand(command, cwd, 0);
506
+ }