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.
- package/README.md +487 -0
- package/bin/castor.js +706 -0
- package/index.js +206 -0
- package/package.json +97 -0
- package/skills/canary-test-staging/SKILL.md +24 -0
- package/skills/evo-mutation-rollback/SKILL.md +29 -0
- package/skills/hypothesis-generation/SKILL.md +26 -0
- package/skills/traceback-condensing/SKILL.md +26 -0
- package/src/castor_runner.js +469 -0
- package/src/config.js +1204 -0
- package/src/env.js +10 -0
- package/src/evo_engine.js +214 -0
- package/src/harness/core/events.js +75 -0
- package/src/harness/core/kernel.js +209 -0
- package/src/harness/evo/evaluator.js +156 -0
- package/src/harness/evo/evo_operator.js +550 -0
- package/src/harness/evo/lineage_dag.js +383 -0
- package/src/harness/evo/trace_repair.js +173 -0
- package/src/harness/evo/watchdog.js +72 -0
- package/src/harness/loop_detector.js +135 -0
- package/src/harness/runner.js +1216 -0
- package/src/harness/services/ast_service.js +1813 -0
- package/src/harness/services/event_logger.js +275 -0
- package/src/harness/services/mcp_bridge.js +408 -0
- package/src/harness/services/provider_vllm.js +728 -0
- package/src/harness/services/sandbox_fs.js +1238 -0
- package/src/harness/services/searxng_lifecycle.js +254 -0
- package/src/harness/services/shell_executor.js +264 -0
- package/src/harness/services/shell_validator.js +506 -0
- package/src/harness/services/web_service.js +828 -0
- package/src/platform.js +344 -0
- package/src/repetition_detector.js +139 -0
- package/src/semaphore.js +373 -0
- package/src/server_lifecycle.js +781 -0
- package/src/skills.js +400 -0
- package/src/state_pruner.js +392 -0
- package/src/task_registry.js +1357 -0
- package/src/telemetry.js +638 -0
- package/src/tools.js +997 -0
- package/src/wsl_bridge.js +629 -0
- package/src/wsl_env.js +171 -0
- 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
|
+
}
|