@warlock.js/ai-workspace 5.30.0 → 5.31.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 +46 -2
- package/README.md +34 -21
- package/cjs/index.cjs +3116 -309
- package/cjs/index.cjs.map +1 -1
- package/esm/backends/local.d.mts.map +1 -1
- package/esm/backends/local.mjs +165 -27
- package/esm/backends/local.mjs.map +1 -1
- package/esm/backends/mock.d.mts +11 -5
- package/esm/backends/mock.d.mts.map +1 -1
- package/esm/backends/mock.mjs +34 -0
- package/esm/backends/mock.mjs.map +1 -1
- package/esm/contracts/index.d.mts +3 -3
- package/esm/contracts/tool-io.type.d.mts +202 -4
- package/esm/contracts/tool-io.type.d.mts.map +1 -1
- package/esm/contracts/workspace-backend.contract.d.mts +61 -1
- package/esm/contracts/workspace-backend.contract.d.mts.map +1 -1
- package/esm/contracts/workspace-ops.contract.d.mts +32 -3
- package/esm/contracts/workspace-ops.contract.d.mts.map +1 -1
- package/esm/contracts/workspace-policy.type.d.mts +133 -12
- package/esm/contracts/workspace-policy.type.d.mts.map +1 -1
- package/esm/contracts/workspace.contract.d.mts +63 -9
- package/esm/contracts/workspace.contract.d.mts.map +1 -1
- package/esm/errors.d.mts +79 -5
- package/esm/errors.d.mts.map +1 -1
- package/esm/errors.mjs +22 -1
- package/esm/errors.mjs.map +1 -1
- package/esm/index.d.mts +13 -5
- package/esm/index.mjs +10 -2
- package/esm/ops.d.mts +21 -2
- package/esm/ops.d.mts.map +1 -1
- package/esm/ops.mjs +230 -64
- package/esm/ops.mjs.map +1 -1
- package/esm/policy/jail.mjs +73 -0
- package/esm/policy/jail.mjs.map +1 -0
- package/esm/policy/policy.d.mts +25 -15
- package/esm/policy/policy.d.mts.map +1 -1
- package/esm/policy/policy.mjs +61 -85
- package/esm/policy/policy.mjs.map +1 -1
- package/esm/policy/shell-check.d.mts +64 -0
- package/esm/policy/shell-check.d.mts.map +1 -0
- package/esm/policy/shell-check.mjs +627 -0
- package/esm/policy/shell-check.mjs.map +1 -0
- package/esm/policy/shell-parser.mjs +802 -0
- package/esm/policy/shell-parser.mjs.map +1 -0
- package/esm/policy/tokenize-command.d.mts +63 -0
- package/esm/policy/tokenize-command.d.mts.map +1 -0
- package/esm/policy/tokenize-command.mjs +75 -23
- package/esm/policy/tokenize-command.mjs.map +1 -1
- package/esm/shell-jobs.d.mts +45 -0
- package/esm/shell-jobs.d.mts.map +1 -0
- package/esm/shell-jobs.mjs +247 -0
- package/esm/shell-jobs.mjs.map +1 -0
- package/esm/todos.d.mts +22 -0
- package/esm/todos.d.mts.map +1 -0
- package/esm/todos.mjs +73 -0
- package/esm/todos.mjs.map +1 -0
- package/esm/tools/edit-file.d.mts.map +1 -1
- package/esm/tools/edit-file.mjs +2 -1
- package/esm/tools/edit-file.mjs.map +1 -1
- package/esm/tools/glob.d.mts.map +1 -1
- package/esm/tools/glob.mjs +2 -1
- package/esm/tools/glob.mjs.map +1 -1
- package/esm/tools/grep.d.mts.map +1 -1
- package/esm/tools/grep.mjs +2 -1
- package/esm/tools/grep.mjs.map +1 -1
- package/esm/tools/json-schemas.mjs +175 -0
- package/esm/tools/json-schemas.mjs.map +1 -0
- package/esm/tools/list-dir.d.mts +32 -0
- package/esm/tools/list-dir.d.mts.map +1 -0
- package/esm/tools/list-dir.mjs +50 -0
- package/esm/tools/list-dir.mjs.map +1 -0
- package/esm/tools/multi-edit.d.mts +38 -0
- package/esm/tools/multi-edit.d.mts.map +1 -0
- package/esm/tools/multi-edit.mjs +121 -0
- package/esm/tools/multi-edit.mjs.map +1 -0
- package/esm/tools/read-file.d.mts.map +1 -1
- package/esm/tools/read-file.mjs +2 -1
- package/esm/tools/read-file.mjs.map +1 -1
- package/esm/tools/run-shell.d.mts +34 -12
- package/esm/tools/run-shell.d.mts.map +1 -1
- package/esm/tools/run-shell.mjs +59 -53
- package/esm/tools/run-shell.mjs.map +1 -1
- package/esm/tools/run-tests.d.mts +8 -0
- package/esm/tools/run-tests.d.mts.map +1 -1
- package/esm/tools/run-tests.mjs +10 -3
- package/esm/tools/run-tests.mjs.map +1 -1
- package/esm/tools/schema.mjs +23 -18
- package/esm/tools/schema.mjs.map +1 -1
- package/esm/tools/shell-kill.d.mts +25 -0
- package/esm/tools/shell-kill.d.mts.map +1 -0
- package/esm/tools/shell-kill.mjs +36 -0
- package/esm/tools/shell-kill.mjs.map +1 -0
- package/esm/tools/shell-output.d.mts +30 -0
- package/esm/tools/shell-output.d.mts.map +1 -0
- package/esm/tools/shell-output.mjs +49 -0
- package/esm/tools/shell-output.mjs.map +1 -0
- package/esm/tools/todo.d.mts +43 -0
- package/esm/tools/todo.d.mts.map +1 -0
- package/esm/tools/todo.mjs +126 -0
- package/esm/tools/todo.mjs.map +1 -0
- package/esm/tools/write-file.d.mts.map +1 -1
- package/esm/tools/write-file.mjs +2 -1
- package/esm/tools/write-file.mjs.map +1 -1
- package/esm/workspace.d.mts +11 -8
- package/esm/workspace.d.mts.map +1 -1
- package/esm/workspace.mjs +108 -27
- package/esm/workspace.mjs.map +1 -1
- package/llms-full.txt +155 -34
- package/llms.txt +1 -1
- package/package.json +3 -3
- package/skills/README.md +2 -2
- package/skills/build-loop-agent/SKILL.md +7 -7
- package/skills/index.md +6 -1
- package/skills/use-a-workspace/SKILL.md +148 -27
package/cjs/index.cjs
CHANGED
|
@@ -33,6 +33,7 @@ let node_fs_promises = require("node:fs/promises");
|
|
|
33
33
|
let _warlock_js_fs = require("@warlock.js/fs");
|
|
34
34
|
let node_child_process = require("node:child_process");
|
|
35
35
|
let node_process = require("node:process");
|
|
36
|
+
let node_string_decoder = require("node:string_decoder");
|
|
36
37
|
|
|
37
38
|
//#region ../../packages/ai-workspace/src/errors.ts
|
|
38
39
|
/**
|
|
@@ -60,6 +61,8 @@ var WorkspacePolicyError = class extends _warlock_js_ai.AIError {
|
|
|
60
61
|
this.path = options.path;
|
|
61
62
|
this.command = options.command;
|
|
62
63
|
this.pattern = options.pattern;
|
|
64
|
+
this.reason = options.reason;
|
|
65
|
+
this.executable = options.executable;
|
|
63
66
|
}
|
|
64
67
|
};
|
|
65
68
|
/**
|
|
@@ -85,8 +88,896 @@ var WorkspaceEditError = class extends _warlock_js_ai.AIError {
|
|
|
85
88
|
this.matches = options.matches;
|
|
86
89
|
this.expectedHash = options.expectedHash;
|
|
87
90
|
this.actualHash = options.actualHash;
|
|
91
|
+
this.editIndex = options.editIndex;
|
|
88
92
|
}
|
|
89
93
|
};
|
|
94
|
+
/**
|
|
95
|
+
* A `shell_output` / `shell_kill` call (or `shellOutput()` / `shellKill()`)
|
|
96
|
+
* named a background command this workspace does not know. Returned to the
|
|
97
|
+
* agent as tool-error data like the other workspace errors.
|
|
98
|
+
*
|
|
99
|
+
* @example
|
|
100
|
+
* if (error instanceof WorkspaceJobError && error.type === "not-found") {
|
|
101
|
+
* console.warn(`No background command ${error.jobId}`);
|
|
102
|
+
* }
|
|
103
|
+
*/
|
|
104
|
+
var WorkspaceJobError = class extends _warlock_js_ai.AIError {
|
|
105
|
+
constructor(message, options) {
|
|
106
|
+
super("TOOL_EXEC_FAILED", message, options);
|
|
107
|
+
this.name = "WorkspaceJobError";
|
|
108
|
+
this.type = options.type;
|
|
109
|
+
this.jobId = options.jobId;
|
|
110
|
+
}
|
|
111
|
+
};
|
|
112
|
+
|
|
113
|
+
//#endregion
|
|
114
|
+
//#region ../../packages/ai-workspace/src/policy/jail.ts
|
|
115
|
+
/**
|
|
116
|
+
* Whether `child` is contained within `root` (or equals it), comparing
|
|
117
|
+
* absolute paths. Guards against the `/srv/app-evil` vs `/srv/app`
|
|
118
|
+
* prefix-collision by comparing path segments, not string prefixes.
|
|
119
|
+
*/
|
|
120
|
+
function isInside(child, root) {
|
|
121
|
+
const relative = node_path.default.relative(root, child);
|
|
122
|
+
return relative === "" || !relative.startsWith("..") && !node_path.default.isAbsolute(relative);
|
|
123
|
+
}
|
|
124
|
+
/**
|
|
125
|
+
* Translate a glob (the small subset used by `denyPaths` and
|
|
126
|
+
* `list.ignore` — `*`, `**`, `?`) into an anchored `RegExp` over a
|
|
127
|
+
* `/`-separated relative path. `**` spans path separators; a single `*`
|
|
128
|
+
* does not.
|
|
129
|
+
*/
|
|
130
|
+
function globToRegExp(glob) {
|
|
131
|
+
let source = "";
|
|
132
|
+
for (let index = 0; index < glob.length; index++) {
|
|
133
|
+
const char = glob.charAt(index);
|
|
134
|
+
if (char === "*") {
|
|
135
|
+
if (glob.charAt(index + 1) === "*") {
|
|
136
|
+
source += ".*";
|
|
137
|
+
index++;
|
|
138
|
+
if (glob.charAt(index + 1) === "/") index++;
|
|
139
|
+
} else source += "[^/]*";
|
|
140
|
+
continue;
|
|
141
|
+
}
|
|
142
|
+
if (char === "?") {
|
|
143
|
+
source += "[^/]";
|
|
144
|
+
continue;
|
|
145
|
+
}
|
|
146
|
+
source += char.replace(/[.+^${}()|[\]\\]/g, "\\$&");
|
|
147
|
+
}
|
|
148
|
+
return new RegExp(`^${source}$`);
|
|
149
|
+
}
|
|
150
|
+
/**
|
|
151
|
+
* Whether a workspace-relative (`/`-separated) path matches any of the
|
|
152
|
+
* globs. A glob also covers everything beneath a matched directory
|
|
153
|
+
* (`".git/**"` covers `.git/config`), and a bare name without wildcards
|
|
154
|
+
* (`".git"`, `"node_modules"`) covers its contents.
|
|
155
|
+
*/
|
|
156
|
+
function matchesAnyGlob(relativePath, globs) {
|
|
157
|
+
return globs.some((glob) => {
|
|
158
|
+
if (globToRegExp(glob).test(relativePath)) return true;
|
|
159
|
+
if (!glob.includes("*") && !glob.includes("?")) {
|
|
160
|
+
const prefix = glob.endsWith("/") ? glob : `${glob}/`;
|
|
161
|
+
return relativePath.startsWith(prefix);
|
|
162
|
+
}
|
|
163
|
+
return false;
|
|
164
|
+
});
|
|
165
|
+
}
|
|
166
|
+
/**
|
|
167
|
+
* The jail check without touching the filesystem: is the absolute path
|
|
168
|
+
* `target` (already resolved lexically) inside `cwd` and off every
|
|
169
|
+
* `denyPaths` glob, or inside an `allowPaths` root? Symlinks are NOT
|
|
170
|
+
* resolved here — the shell policy uses it to refuse obvious escapes
|
|
171
|
+
* synchronously, and `exec` re-checks every path with `resolveInJail`.
|
|
172
|
+
*/
|
|
173
|
+
function isInsideJailLexically(policy, target) {
|
|
174
|
+
const root = node_path.default.resolve(policy.cwd);
|
|
175
|
+
if (isInside(target, root)) {
|
|
176
|
+
const relative = node_path.default.relative(root, target).split(node_path.default.sep).join("/");
|
|
177
|
+
return !(policy.denyPaths && policy.denyPaths.length > 0 && matchesAnyGlob(relative, policy.denyPaths));
|
|
178
|
+
}
|
|
179
|
+
return (policy.allowPaths ?? []).some((allowRoot) => isInside(target, node_path.default.resolve(allowRoot)));
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
//#endregion
|
|
183
|
+
//#region ../../packages/ai-workspace/src/policy/shell-parser.ts
|
|
184
|
+
/** Deepest nesting of subshells and substitutions the parser accepts. */
|
|
185
|
+
const MAX_NESTING = 16;
|
|
186
|
+
/**
|
|
187
|
+
* Words that start a compound command (or negate a pipeline) when they
|
|
188
|
+
* appear unquoted in command position. The parser does not read compound
|
|
189
|
+
* commands, so a line using one is refused.
|
|
190
|
+
*/
|
|
191
|
+
const RESERVED_WORDS = new Set([
|
|
192
|
+
"!",
|
|
193
|
+
"{",
|
|
194
|
+
"}",
|
|
195
|
+
"[[",
|
|
196
|
+
"]]",
|
|
197
|
+
"case",
|
|
198
|
+
"coproc",
|
|
199
|
+
"do",
|
|
200
|
+
"done",
|
|
201
|
+
"elif",
|
|
202
|
+
"else",
|
|
203
|
+
"esac",
|
|
204
|
+
"fi",
|
|
205
|
+
"for",
|
|
206
|
+
"function",
|
|
207
|
+
"if",
|
|
208
|
+
"in",
|
|
209
|
+
"select",
|
|
210
|
+
"then",
|
|
211
|
+
"until",
|
|
212
|
+
"while"
|
|
213
|
+
]);
|
|
214
|
+
/** cmd.exe keywords the `"cmd"` dialect refuses in command position. */
|
|
215
|
+
const CMD_RESERVED_WORDS = new Set([
|
|
216
|
+
"if",
|
|
217
|
+
"for",
|
|
218
|
+
"goto",
|
|
219
|
+
"setlocal",
|
|
220
|
+
"endlocal"
|
|
221
|
+
]);
|
|
222
|
+
/** A parameter name inside `${…}`: a variable, a positional or a special parameter. */
|
|
223
|
+
const PARAMETER_NAME = String.raw`(?:[A-Za-z_][A-Za-z0-9_]*|[0-9]+|[@*#?$!-])`;
|
|
224
|
+
/**
|
|
225
|
+
* Allowed `${…}` forms: `${name}`, `${#name}`, `${name-word}`,
|
|
226
|
+
* `${name:-word}` (also `=`, `+`, `?`), `${name%word}`, `${name%%word}`,
|
|
227
|
+
* `${name#word}`, `${name##word}`. Everything else — substrings and
|
|
228
|
+
* subscripts (arithmetic in bash), `${!name}`, `${name@op}`, pattern
|
|
229
|
+
* substitution — is refused.
|
|
230
|
+
*/
|
|
231
|
+
const PARAMETER_EXPANSION = new RegExp(`^(?:#${PARAMETER_NAME}|${PARAMETER_NAME}(?:(?::?[-=+?]|%%?|##?)[^\`\$'"\\\\{}()@\\n]*)?)$`);
|
|
232
|
+
/** A refusal raised while parsing; caught by {@link parseShellLine}. */
|
|
233
|
+
var ShellSyntaxRefusal = class extends Error {
|
|
234
|
+
constructor(reason, message) {
|
|
235
|
+
super(message);
|
|
236
|
+
this.reason = reason;
|
|
237
|
+
}
|
|
238
|
+
};
|
|
239
|
+
/** Refuse a construct the parser does not read with confidence. */
|
|
240
|
+
function unsupported(message) {
|
|
241
|
+
return new ShellSyntaxRefusal("unsupported-syntax", message);
|
|
242
|
+
}
|
|
243
|
+
/** Refuse an unterminated quote / substitution. */
|
|
244
|
+
function unbalanced(message) {
|
|
245
|
+
return new ShellSyntaxRefusal("unbalanced-quotes", message);
|
|
246
|
+
}
|
|
247
|
+
/** Parser for the POSIX `sh` dialect. */
|
|
248
|
+
var PosixParser = class PosixParser {
|
|
249
|
+
constructor(src, state) {
|
|
250
|
+
this.src = src;
|
|
251
|
+
this.state = state;
|
|
252
|
+
this.pos = 0;
|
|
253
|
+
this.pendingHeredocs = [];
|
|
254
|
+
}
|
|
255
|
+
/** Parse the whole line as a command list. */
|
|
256
|
+
parseProgram(segments) {
|
|
257
|
+
this.parseList(null, segments);
|
|
258
|
+
this.assertNoPendingHeredocs();
|
|
259
|
+
}
|
|
260
|
+
/** Parse the contents of a backtick substitution (a full list until EOF). */
|
|
261
|
+
parseNestedProgram() {
|
|
262
|
+
this.parseList(null, void 0);
|
|
263
|
+
this.assertNoPendingHeredocs();
|
|
264
|
+
}
|
|
265
|
+
/**
|
|
266
|
+
* Scan an unquoted-delimiter here-document body: `$…`, `${…}`, `$(…)` and
|
|
267
|
+
* backticks expand there exactly as inside double quotes (but `"` is an
|
|
268
|
+
* ordinary character), so any command substitution in it runs.
|
|
269
|
+
*/
|
|
270
|
+
parseExpandingText() {
|
|
271
|
+
while (!this.atEnd()) {
|
|
272
|
+
const char = this.peek();
|
|
273
|
+
if (char === "\\") {
|
|
274
|
+
this.pos += 2;
|
|
275
|
+
continue;
|
|
276
|
+
}
|
|
277
|
+
if (char === "$") {
|
|
278
|
+
this.parseDollar("heredoc");
|
|
279
|
+
continue;
|
|
280
|
+
}
|
|
281
|
+
if (char === "`") {
|
|
282
|
+
this.parseBackticks(false);
|
|
283
|
+
continue;
|
|
284
|
+
}
|
|
285
|
+
this.pos++;
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
atEnd() {
|
|
289
|
+
return this.pos >= this.src.length;
|
|
290
|
+
}
|
|
291
|
+
peek(offset = 0) {
|
|
292
|
+
return this.src.charAt(this.pos + offset);
|
|
293
|
+
}
|
|
294
|
+
assertNoPendingHeredocs() {
|
|
295
|
+
if (this.pendingHeredocs.length > 0) throw unsupported(`The here-document delimited by \`${this.pendingHeredocs[0]?.delimiter ?? ""}\` is never terminated.`);
|
|
296
|
+
}
|
|
297
|
+
enterNesting() {
|
|
298
|
+
this.state.nesting++;
|
|
299
|
+
if (this.state.nesting > MAX_NESTING) throw unsupported("The line nests subshells or substitutions too deeply.");
|
|
300
|
+
}
|
|
301
|
+
exitNesting() {
|
|
302
|
+
this.state.nesting--;
|
|
303
|
+
}
|
|
304
|
+
/** Skip spaces, tabs and backslash-newline continuations. */
|
|
305
|
+
skipBlanks() {
|
|
306
|
+
while (!this.atEnd()) {
|
|
307
|
+
const char = this.peek();
|
|
308
|
+
if (char === " " || char === " ") {
|
|
309
|
+
this.pos++;
|
|
310
|
+
continue;
|
|
311
|
+
}
|
|
312
|
+
if (char === "\\" && this.peek(1) === "\n") {
|
|
313
|
+
this.pos += 2;
|
|
314
|
+
continue;
|
|
315
|
+
}
|
|
316
|
+
break;
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
/** Skip a `#` comment up to (not including) the newline. */
|
|
320
|
+
skipComment() {
|
|
321
|
+
const newline = this.src.indexOf("\n", this.pos);
|
|
322
|
+
this.pos = newline === -1 ? this.src.length : newline;
|
|
323
|
+
}
|
|
324
|
+
/** Consume one newline and read any here-document bodies queued before it. */
|
|
325
|
+
consumeNewline() {
|
|
326
|
+
this.pos++;
|
|
327
|
+
if (this.pendingHeredocs.length === 0) return;
|
|
328
|
+
if (this.state.nesting > 0) throw unsupported("Here-documents inside subshells or substitutions are not supported.");
|
|
329
|
+
const pending = this.pendingHeredocs;
|
|
330
|
+
this.pendingHeredocs = [];
|
|
331
|
+
for (const heredoc of pending) this.readHeredocBody(heredoc);
|
|
332
|
+
}
|
|
333
|
+
/** Skip blanks, comments and newlines (separators between commands). */
|
|
334
|
+
skipBlanksAndNewlines() {
|
|
335
|
+
while (!this.atEnd()) {
|
|
336
|
+
this.skipBlanks();
|
|
337
|
+
if (this.peek() === "#") {
|
|
338
|
+
this.skipComment();
|
|
339
|
+
continue;
|
|
340
|
+
}
|
|
341
|
+
if (this.peek() === "\n") {
|
|
342
|
+
this.consumeNewline();
|
|
343
|
+
continue;
|
|
344
|
+
}
|
|
345
|
+
break;
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
readHeredocBody(heredoc) {
|
|
349
|
+
const bodyLines = [];
|
|
350
|
+
while (true) {
|
|
351
|
+
if (this.atEnd()) throw unsupported(`The here-document delimited by \`${heredoc.delimiter}\` is never terminated.`);
|
|
352
|
+
const lineEnd = this.src.indexOf("\n", this.pos);
|
|
353
|
+
const line = lineEnd === -1 ? this.src.slice(this.pos) : this.src.slice(this.pos, lineEnd);
|
|
354
|
+
const compared = heredoc.stripTabs ? line.replace(/^\t+/, "") : line;
|
|
355
|
+
this.pos = lineEnd === -1 ? this.src.length : lineEnd + 1;
|
|
356
|
+
if (compared === heredoc.delimiter) break;
|
|
357
|
+
bodyLines.push(compared);
|
|
358
|
+
}
|
|
359
|
+
if (heredoc.expand) new PosixParser(bodyLines.join("\n"), this.state).parseExpandingText();
|
|
360
|
+
}
|
|
361
|
+
/**
|
|
362
|
+
* Parse a command list until `closing` (`")"`) or the end of the input.
|
|
363
|
+
* `segments`, when given, collects the top-level pipelines.
|
|
364
|
+
*/
|
|
365
|
+
parseList(closing, segments) {
|
|
366
|
+
let sawCommand = false;
|
|
367
|
+
while (true) {
|
|
368
|
+
this.skipBlanksAndNewlines();
|
|
369
|
+
if (this.atEnd()) {
|
|
370
|
+
if (closing !== null) throw unbalanced(`A \`(\` or \`$(\` is never closed with \`${closing}\`.`);
|
|
371
|
+
break;
|
|
372
|
+
}
|
|
373
|
+
if (closing !== null && this.peek() === closing) break;
|
|
374
|
+
const start = this.pos;
|
|
375
|
+
this.parsePipeline(closing);
|
|
376
|
+
const end = this.pos;
|
|
377
|
+
sawCommand = true;
|
|
378
|
+
this.skipBlanks();
|
|
379
|
+
if (this.peek() === "#") this.skipComment();
|
|
380
|
+
const operator = this.readListOperator();
|
|
381
|
+
segments?.push({
|
|
382
|
+
source: this.src.slice(start, end).trim(),
|
|
383
|
+
...operator !== null ? { operator } : {}
|
|
384
|
+
});
|
|
385
|
+
if (operator === "&&" || operator === "||") {
|
|
386
|
+
this.skipBlanksAndNewlines();
|
|
387
|
+
if (this.atEnd() || closing !== null && this.peek() === closing) throw unsupported(`The line ends with \`${operator}\` but no command follows it.`);
|
|
388
|
+
continue;
|
|
389
|
+
}
|
|
390
|
+
if (operator === "&") this.state.background = true;
|
|
391
|
+
if (operator === null) {
|
|
392
|
+
if (this.atEnd() || closing !== null && this.peek() === closing) break;
|
|
393
|
+
throw unsupported(`Unexpected \`${this.peek()}\`.`);
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
if (!sawCommand) throw unsupported(closing === null ? "The command is empty." : "A `( )` or `$( )` is empty.");
|
|
397
|
+
}
|
|
398
|
+
/** Read `&&`, `||`, `;`, `&` or a newline after a pipeline. */
|
|
399
|
+
readListOperator() {
|
|
400
|
+
const char = this.peek();
|
|
401
|
+
const next = this.peek(1);
|
|
402
|
+
if (char === "&") {
|
|
403
|
+
if (next === "&") {
|
|
404
|
+
this.pos += 2;
|
|
405
|
+
return "&&";
|
|
406
|
+
}
|
|
407
|
+
if (next === ">") throw unsupported("`&>` (redirect both streams) is not portable; write `> file 2>&1` instead.");
|
|
408
|
+
this.pos++;
|
|
409
|
+
return "&";
|
|
410
|
+
}
|
|
411
|
+
if (char === "|" && next === "|") {
|
|
412
|
+
this.pos += 2;
|
|
413
|
+
return "||";
|
|
414
|
+
}
|
|
415
|
+
if (char === ";") {
|
|
416
|
+
if (next === ";") throw unsupported("`;;` (a `case` clause) is not supported.");
|
|
417
|
+
this.pos++;
|
|
418
|
+
return ";";
|
|
419
|
+
}
|
|
420
|
+
if (char === "\n") {
|
|
421
|
+
this.consumeNewline();
|
|
422
|
+
return "\n";
|
|
423
|
+
}
|
|
424
|
+
return null;
|
|
425
|
+
}
|
|
426
|
+
parsePipeline(closing) {
|
|
427
|
+
this.parseCommand(closing);
|
|
428
|
+
while (true) {
|
|
429
|
+
this.skipBlanks();
|
|
430
|
+
if (this.peek() !== "|" || this.peek(1) === "|") return;
|
|
431
|
+
if (this.peek(1) === "&") throw unsupported("`|&` is not portable; write `2>&1 |` instead.");
|
|
432
|
+
this.pos++;
|
|
433
|
+
this.skipBlanksAndNewlines();
|
|
434
|
+
if (this.atEnd() || closing !== null && this.peek() === closing) throw unsupported("The line ends with `|` but no command follows it.");
|
|
435
|
+
this.parseCommand(closing);
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
parseCommand(closing) {
|
|
439
|
+
this.skipBlanks();
|
|
440
|
+
if (this.peek() === "(") {
|
|
441
|
+
if (this.peek(1) === "(") throw unsupported("`((…))` (arithmetic) is not supported.");
|
|
442
|
+
this.parseSubshell();
|
|
443
|
+
return;
|
|
444
|
+
}
|
|
445
|
+
if (this.peek() === ")") throw unsupported(closing === null ? "Unexpected `)`." : "A `( )` or `$( )` is empty.");
|
|
446
|
+
this.parseSimpleCommand();
|
|
447
|
+
}
|
|
448
|
+
parseSubshell() {
|
|
449
|
+
const start = this.pos;
|
|
450
|
+
this.pos++;
|
|
451
|
+
this.enterNesting();
|
|
452
|
+
this.parseList(")", void 0);
|
|
453
|
+
if (this.peek() !== ")") throw unbalanced("A `(` is never closed with `)`.");
|
|
454
|
+
this.pos++;
|
|
455
|
+
this.exitNesting();
|
|
456
|
+
const redirects = [];
|
|
457
|
+
while (true) {
|
|
458
|
+
this.skipBlanks();
|
|
459
|
+
if (!this.atRedirectStart()) break;
|
|
460
|
+
redirects.push(this.parseRedirect());
|
|
461
|
+
}
|
|
462
|
+
if (redirects.length > 0) this.state.commands.push({
|
|
463
|
+
assignments: [],
|
|
464
|
+
words: [],
|
|
465
|
+
redirects,
|
|
466
|
+
source: this.src.slice(start, this.pos).trim()
|
|
467
|
+
});
|
|
468
|
+
if (!this.atEnd() && !this.atOperatorBoundary()) throw unsupported("Only an operator or a redirection may follow `)`.");
|
|
469
|
+
}
|
|
470
|
+
/** Whether the next character ends a simple command. */
|
|
471
|
+
atOperatorBoundary() {
|
|
472
|
+
const char = this.peek();
|
|
473
|
+
return char === "\n" || char === ";" || char === "&" || char === "|" || char === ")" || char === "#" || char === " " || char === " ";
|
|
474
|
+
}
|
|
475
|
+
/** Whether a redirection starts here (`>`, `<`, or digits glued to one). */
|
|
476
|
+
atRedirectStart() {
|
|
477
|
+
let index = this.pos;
|
|
478
|
+
while (index < this.src.length && /[0-9]/.test(this.src.charAt(index))) index++;
|
|
479
|
+
const char = this.src.charAt(index);
|
|
480
|
+
return char === "<" || char === ">";
|
|
481
|
+
}
|
|
482
|
+
parseSimpleCommand() {
|
|
483
|
+
const start = this.pos;
|
|
484
|
+
const command = {
|
|
485
|
+
assignments: [],
|
|
486
|
+
words: [],
|
|
487
|
+
redirects: [],
|
|
488
|
+
source: ""
|
|
489
|
+
};
|
|
490
|
+
while (true) {
|
|
491
|
+
this.skipBlanks();
|
|
492
|
+
if (this.atEnd()) break;
|
|
493
|
+
const char = this.peek();
|
|
494
|
+
if (char === "\n" || char === ";" || char === "&" || char === "|" || char === ")") break;
|
|
495
|
+
if (char === "(") throw unsupported("`(` is only supported at the start of a command (a subshell); quote it to pass it as text.");
|
|
496
|
+
if (char === "#") {
|
|
497
|
+
this.skipComment();
|
|
498
|
+
break;
|
|
499
|
+
}
|
|
500
|
+
if (this.atRedirectStart()) {
|
|
501
|
+
command.redirects.push(this.parseRedirect());
|
|
502
|
+
continue;
|
|
503
|
+
}
|
|
504
|
+
const word = this.parseWord();
|
|
505
|
+
if (command.words.length === 0 && word.assignmentName !== void 0) {
|
|
506
|
+
command.assignments.push(word);
|
|
507
|
+
continue;
|
|
508
|
+
}
|
|
509
|
+
command.words.push(word);
|
|
510
|
+
if (command.words.length === 1 && !word.quoted && RESERVED_WORDS.has(word.value)) throw unsupported(`\`${word.value}\` starts a shell compound command, which is not supported; run the commands one at a time.`);
|
|
511
|
+
}
|
|
512
|
+
if (command.words.length === 0 && command.assignments.length === 0 && command.redirects.length === 0) throw unsupported("The line has an empty command (two operators in a row?).");
|
|
513
|
+
command.source = this.src.slice(start, this.pos).trim();
|
|
514
|
+
this.state.commands.push(command);
|
|
515
|
+
}
|
|
516
|
+
parseRedirect() {
|
|
517
|
+
let digits = "";
|
|
518
|
+
while (/[0-9]/.test(this.peek())) {
|
|
519
|
+
digits += this.peek();
|
|
520
|
+
this.pos++;
|
|
521
|
+
}
|
|
522
|
+
const rest = this.src.slice(this.pos, this.pos + 3);
|
|
523
|
+
let op;
|
|
524
|
+
if (rest.startsWith("<<<")) throw unsupported("`<<<` (a here-string) is not portable; use a here-document.");
|
|
525
|
+
else if (rest.startsWith("<<-")) op = "<<-";
|
|
526
|
+
else if (rest.startsWith("<<")) op = "<<";
|
|
527
|
+
else if (rest.startsWith("<>")) op = "<>";
|
|
528
|
+
else if (rest.startsWith("<&")) op = "<&";
|
|
529
|
+
else if (rest.startsWith("<(") || rest.startsWith(">(")) throw unsupported("Process substitution (`<(…)`, `>(…)`) is not supported.");
|
|
530
|
+
else if (rest.startsWith(">>")) op = ">>";
|
|
531
|
+
else if (rest.startsWith(">|")) op = ">|";
|
|
532
|
+
else if (rest.startsWith(">&")) op = ">&";
|
|
533
|
+
else op = this.peek();
|
|
534
|
+
this.pos += op.length;
|
|
535
|
+
const fd = digits === "" ? void 0 : Number(digits);
|
|
536
|
+
this.skipBlanks();
|
|
537
|
+
if (this.atEnd() || /[\n;&|<>()]/.test(this.peek())) throw unsupported(`The redirection \`${op}\` has no target.`);
|
|
538
|
+
const target = this.parseWord();
|
|
539
|
+
if (op === "<<" || op === "<<-") {
|
|
540
|
+
if (this.state.nesting > 0) throw unsupported("Here-documents inside subshells or substitutions are not supported.");
|
|
541
|
+
if (target.dynamic) throw unsupported("A here-document delimiter must be literal text.");
|
|
542
|
+
this.pendingHeredocs.push({
|
|
543
|
+
delimiter: target.value,
|
|
544
|
+
stripTabs: op === "<<-",
|
|
545
|
+
expand: !target.quoted
|
|
546
|
+
});
|
|
547
|
+
return {
|
|
548
|
+
op,
|
|
549
|
+
...fd !== void 0 ? { fd } : {},
|
|
550
|
+
target,
|
|
551
|
+
type: "heredoc"
|
|
552
|
+
};
|
|
553
|
+
}
|
|
554
|
+
if (op === ">&" || op === "<&") {
|
|
555
|
+
if (target.dynamic || !/^(?:[0-9]+|-)$/.test(target.value)) throw unsupported(`\`${op}${target.raw}\` is not portable; write \`> file 2>&1\` to send both streams to a file.`);
|
|
556
|
+
return {
|
|
557
|
+
op,
|
|
558
|
+
...fd !== void 0 ? { fd } : {},
|
|
559
|
+
target,
|
|
560
|
+
type: "dup"
|
|
561
|
+
};
|
|
562
|
+
}
|
|
563
|
+
return {
|
|
564
|
+
op,
|
|
565
|
+
...fd !== void 0 ? { fd } : {},
|
|
566
|
+
target,
|
|
567
|
+
type: op === "<" ? "read" : "write"
|
|
568
|
+
};
|
|
569
|
+
}
|
|
570
|
+
/** Parse one unquoted-context word. */
|
|
571
|
+
parseWord() {
|
|
572
|
+
const start = this.pos;
|
|
573
|
+
let value = "";
|
|
574
|
+
let dynamic = false;
|
|
575
|
+
let quoted = false;
|
|
576
|
+
let braces = false;
|
|
577
|
+
let nameCandidate = true;
|
|
578
|
+
let assignmentName;
|
|
579
|
+
while (!this.atEnd()) {
|
|
580
|
+
const char = this.peek();
|
|
581
|
+
if (char === " " || char === " " || char === "\n" || char === ";" || char === "&" || char === "|" || char === "<" || char === ">" || char === "(" || char === ")") break;
|
|
582
|
+
if (char === "\r" || char === "\0") throw unsupported("Carriage returns and NUL bytes are not supported in a command line.");
|
|
583
|
+
if (char === "\\") {
|
|
584
|
+
const next = this.peek(1);
|
|
585
|
+
if (next === "") throw unsupported("The line ends with a backslash.");
|
|
586
|
+
this.pos += 2;
|
|
587
|
+
if (next !== "\n") {
|
|
588
|
+
value += next;
|
|
589
|
+
quoted = true;
|
|
590
|
+
nameCandidate = false;
|
|
591
|
+
}
|
|
592
|
+
continue;
|
|
593
|
+
}
|
|
594
|
+
if (char === "'") {
|
|
595
|
+
const closing = this.src.indexOf("'", this.pos + 1);
|
|
596
|
+
if (closing === -1) throw unbalanced("A `'` quote is never closed.");
|
|
597
|
+
value += this.src.slice(this.pos + 1, closing);
|
|
598
|
+
quoted = true;
|
|
599
|
+
nameCandidate = false;
|
|
600
|
+
this.pos = closing + 1;
|
|
601
|
+
continue;
|
|
602
|
+
}
|
|
603
|
+
if (char === "\"") {
|
|
604
|
+
const part = this.parseDoubleQuoted();
|
|
605
|
+
value += part.value;
|
|
606
|
+
dynamic = dynamic || part.dynamic;
|
|
607
|
+
quoted = true;
|
|
608
|
+
nameCandidate = false;
|
|
609
|
+
continue;
|
|
610
|
+
}
|
|
611
|
+
if (char === "$") {
|
|
612
|
+
const part = this.parseDollar("unquoted");
|
|
613
|
+
value += part.text;
|
|
614
|
+
dynamic = dynamic || part.dynamic;
|
|
615
|
+
nameCandidate = false;
|
|
616
|
+
continue;
|
|
617
|
+
}
|
|
618
|
+
if (char === "`") {
|
|
619
|
+
value += this.parseBackticks(false);
|
|
620
|
+
dynamic = true;
|
|
621
|
+
nameCandidate = false;
|
|
622
|
+
continue;
|
|
623
|
+
}
|
|
624
|
+
if (char === "=" && nameCandidate && assignmentName === void 0) {
|
|
625
|
+
if (/^[A-Za-z_][A-Za-z0-9_]*$/.test(value)) assignmentName = value;
|
|
626
|
+
nameCandidate = false;
|
|
627
|
+
} else if (!/[A-Za-z0-9_]/.test(char)) nameCandidate = false;
|
|
628
|
+
if (char === "*" || char === "?" || char === "[") dynamic = true;
|
|
629
|
+
if (char === "~") {
|
|
630
|
+
const previous = this.src.charAt(this.pos - 1);
|
|
631
|
+
if (this.pos === start || assignmentName !== void 0 && (previous === "=" || previous === ":")) dynamic = true;
|
|
632
|
+
}
|
|
633
|
+
if (char === "{" || char === "}") braces = true;
|
|
634
|
+
value += char;
|
|
635
|
+
this.pos++;
|
|
636
|
+
}
|
|
637
|
+
const raw = this.src.slice(start, this.pos);
|
|
638
|
+
if (braces && raw !== "{}") throw unsupported(`Unquoted \`{\` or \`}\` in \`${raw}\` (brace expansion or a command group) is not supported; quote it.`);
|
|
639
|
+
if (raw === "[" || raw === "]") dynamic = false;
|
|
640
|
+
return {
|
|
641
|
+
raw,
|
|
642
|
+
value,
|
|
643
|
+
dynamic,
|
|
644
|
+
quoted,
|
|
645
|
+
...assignmentName !== void 0 ? { assignmentName } : {}
|
|
646
|
+
};
|
|
647
|
+
}
|
|
648
|
+
/** Parse a `"…"` span (cursor on the opening quote). */
|
|
649
|
+
parseDoubleQuoted() {
|
|
650
|
+
this.pos++;
|
|
651
|
+
let value = "";
|
|
652
|
+
let dynamic = false;
|
|
653
|
+
while (true) {
|
|
654
|
+
if (this.atEnd()) throw unbalanced("A `\"` quote is never closed.");
|
|
655
|
+
const char = this.peek();
|
|
656
|
+
if (char === "\"") {
|
|
657
|
+
this.pos++;
|
|
658
|
+
return {
|
|
659
|
+
value,
|
|
660
|
+
dynamic
|
|
661
|
+
};
|
|
662
|
+
}
|
|
663
|
+
if (char === "\0") throw unsupported("NUL bytes are not supported in a command line.");
|
|
664
|
+
if (char === "\\") {
|
|
665
|
+
const next = this.peek(1);
|
|
666
|
+
if (next === "") throw unbalanced("A `\"` quote is never closed.");
|
|
667
|
+
if (next === "$" || next === "`" || next === "\"" || next === "\\") {
|
|
668
|
+
value += next;
|
|
669
|
+
this.pos += 2;
|
|
670
|
+
continue;
|
|
671
|
+
}
|
|
672
|
+
if (next === "\n") {
|
|
673
|
+
this.pos += 2;
|
|
674
|
+
continue;
|
|
675
|
+
}
|
|
676
|
+
value += char;
|
|
677
|
+
this.pos++;
|
|
678
|
+
continue;
|
|
679
|
+
}
|
|
680
|
+
if (char === "$") {
|
|
681
|
+
const part = this.parseDollar("dquote");
|
|
682
|
+
value += part.text;
|
|
683
|
+
dynamic = dynamic || part.dynamic;
|
|
684
|
+
continue;
|
|
685
|
+
}
|
|
686
|
+
if (char === "`") {
|
|
687
|
+
value += this.parseBackticks(true);
|
|
688
|
+
dynamic = true;
|
|
689
|
+
continue;
|
|
690
|
+
}
|
|
691
|
+
value += char;
|
|
692
|
+
this.pos++;
|
|
693
|
+
}
|
|
694
|
+
}
|
|
695
|
+
/** Parse a `$…` expansion (cursor on the `$`). */
|
|
696
|
+
parseDollar(context) {
|
|
697
|
+
const start = this.pos;
|
|
698
|
+
const next = this.peek(1);
|
|
699
|
+
if (next === "(") {
|
|
700
|
+
if (this.peek(2) === "(") throw unsupported("`$((…))` (arithmetic expansion) is not supported.");
|
|
701
|
+
this.pos += 2;
|
|
702
|
+
this.enterNesting();
|
|
703
|
+
this.parseList(")", void 0);
|
|
704
|
+
if (this.peek() !== ")") throw unbalanced("A `$(` is never closed with `)`.");
|
|
705
|
+
this.pos++;
|
|
706
|
+
this.exitNesting();
|
|
707
|
+
return {
|
|
708
|
+
text: this.src.slice(start, this.pos),
|
|
709
|
+
dynamic: true
|
|
710
|
+
};
|
|
711
|
+
}
|
|
712
|
+
if (next === "{") {
|
|
713
|
+
const closing = this.src.indexOf("}", this.pos + 2);
|
|
714
|
+
if (closing === -1) throw unbalanced("A `${` is never closed with `}`.");
|
|
715
|
+
const body = this.src.slice(this.pos + 2, closing);
|
|
716
|
+
if (!PARAMETER_EXPANSION.test(body)) throw unsupported(`\`\${${body}}\` is not a supported parameter expansion; use \`$name\`, \`\${name}\` or \`\${name:-default}\`.`);
|
|
717
|
+
this.pos = closing + 1;
|
|
718
|
+
return {
|
|
719
|
+
text: this.src.slice(start, this.pos),
|
|
720
|
+
dynamic: true
|
|
721
|
+
};
|
|
722
|
+
}
|
|
723
|
+
if (next === "[") throw unsupported("`$[…]` (arithmetic expansion) is not supported.");
|
|
724
|
+
if (context === "unquoted" && (next === "'" || next === "\"")) throw unsupported(`\`$${next}…${next}\` quoting is not portable; use plain quotes.`);
|
|
725
|
+
if (/[A-Za-z_]/.test(next)) {
|
|
726
|
+
this.pos += 2;
|
|
727
|
+
while (/[A-Za-z0-9_]/.test(this.peek())) this.pos++;
|
|
728
|
+
return {
|
|
729
|
+
text: this.src.slice(start, this.pos),
|
|
730
|
+
dynamic: true
|
|
731
|
+
};
|
|
732
|
+
}
|
|
733
|
+
if (/[0-9@*#?$!-]/.test(next) && next !== "") {
|
|
734
|
+
this.pos += 2;
|
|
735
|
+
return {
|
|
736
|
+
text: this.src.slice(start, this.pos),
|
|
737
|
+
dynamic: true
|
|
738
|
+
};
|
|
739
|
+
}
|
|
740
|
+
this.pos++;
|
|
741
|
+
return {
|
|
742
|
+
text: "$",
|
|
743
|
+
dynamic: false
|
|
744
|
+
};
|
|
745
|
+
}
|
|
746
|
+
/** Parse a backtick substitution (cursor on the opening backtick); returns its source. */
|
|
747
|
+
parseBackticks(insideDoubleQuotes) {
|
|
748
|
+
const start = this.pos;
|
|
749
|
+
let content = "";
|
|
750
|
+
this.pos++;
|
|
751
|
+
while (true) {
|
|
752
|
+
if (this.atEnd()) throw unbalanced("A backtick substitution is never closed.");
|
|
753
|
+
const char = this.peek();
|
|
754
|
+
if (char === "`") {
|
|
755
|
+
this.pos++;
|
|
756
|
+
break;
|
|
757
|
+
}
|
|
758
|
+
if (char === "\\") {
|
|
759
|
+
const next = this.peek(1);
|
|
760
|
+
if (next === "") throw unbalanced("A backtick substitution is never closed.");
|
|
761
|
+
if (next === "`" || next === "\\" || next === "$") {
|
|
762
|
+
content += next;
|
|
763
|
+
this.pos += 2;
|
|
764
|
+
continue;
|
|
765
|
+
}
|
|
766
|
+
if (next === "\"" && insideDoubleQuotes) throw unsupported("An escaped `\\\"` inside backticks inside double quotes is not portable; use `$(…)`.");
|
|
767
|
+
content += char + next;
|
|
768
|
+
this.pos += 2;
|
|
769
|
+
continue;
|
|
770
|
+
}
|
|
771
|
+
content += char;
|
|
772
|
+
this.pos++;
|
|
773
|
+
}
|
|
774
|
+
this.enterNesting();
|
|
775
|
+
new PosixParser(content, this.state).parseNestedProgram();
|
|
776
|
+
this.exitNesting();
|
|
777
|
+
return this.src.slice(start, this.pos);
|
|
778
|
+
}
|
|
779
|
+
};
|
|
780
|
+
/** Parser for the restricted `cmd.exe` dialect. */
|
|
781
|
+
var CmdParser = class {
|
|
782
|
+
constructor(src, commands) {
|
|
783
|
+
this.src = src;
|
|
784
|
+
this.commands = commands;
|
|
785
|
+
this.pos = 0;
|
|
786
|
+
}
|
|
787
|
+
parseProgram(segments) {
|
|
788
|
+
if (/[\r\n\0]/.test(this.src)) throw unsupported("Line breaks and NUL bytes are not supported in a cmd.exe command line.");
|
|
789
|
+
while (true) {
|
|
790
|
+
this.skipBlanks();
|
|
791
|
+
if (this.atEnd()) throw unsupported(segments.length === 0 ? "The command is empty." : "The line ends with an operator but no command follows it.");
|
|
792
|
+
const start = this.pos;
|
|
793
|
+
this.parsePipeline();
|
|
794
|
+
const end = this.pos;
|
|
795
|
+
this.skipBlanks();
|
|
796
|
+
const operator = this.readListOperator();
|
|
797
|
+
segments.push({
|
|
798
|
+
source: this.src.slice(start, end).trim(),
|
|
799
|
+
...operator !== null ? { operator } : {}
|
|
800
|
+
});
|
|
801
|
+
if (operator === null) {
|
|
802
|
+
if (!this.atEnd()) throw unsupported(`Unexpected \`${this.peek()}\`.`);
|
|
803
|
+
return;
|
|
804
|
+
}
|
|
805
|
+
}
|
|
806
|
+
}
|
|
807
|
+
atEnd() {
|
|
808
|
+
return this.pos >= this.src.length;
|
|
809
|
+
}
|
|
810
|
+
peek(offset = 0) {
|
|
811
|
+
return this.src.charAt(this.pos + offset);
|
|
812
|
+
}
|
|
813
|
+
skipBlanks() {
|
|
814
|
+
while (this.peek() === " " || this.peek() === " ") this.pos++;
|
|
815
|
+
}
|
|
816
|
+
readListOperator() {
|
|
817
|
+
if (this.peek() === "&") {
|
|
818
|
+
if (this.peek(1) === "&") {
|
|
819
|
+
this.pos += 2;
|
|
820
|
+
return "&&";
|
|
821
|
+
}
|
|
822
|
+
this.pos++;
|
|
823
|
+
return "&";
|
|
824
|
+
}
|
|
825
|
+
if (this.peek() === "|" && this.peek(1) === "|") {
|
|
826
|
+
this.pos += 2;
|
|
827
|
+
return "||";
|
|
828
|
+
}
|
|
829
|
+
return null;
|
|
830
|
+
}
|
|
831
|
+
parsePipeline() {
|
|
832
|
+
this.parseSimpleCommand();
|
|
833
|
+
while (this.peek() === "|" && this.peek(1) !== "|") {
|
|
834
|
+
this.pos++;
|
|
835
|
+
this.skipBlanks();
|
|
836
|
+
if (this.atEnd()) throw unsupported("The line ends with `|` but no command follows it.");
|
|
837
|
+
this.parseSimpleCommand();
|
|
838
|
+
}
|
|
839
|
+
}
|
|
840
|
+
atRedirectStart() {
|
|
841
|
+
const char = this.peek();
|
|
842
|
+
if (char === "<" || char === ">") return true;
|
|
843
|
+
return /[0-9]/.test(char) && (this.peek(1) === ">" || this.peek(1) === "<");
|
|
844
|
+
}
|
|
845
|
+
parseSimpleCommand() {
|
|
846
|
+
const start = this.pos;
|
|
847
|
+
const command = {
|
|
848
|
+
assignments: [],
|
|
849
|
+
words: [],
|
|
850
|
+
redirects: [],
|
|
851
|
+
source: ""
|
|
852
|
+
};
|
|
853
|
+
while (true) {
|
|
854
|
+
this.skipBlanks();
|
|
855
|
+
if (this.atEnd() || this.peek() === "&" || this.peek() === "|") break;
|
|
856
|
+
if (this.atRedirectStart()) {
|
|
857
|
+
command.redirects.push(this.parseRedirect());
|
|
858
|
+
continue;
|
|
859
|
+
}
|
|
860
|
+
const word = this.parseWord();
|
|
861
|
+
command.words.push(word);
|
|
862
|
+
if (command.words.length === 1) this.checkCommandWord(word);
|
|
863
|
+
}
|
|
864
|
+
if (command.words.length === 0 && command.redirects.length === 0) throw unsupported("The line has an empty command (two operators in a row?).");
|
|
865
|
+
command.source = this.src.slice(start, this.pos).trim();
|
|
866
|
+
this.commands.push(command);
|
|
867
|
+
}
|
|
868
|
+
checkCommandWord(word) {
|
|
869
|
+
if (word.raw.startsWith("@") || word.raw.startsWith(":")) throw unsupported(`\`${word.raw}\` is not supported as a cmd.exe command name.`);
|
|
870
|
+
if (/[/=]/.test(word.value)) throw unsupported(`A cmd.exe command name may not contain \`/\` or \`=\` (\`${word.value}\`); use \`\\\` in paths.`);
|
|
871
|
+
if (!word.quoted && CMD_RESERVED_WORDS.has(word.value.toLowerCase())) throw unsupported(`\`${word.value}\` starts a cmd.exe compound command, which is not supported.`);
|
|
872
|
+
}
|
|
873
|
+
parseRedirect() {
|
|
874
|
+
let fd;
|
|
875
|
+
if (/[0-9]/.test(this.peek())) {
|
|
876
|
+
fd = Number(this.peek());
|
|
877
|
+
this.pos++;
|
|
878
|
+
}
|
|
879
|
+
let op;
|
|
880
|
+
if (this.src.startsWith(">>", this.pos)) op = ">>";
|
|
881
|
+
else if (this.src.startsWith(">&", this.pos)) op = ">&";
|
|
882
|
+
else if (this.src.startsWith("<&", this.pos) || this.src.startsWith("<<", this.pos)) throw unsupported(`\`${this.src.slice(this.pos, this.pos + 2)}\` is not supported by cmd.exe.`);
|
|
883
|
+
else op = this.peek();
|
|
884
|
+
this.pos += op.length;
|
|
885
|
+
this.skipBlanks();
|
|
886
|
+
if (this.atEnd() || /[&|<>]/.test(this.peek())) throw unsupported(`The redirection \`${op}\` has no target.`);
|
|
887
|
+
const target = this.parseWord();
|
|
888
|
+
if (op === ">&") {
|
|
889
|
+
if (!/^[0-9]$/.test(target.value)) throw unsupported(`\`>&${target.raw}\` is not supported; write \`> file 2>&1\`.`);
|
|
890
|
+
return {
|
|
891
|
+
op,
|
|
892
|
+
...fd !== void 0 ? { fd } : {},
|
|
893
|
+
target,
|
|
894
|
+
type: "dup"
|
|
895
|
+
};
|
|
896
|
+
}
|
|
897
|
+
return {
|
|
898
|
+
op,
|
|
899
|
+
...fd !== void 0 ? { fd } : {},
|
|
900
|
+
target,
|
|
901
|
+
type: op === "<" ? "read" : "write"
|
|
902
|
+
};
|
|
903
|
+
}
|
|
904
|
+
parseWord() {
|
|
905
|
+
const start = this.pos;
|
|
906
|
+
let value = "";
|
|
907
|
+
let dynamic = false;
|
|
908
|
+
let quoted = false;
|
|
909
|
+
while (!this.atEnd()) {
|
|
910
|
+
const char = this.peek();
|
|
911
|
+
if (char === " " || char === " " || char === "&" || char === "|" || char === "<" || char === ">") break;
|
|
912
|
+
if (char === "\"") {
|
|
913
|
+
const closing = this.src.indexOf("\"", this.pos + 1);
|
|
914
|
+
if (closing === -1) throw unbalanced("A `\"` quote is never closed.");
|
|
915
|
+
const inner = this.src.slice(this.pos + 1, closing);
|
|
916
|
+
if (/[%!]/.test(inner)) throw unsupported("`%` and `!` expand even inside quotes in cmd.exe; they are not supported.");
|
|
917
|
+
if (value.endsWith("\\") || inner.endsWith("\\")) throw unsupported("A backslash before a `\"` is read differently by cmd.exe and programs.");
|
|
918
|
+
value += inner;
|
|
919
|
+
quoted = true;
|
|
920
|
+
this.pos = closing + 1;
|
|
921
|
+
continue;
|
|
922
|
+
}
|
|
923
|
+
if (char === "%" || char === "!" || char === "^" || char === "(" || char === ")" || char === ";" || char === ",") throw unsupported(`Unquoted \`${char}\` has a special meaning in cmd.exe and is not supported.`);
|
|
924
|
+
if (char === "\\" && this.peek(1) === "\"") throw unsupported("A backslash before a `\"` is read differently by cmd.exe and programs.");
|
|
925
|
+
if (char === "*" || char === "?") dynamic = true;
|
|
926
|
+
value += char;
|
|
927
|
+
this.pos++;
|
|
928
|
+
}
|
|
929
|
+
return {
|
|
930
|
+
raw: this.src.slice(start, this.pos),
|
|
931
|
+
value,
|
|
932
|
+
dynamic,
|
|
933
|
+
quoted
|
|
934
|
+
};
|
|
935
|
+
}
|
|
936
|
+
};
|
|
937
|
+
/**
|
|
938
|
+
* Parse a command line for the shell policy. Never throws: a construct the
|
|
939
|
+
* parser does not read with confidence comes back as `{ ok: false }` with a
|
|
940
|
+
* message naming it.
|
|
941
|
+
*
|
|
942
|
+
* @example
|
|
943
|
+
* const parsed = parseShellLine("git add -A && git commit -m 'wip'", "posix");
|
|
944
|
+
* // parsed.ok === true; parsed.commands.map((c) => c.words.map((w) => w.value))
|
|
945
|
+
* // → [["git", "add", "-A"], ["git", "commit", "-m", "wip"]]
|
|
946
|
+
*/
|
|
947
|
+
function parseShellLine(line, dialect) {
|
|
948
|
+
const segments = [];
|
|
949
|
+
try {
|
|
950
|
+
if (dialect === "cmd") {
|
|
951
|
+
const commands = [];
|
|
952
|
+
new CmdParser(line, commands).parseProgram(segments);
|
|
953
|
+
return {
|
|
954
|
+
ok: true,
|
|
955
|
+
commands,
|
|
956
|
+
segments,
|
|
957
|
+
background: false
|
|
958
|
+
};
|
|
959
|
+
}
|
|
960
|
+
const state = {
|
|
961
|
+
commands: [],
|
|
962
|
+
background: false,
|
|
963
|
+
nesting: 0
|
|
964
|
+
};
|
|
965
|
+
new PosixParser(line, state).parseProgram(segments);
|
|
966
|
+
return {
|
|
967
|
+
ok: true,
|
|
968
|
+
commands: state.commands,
|
|
969
|
+
segments,
|
|
970
|
+
background: state.background
|
|
971
|
+
};
|
|
972
|
+
} catch (error) {
|
|
973
|
+
if (error instanceof ShellSyntaxRefusal) return {
|
|
974
|
+
ok: false,
|
|
975
|
+
reason: error.reason,
|
|
976
|
+
message: error.message
|
|
977
|
+
};
|
|
978
|
+
throw error;
|
|
979
|
+
}
|
|
980
|
+
}
|
|
90
981
|
|
|
91
982
|
//#endregion
|
|
92
983
|
//#region ../../packages/ai-workspace/src/policy/tokenize-command.ts
|
|
@@ -110,6 +1001,85 @@ const UNQUOTED_METACHARACTERS = new Set([
|
|
|
110
1001
|
"(",
|
|
111
1002
|
")"
|
|
112
1003
|
]);
|
|
1004
|
+
/** Two-character operators reported as one token in a refusal. */
|
|
1005
|
+
const TWO_CHARACTER_OPERATORS = new Set([
|
|
1006
|
+
"&&",
|
|
1007
|
+
"||",
|
|
1008
|
+
">>",
|
|
1009
|
+
"<<",
|
|
1010
|
+
"$(",
|
|
1011
|
+
"${",
|
|
1012
|
+
">&",
|
|
1013
|
+
"<&"
|
|
1014
|
+
]);
|
|
1015
|
+
/**
|
|
1016
|
+
* Tokenize a command line into an argv the way {@link tokenizeCommand}
|
|
1017
|
+
* does, but say **why** a line cannot be represented as one argv — which
|
|
1018
|
+
* quote is unbalanced, or which unquoted operator (`&&`, `|`, `>`, `$(`,
|
|
1019
|
+
* a newline, …) was found first. The shell policy uses it to phrase a
|
|
1020
|
+
* refusal that tells the model how to split the line.
|
|
1021
|
+
*
|
|
1022
|
+
* @example
|
|
1023
|
+
* analyzeArgvCommand("npm test"); // { ok: true, argv: ["npm", "test"] }
|
|
1024
|
+
* analyzeArgvCommand("git add -A && git commit"); // { ok: false, type: "metacharacter", token: "&&", index: 11 }
|
|
1025
|
+
*/
|
|
1026
|
+
function analyzeArgvCommand(command) {
|
|
1027
|
+
const argv = [];
|
|
1028
|
+
let current = "";
|
|
1029
|
+
let inToken = false;
|
|
1030
|
+
let index = 0;
|
|
1031
|
+
while (index < command.length) {
|
|
1032
|
+
const char = command.charAt(index);
|
|
1033
|
+
if (char === "'" || char === "\"") {
|
|
1034
|
+
const closing = command.indexOf(char, index + 1);
|
|
1035
|
+
if (closing === -1) return {
|
|
1036
|
+
ok: false,
|
|
1037
|
+
type: "unbalanced-quotes",
|
|
1038
|
+
quote: char,
|
|
1039
|
+
index
|
|
1040
|
+
};
|
|
1041
|
+
current += command.slice(index + 1, closing);
|
|
1042
|
+
inToken = true;
|
|
1043
|
+
index = closing + 1;
|
|
1044
|
+
continue;
|
|
1045
|
+
}
|
|
1046
|
+
if (char === " " || char === " ") {
|
|
1047
|
+
if (inToken) {
|
|
1048
|
+
argv.push(current);
|
|
1049
|
+
current = "";
|
|
1050
|
+
inToken = false;
|
|
1051
|
+
}
|
|
1052
|
+
index++;
|
|
1053
|
+
continue;
|
|
1054
|
+
}
|
|
1055
|
+
if (char === "\n" || char === "\r") return {
|
|
1056
|
+
ok: false,
|
|
1057
|
+
type: "metacharacter",
|
|
1058
|
+
token: "\n",
|
|
1059
|
+
index
|
|
1060
|
+
};
|
|
1061
|
+
if (UNQUOTED_METACHARACTERS.has(char)) {
|
|
1062
|
+
const pair = command.slice(index, index + 2);
|
|
1063
|
+
return {
|
|
1064
|
+
ok: false,
|
|
1065
|
+
type: "metacharacter",
|
|
1066
|
+
token: TWO_CHARACTER_OPERATORS.has(pair) ? pair : char,
|
|
1067
|
+
index
|
|
1068
|
+
};
|
|
1069
|
+
}
|
|
1070
|
+
current += char;
|
|
1071
|
+
inToken = true;
|
|
1072
|
+
index++;
|
|
1073
|
+
}
|
|
1074
|
+
if (inToken) argv.push(current);
|
|
1075
|
+
return argv.length > 0 ? {
|
|
1076
|
+
ok: true,
|
|
1077
|
+
argv
|
|
1078
|
+
} : {
|
|
1079
|
+
ok: false,
|
|
1080
|
+
type: "empty"
|
|
1081
|
+
};
|
|
1082
|
+
}
|
|
113
1083
|
/**
|
|
114
1084
|
* Tokenize a command line into an argv array WITHOUT any shell semantics.
|
|
115
1085
|
*
|
|
@@ -125,6 +1095,7 @@ const UNQUOTED_METACHARACTERS = new Set([
|
|
|
125
1095
|
* shell metacharacter / newline (see {@link UNQUOTED_METACHARACTERS}). The
|
|
126
1096
|
* policy gate treats `null` as denied and the local backend refuses to
|
|
127
1097
|
* spawn it, which is what closes the `allowed_cmd; anything-else` injection.
|
|
1098
|
+
* {@link analyzeArgvCommand} says why a line was refused.
|
|
128
1099
|
*
|
|
129
1100
|
* @example
|
|
130
1101
|
* tokenizeCommand('npm test'); // ["npm", "test"]
|
|
@@ -132,36 +1103,628 @@ const UNQUOTED_METACHARACTERS = new Set([
|
|
|
132
1103
|
* tokenizeCommand('npm test; curl http://evil'); // null (unquoted `;`)
|
|
133
1104
|
*/
|
|
134
1105
|
function tokenizeCommand(command) {
|
|
135
|
-
const
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
1106
|
+
const analysis = analyzeArgvCommand(command);
|
|
1107
|
+
return analysis.ok ? analysis.argv : null;
|
|
1108
|
+
}
|
|
1109
|
+
|
|
1110
|
+
//#endregion
|
|
1111
|
+
//#region ../../packages/ai-workspace/src/policy/shell-check.ts
|
|
1112
|
+
/**
|
|
1113
|
+
* Programs and shell builtins that run a command or code the policy cannot
|
|
1114
|
+
* see (a script string, a file, a command read from input, another user).
|
|
1115
|
+
* With deny rules in force they would hide a denied command, so they are
|
|
1116
|
+
* refused unless the policy names them in `allow`.
|
|
1117
|
+
*/
|
|
1118
|
+
const OPAQUE_RUNNERS = new Set([
|
|
1119
|
+
"sh",
|
|
1120
|
+
"bash",
|
|
1121
|
+
"dash",
|
|
1122
|
+
"zsh",
|
|
1123
|
+
"ksh",
|
|
1124
|
+
"mksh",
|
|
1125
|
+
"ash",
|
|
1126
|
+
"yash",
|
|
1127
|
+
"posh",
|
|
1128
|
+
"fish",
|
|
1129
|
+
"csh",
|
|
1130
|
+
"tcsh",
|
|
1131
|
+
"busybox",
|
|
1132
|
+
"cmd",
|
|
1133
|
+
"powershell",
|
|
1134
|
+
"pwsh",
|
|
1135
|
+
"wsl",
|
|
1136
|
+
"sudo",
|
|
1137
|
+
"doas",
|
|
1138
|
+
"su",
|
|
1139
|
+
"runuser",
|
|
1140
|
+
"pkexec",
|
|
1141
|
+
"runas",
|
|
1142
|
+
"chroot",
|
|
1143
|
+
"nsenter",
|
|
1144
|
+
"unshare",
|
|
1145
|
+
"setsid",
|
|
1146
|
+
"systemd-run",
|
|
1147
|
+
"start-stop-daemon",
|
|
1148
|
+
"daemonize",
|
|
1149
|
+
"xargs",
|
|
1150
|
+
"parallel",
|
|
1151
|
+
"watch",
|
|
1152
|
+
"script",
|
|
1153
|
+
"expect",
|
|
1154
|
+
"at",
|
|
1155
|
+
"batch",
|
|
1156
|
+
"screen",
|
|
1157
|
+
"tmux",
|
|
1158
|
+
"stdbuf",
|
|
1159
|
+
"flock"
|
|
1160
|
+
]);
|
|
1161
|
+
/** POSIX shell builtins that evaluate code or change how later names resolve. */
|
|
1162
|
+
const SHELL_OPAQUE_BUILTINS = new Set([
|
|
1163
|
+
"eval",
|
|
1164
|
+
".",
|
|
1165
|
+
"source",
|
|
1166
|
+
"alias",
|
|
1167
|
+
"unalias",
|
|
1168
|
+
"hash",
|
|
1169
|
+
"enable",
|
|
1170
|
+
"trap",
|
|
1171
|
+
"let",
|
|
1172
|
+
"declare",
|
|
1173
|
+
"typeset",
|
|
1174
|
+
"local",
|
|
1175
|
+
"set",
|
|
1176
|
+
"shopt",
|
|
1177
|
+
"fc",
|
|
1178
|
+
"bind",
|
|
1179
|
+
"complete",
|
|
1180
|
+
"compgen",
|
|
1181
|
+
"compopt",
|
|
1182
|
+
"mapfile",
|
|
1183
|
+
"readarray"
|
|
1184
|
+
]);
|
|
1185
|
+
/** cmd.exe builtins that run commands or change the environment. */
|
|
1186
|
+
const CMD_OPAQUE_BUILTINS = new Set([
|
|
1187
|
+
"call",
|
|
1188
|
+
"start",
|
|
1189
|
+
"set",
|
|
1190
|
+
"pushd",
|
|
1191
|
+
"popd"
|
|
1192
|
+
]);
|
|
1193
|
+
/**
|
|
1194
|
+
* Shell builtins whose arguments bash may evaluate as arithmetic (array
|
|
1195
|
+
* subscripts such as `a[$(cmd)]`): their arguments must be literal and free
|
|
1196
|
+
* of `[`.
|
|
1197
|
+
*/
|
|
1198
|
+
const ARITHMETIC_SENSITIVE_BUILTINS = new Set([
|
|
1199
|
+
"test",
|
|
1200
|
+
"[",
|
|
1201
|
+
"printf",
|
|
1202
|
+
"read",
|
|
1203
|
+
"unset",
|
|
1204
|
+
"export",
|
|
1205
|
+
"readonly",
|
|
1206
|
+
"getopts",
|
|
1207
|
+
"wait",
|
|
1208
|
+
"kill",
|
|
1209
|
+
"shift",
|
|
1210
|
+
"return",
|
|
1211
|
+
"exit",
|
|
1212
|
+
"break",
|
|
1213
|
+
"continue",
|
|
1214
|
+
"ulimit"
|
|
1215
|
+
]);
|
|
1216
|
+
/**
|
|
1217
|
+
* Variables a shell-mode line may not assign: they change how the shell
|
|
1218
|
+
* parses, traces or resolves what follows (`CDPATH` would move `cd` out of
|
|
1219
|
+
* the jail's sight, `PS4` is expanded — command substitutions included —
|
|
1220
|
+
* on every traced line, `IFS` re-splits expansions).
|
|
1221
|
+
*/
|
|
1222
|
+
const SENSITIVE_VARIABLES = new Set([
|
|
1223
|
+
"CDPATH",
|
|
1224
|
+
"ENV",
|
|
1225
|
+
"BASH_ENV",
|
|
1226
|
+
"SHELLOPTS",
|
|
1227
|
+
"BASHOPTS",
|
|
1228
|
+
"IFS",
|
|
1229
|
+
"PS1",
|
|
1230
|
+
"PS2",
|
|
1231
|
+
"PS3",
|
|
1232
|
+
"PS4",
|
|
1233
|
+
"PROMPT_COMMAND",
|
|
1234
|
+
"BASH_XTRACEFD",
|
|
1235
|
+
"GLOBIGNORE",
|
|
1236
|
+
"EXECIGNORE",
|
|
1237
|
+
"BASH_LOADABLES_PATH"
|
|
1238
|
+
]);
|
|
1239
|
+
/** Redirection targets that are always fine even though they sit outside the jail. */
|
|
1240
|
+
const DEVICE_TARGETS = new Set([
|
|
1241
|
+
"/dev/null",
|
|
1242
|
+
"/dev/stdout",
|
|
1243
|
+
"/dev/stderr"
|
|
1244
|
+
]);
|
|
1245
|
+
/** cmd.exe's null device. */
|
|
1246
|
+
const CMD_DEVICE_TARGETS = new Set(["nul"]);
|
|
1247
|
+
/** Deepest chain of wrappers (`env time nice …`) the policy unwraps. */
|
|
1248
|
+
const MAX_WRAPPER_DEPTH = 8;
|
|
1249
|
+
/** Most distinct directories a line may `cd` between. */
|
|
1250
|
+
const MAX_CD_CANDIDATES = 32;
|
|
1251
|
+
/** Longest command shown verbatim in a refusal before it is abbreviated. */
|
|
1252
|
+
const MAX_SHOWN_COMMAND = 48;
|
|
1253
|
+
/** The dialect shell mode parses on this platform. */
|
|
1254
|
+
function shellDialect() {
|
|
1255
|
+
return process.platform === "win32" ? "cmd" : "posix";
|
|
1256
|
+
}
|
|
1257
|
+
/**
|
|
1258
|
+
* Reduce an argv's first element to the basename the allow/deny policy is
|
|
1259
|
+
* keyed on. `"npm"` → `"npm"`; `"/usr/bin/node"` → `"node"`; `"node.exe"`
|
|
1260
|
+
* → `"node"` (the `.exe`/`.cmd`/`.bat`/`.com` Windows extension is
|
|
1261
|
+
* stripped). The cmd dialect splits Windows paths too.
|
|
1262
|
+
*/
|
|
1263
|
+
function executableBasename(firstToken, dialect = "posix") {
|
|
1264
|
+
return (dialect === "cmd" ? node_path.default.win32.basename(firstToken) : node_path.default.basename(firstToken)).replace(/\.(exe|cmd|bat|com)$/i, "");
|
|
1265
|
+
}
|
|
1266
|
+
/** Show a command in a refusal, abbreviated at a word boundary when long. */
|
|
1267
|
+
function show(source) {
|
|
1268
|
+
const flat = source.replace(/\s+/g, " ").trim();
|
|
1269
|
+
if (flat.length <= MAX_SHOWN_COMMAND) return `\`${flat}\``;
|
|
1270
|
+
const cut = flat.lastIndexOf(" ", MAX_SHOWN_COMMAND);
|
|
1271
|
+
return `\`${flat.slice(0, cut > 0 ? cut : MAX_SHOWN_COMMAND)} …\``;
|
|
1272
|
+
}
|
|
1273
|
+
function compilePattern(pattern) {
|
|
1274
|
+
if (pattern instanceof RegExp) {
|
|
1275
|
+
const regex = new RegExp(pattern.source, pattern.flags.replace(/[gy]/g, ""));
|
|
1276
|
+
return {
|
|
1277
|
+
source: pattern,
|
|
1278
|
+
test: (argv, dialect) => regex.test([executableBasename(argv[0] ?? "", dialect), ...argv.slice(1)].join(" "))
|
|
1279
|
+
};
|
|
1280
|
+
}
|
|
1281
|
+
const tokens = tokenizeCommand(pattern);
|
|
1282
|
+
if (tokens === null) return {
|
|
1283
|
+
source: pattern,
|
|
1284
|
+
test: (argv, dialect) => [executableBasename(argv[0] ?? "", dialect), ...argv.slice(1)].join(" ").includes(pattern)
|
|
1285
|
+
};
|
|
1286
|
+
return {
|
|
1287
|
+
source: pattern,
|
|
1288
|
+
test: (argv, dialect) => {
|
|
1289
|
+
if (argv.length < tokens.length) return false;
|
|
1290
|
+
return tokens.every((token, index) => {
|
|
1291
|
+
const actual = argv[index] ?? "";
|
|
1292
|
+
return index === 0 ? executableBasename(actual, dialect).toLowerCase() === executableBasename(token, dialect).toLowerCase() : actual === token;
|
|
1293
|
+
});
|
|
1294
|
+
}
|
|
1295
|
+
};
|
|
1296
|
+
}
|
|
1297
|
+
/** Thrown inside the checker to stop at the first refusal. */
|
|
1298
|
+
var Refusal = class extends Error {
|
|
1299
|
+
constructor(reason, message, executable, refusedPath) {
|
|
1300
|
+
super(message);
|
|
1301
|
+
this.reason = reason;
|
|
1302
|
+
this.executable = executable;
|
|
1303
|
+
this.refusedPath = refusedPath;
|
|
1304
|
+
}
|
|
1305
|
+
};
|
|
1306
|
+
/** Per-line checker state. */
|
|
1307
|
+
var LineChecker = class {
|
|
1308
|
+
constructor(policy, shell, line, dialect) {
|
|
1309
|
+
this.policy = policy;
|
|
1310
|
+
this.shell = shell;
|
|
1311
|
+
this.line = line;
|
|
1312
|
+
this.dialect = dialect;
|
|
1313
|
+
this.commands = [];
|
|
1314
|
+
this.paths = [];
|
|
1315
|
+
this.deny = new Set((shell.deny ?? []).map((name) => name.toLowerCase()));
|
|
1316
|
+
this.allow = shell.allow ?? [];
|
|
1317
|
+
this.patterns = (shell.denyPatterns ?? []).map(compilePattern);
|
|
1318
|
+
this.interpreter = shell.interpreter ?? "none";
|
|
1319
|
+
this.hasCommandRules = this.deny.size > 0 || this.patterns.length > 0 || shell.allowCommand !== void 0;
|
|
1320
|
+
}
|
|
1321
|
+
/** Whether argument hooks (`denyPatterns` / `allowCommand`) need literal argv. */
|
|
1322
|
+
get hasArgumentHooks() {
|
|
1323
|
+
return this.patterns.length > 0 || this.shell.allowCommand !== void 0;
|
|
1324
|
+
}
|
|
1325
|
+
get shellMode() {
|
|
1326
|
+
return this.interpreter === "sh";
|
|
1327
|
+
}
|
|
1328
|
+
/** Run `denyPatterns` and `allowCommand` on one argv. */
|
|
1329
|
+
checkHooks(argv, nested) {
|
|
1330
|
+
const shown = show(argv.join(" "));
|
|
1331
|
+
for (const pattern of this.patterns) if (pattern.test(argv, this.dialect)) throw new Refusal("denied-pattern", `${shown} matches a command pattern the workspace shell policy denies.`, executableBasename(argv[0] ?? "", this.dialect));
|
|
1332
|
+
const hook = this.shell.allowCommand;
|
|
1333
|
+
if (hook === void 0) return;
|
|
1334
|
+
let verdict;
|
|
1335
|
+
try {
|
|
1336
|
+
verdict = hook([...argv], {
|
|
1337
|
+
command: this.line,
|
|
1338
|
+
interpreter: this.interpreter,
|
|
1339
|
+
nested
|
|
1340
|
+
});
|
|
1341
|
+
} catch {
|
|
1342
|
+
verdict = false;
|
|
1343
|
+
}
|
|
1344
|
+
if (verdict === true) return;
|
|
1345
|
+
throw new Refusal("not-allowed", `${shown} was refused by the workspace shell policy.${typeof verdict === "string" && verdict.trim() !== "" ? ` ${verdict.trim()}` : ""}`, executableBasename(argv[0] ?? "", this.dialect));
|
|
1346
|
+
}
|
|
1347
|
+
/** Whether the policy names `executable` in a non-empty `allow` list. */
|
|
1348
|
+
explicitlyAllowed(executable) {
|
|
1349
|
+
return this.allow.includes(executable);
|
|
1350
|
+
}
|
|
1351
|
+
/** Refuse a command runner the policy cannot see into, unless explicitly allowed. */
|
|
1352
|
+
checkOpaque(executable, argv) {
|
|
1353
|
+
if (this.explicitlyAllowed(executable) || !this.hasCommandRules) return;
|
|
1354
|
+
throw new Refusal("nested-command", `\`${executable}\` runs a command the workspace shell policy cannot inspect (in ${show(argv.join(" "))}). Run the inner command directly instead.`, executable);
|
|
1355
|
+
}
|
|
1356
|
+
/**
|
|
1357
|
+
* Check one argv: `deny`, `allow`, `denyPatterns`, `allowCommand`, then
|
|
1358
|
+
* follow wrappers and `find -exec` to the command they run. `dynamic[i]`
|
|
1359
|
+
* says whether `argv[i]` is decided at run time (shell mode).
|
|
1360
|
+
*/
|
|
1361
|
+
checkArgv(argv, dynamic, depth) {
|
|
1362
|
+
const first = argv[0] ?? "";
|
|
1363
|
+
const executable = executableBasename(first, this.dialect);
|
|
1364
|
+
const key = executable.toLowerCase();
|
|
1365
|
+
if (executable === "") throw new Refusal("not-allowed", "The command has no executable name.");
|
|
1366
|
+
if (dynamic[0] === true) throw new Refusal("unsupported-syntax", `The command name \`${first}\` is decided when the line runs, so the workspace shell policy cannot check it. Write the command name literally.`);
|
|
1367
|
+
if (this.deny.has(key)) throw new Refusal("denied-executable", `\`${executable}\` is denied by the workspace shell policy (in ${show(argv.join(" "))}).`, executable);
|
|
1368
|
+
if (this.allow.length > 0 && !this.allow.includes(executable)) throw new Refusal("not-allowed", `\`${executable}\` is not in the workspace shell allowlist. Allowed commands: ${this.allow.slice(0, 20).join(", ")}${this.allow.length > 20 ? ", …" : ""}.`, executable);
|
|
1369
|
+
this.checkHooks(argv, depth > 0);
|
|
1370
|
+
this.commands.push([...argv]);
|
|
1371
|
+
if (OPAQUE_RUNNERS.has(key) || this.shellMode && this.dialect === "posix" && SHELL_OPAQUE_BUILTINS.has(key) || this.shellMode && this.dialect === "cmd" && CMD_OPAQUE_BUILTINS.has(key)) {
|
|
1372
|
+
this.checkOpaque(executable, argv);
|
|
1373
|
+
return;
|
|
1374
|
+
}
|
|
1375
|
+
if (this.shellMode && this.dialect === "posix" && ARITHMETIC_SENSITIVE_BUILTINS.has(key)) this.checkArithmeticSensitive(executable, argv, dynamic);
|
|
1376
|
+
const ranges = this.unwrap(key, argv);
|
|
1377
|
+
if (ranges === void 0) return;
|
|
1378
|
+
if (ranges === null || depth >= MAX_WRAPPER_DEPTH) throw new Refusal("nested-command", `\`${executable}\` is used with options the workspace shell policy cannot read (in ${show(argv.join(" "))}). Run the inner command directly instead.`, executable);
|
|
1379
|
+
for (const [start, end] of ranges) this.checkArgv(argv.slice(start, end), dynamic.slice(start, end), depth + 1);
|
|
1380
|
+
}
|
|
1381
|
+
/**
|
|
1382
|
+
* Builtins bash may evaluate arguments of as arithmetic (array subscripts
|
|
1383
|
+
* like `a[$(cmd)]` run the substitution): arguments must be literal, free
|
|
1384
|
+
* of `[`, and must not name a variable that changes how the shell runs.
|
|
1385
|
+
*/
|
|
1386
|
+
checkArithmeticSensitive(executable, argv, dynamic) {
|
|
1387
|
+
for (let index = 1; index < argv.length; index++) {
|
|
1388
|
+
const argument = argv[index] ?? "";
|
|
1389
|
+
if (dynamic[index] === true) throw new Refusal("unsupported-syntax", `\`${executable}\` arguments must be literal here (\`${argument}\` is not): bash may evaluate them as arithmetic.`, executable);
|
|
1390
|
+
if (argument.includes("[") && argument !== "[" && argument !== "]") throw new Refusal("unsupported-syntax", `\`${executable}\` arguments with \`[\` (array subscripts, evaluated as arithmetic) are not supported.`, executable);
|
|
1391
|
+
const name = argument.split("=")[0] ?? "";
|
|
1392
|
+
if (SENSITIVE_VARIABLES.has(name) || name.startsWith("BASH_FUNC_")) throw new Refusal("unsupported-syntax", `Changing \`${name}\` is not supported: it changes how the shell runs the commands that follow.`, executable);
|
|
1393
|
+
}
|
|
1394
|
+
}
|
|
1395
|
+
/**
|
|
1396
|
+
* The argv ranges a wrapper runs. `undefined` — not a wrapper (or one that
|
|
1397
|
+
* runs nothing, like a bare `env`); `null` — a wrapper whose options the
|
|
1398
|
+
* policy cannot read (refused).
|
|
1399
|
+
*/
|
|
1400
|
+
unwrap(key, argv) {
|
|
1401
|
+
switch (key) {
|
|
1402
|
+
case "env": return suffixFrom(argv, unwrapEnv(argv));
|
|
1403
|
+
case "nice": return suffixFrom(argv, unwrapNice(argv));
|
|
1404
|
+
case "nohup": return suffixFrom(argv, unwrapSimple(argv, []));
|
|
1405
|
+
case "time": return suffixFrom(argv, unwrapSimple(argv, ["-p"]));
|
|
1406
|
+
case "timeout": return suffixFrom(argv, unwrapTimeout(argv));
|
|
1407
|
+
case "command": return argv.slice(1).some((arg) => arg === "-v" || arg === "-V") ? void 0 : suffixFrom(argv, unwrapSimple(argv, ["-p"]));
|
|
1408
|
+
case "builtin": return suffixFrom(argv, unwrapSimple(argv, []));
|
|
1409
|
+
case "exec": return this.dialect === "posix" ? suffixFrom(argv, unwrapExec(argv)) : void 0;
|
|
1410
|
+
case "find": return unwrapFind(argv);
|
|
1411
|
+
default: return;
|
|
1412
|
+
}
|
|
1413
|
+
}
|
|
1414
|
+
/** Check a `cd` (shell mode): literal, one operand, inside the jail from every possible cwd. */
|
|
1415
|
+
checkCd(words, cwds) {
|
|
1416
|
+
const argv = words.map((word) => word.value);
|
|
1417
|
+
if (this.deny.has("cd")) throw new Refusal("denied-executable", "`cd` is denied by the workspace shell policy.", "cd");
|
|
1418
|
+
this.checkHooks(argv, false);
|
|
1419
|
+
this.commands.push(argv);
|
|
1420
|
+
const operands = words.slice(1);
|
|
1421
|
+
while (this.dialect === "posix" && operands.length > 0 && [
|
|
1422
|
+
"-P",
|
|
1423
|
+
"-L",
|
|
1424
|
+
"--"
|
|
1425
|
+
].includes(operands[0]?.value ?? "")) operands.shift();
|
|
1426
|
+
const target = operands[0];
|
|
1427
|
+
if (operands.length !== 1 || target === void 0 || target.value === "-" || target.dynamic) throw new Refusal("unsupported-syntax", "`cd` needs exactly one literal directory inside the workspace (no `-`, `~` or variables).", "cd");
|
|
1428
|
+
const next = new Set(cwds);
|
|
1429
|
+
for (const cwd of cwds) {
|
|
1430
|
+
const resolved = this.resolveTarget(cwd, target.value);
|
|
1431
|
+
this.assertInJail(resolved, target.value, "cd");
|
|
1432
|
+
this.paths.push({
|
|
1433
|
+
path: resolved,
|
|
1434
|
+
access: "cwd",
|
|
1435
|
+
target: target.value
|
|
1436
|
+
});
|
|
1437
|
+
next.add(resolved);
|
|
1438
|
+
}
|
|
1439
|
+
if (next.size > MAX_CD_CANDIDATES) throw new Refusal("unsupported-syntax", "The line changes directory too many times.", "cd");
|
|
1440
|
+
return [...next];
|
|
1441
|
+
}
|
|
1442
|
+
/** Check a redirection target from every possible cwd. */
|
|
1443
|
+
checkRedirectTarget(word, access, cwds) {
|
|
1444
|
+
if (word.dynamic) throw new Refusal("unsupported-syntax", `The redirection target \`${word.raw}\` must be a literal path (no variables, globs or \`~\`).`);
|
|
1445
|
+
const value = word.value;
|
|
1446
|
+
if (this.dialect === "posix" && DEVICE_TARGETS.has(value) || this.dialect === "cmd" && CMD_DEVICE_TARGETS.has(value.toLowerCase())) return;
|
|
1447
|
+
for (const cwd of cwds) {
|
|
1448
|
+
const resolved = this.resolveTarget(cwd, value);
|
|
1449
|
+
this.assertInJail(resolved, value, "redirect");
|
|
1450
|
+
this.paths.push({
|
|
1451
|
+
path: resolved,
|
|
1452
|
+
access,
|
|
1453
|
+
target: value
|
|
1454
|
+
});
|
|
1455
|
+
}
|
|
1456
|
+
}
|
|
1457
|
+
resolveTarget(cwd, target) {
|
|
1458
|
+
return node_path.default.resolve(cwd, target);
|
|
1459
|
+
}
|
|
1460
|
+
assertInJail(resolved, target, kind) {
|
|
1461
|
+
if (isInsideJailLexically(this.policy, resolved)) return;
|
|
1462
|
+
throw new Refusal("path-escape", kind === "cd" ? `\`cd ${target}\` leaves the workspace jail (or enters a denied path); commands must stay inside the workspace.` : `The redirection target \`${target}\` is outside the workspace jail or on a denied path.`, void 0, target);
|
|
1463
|
+
}
|
|
1464
|
+
};
|
|
1465
|
+
/**
|
|
1466
|
+
* Turn a wrapper's "inner command starts at `start`" into a range; a
|
|
1467
|
+
* wrapper that runs nothing (`start === argv.length`) becomes `undefined`.
|
|
1468
|
+
*/
|
|
1469
|
+
function suffixFrom(argv, start) {
|
|
1470
|
+
if (start === null) return null;
|
|
1471
|
+
return start < argv.length ? [[start, argv.length]] : void 0;
|
|
1472
|
+
}
|
|
1473
|
+
/** `nohup` / `time` / `command` / `builtin`: skip the listed flags and `--`. */
|
|
1474
|
+
function unwrapSimple(argv, flags) {
|
|
1475
|
+
let index = 1;
|
|
1476
|
+
while (index < argv.length) {
|
|
1477
|
+
const arg = argv[index] ?? "";
|
|
1478
|
+
if (arg === "--") {
|
|
1479
|
+
index++;
|
|
1480
|
+
break;
|
|
1481
|
+
}
|
|
1482
|
+
if (flags.includes(arg)) {
|
|
1483
|
+
index++;
|
|
147
1484
|
continue;
|
|
148
1485
|
}
|
|
149
|
-
if (
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
1486
|
+
if (arg.startsWith("-") && arg !== "-") return null;
|
|
1487
|
+
break;
|
|
1488
|
+
}
|
|
1489
|
+
return index;
|
|
1490
|
+
}
|
|
1491
|
+
/** `env [-i] [-u NAME] [NAME=value]… command…` */
|
|
1492
|
+
function unwrapEnv(argv) {
|
|
1493
|
+
let index = 1;
|
|
1494
|
+
while (index < argv.length) {
|
|
1495
|
+
const arg = argv[index] ?? "";
|
|
1496
|
+
if (arg === "-i" || arg === "-" || arg === "--ignore-environment") {
|
|
155
1497
|
index++;
|
|
156
1498
|
continue;
|
|
157
1499
|
}
|
|
158
|
-
if (
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
1500
|
+
if (arg === "-u" || arg === "--unset") {
|
|
1501
|
+
index += 2;
|
|
1502
|
+
continue;
|
|
1503
|
+
}
|
|
1504
|
+
if (arg.startsWith("--unset=") || arg.startsWith("-u") && arg.length > 2) {
|
|
1505
|
+
index++;
|
|
1506
|
+
continue;
|
|
1507
|
+
}
|
|
1508
|
+
if (arg === "--") {
|
|
1509
|
+
index++;
|
|
1510
|
+
break;
|
|
1511
|
+
}
|
|
1512
|
+
if (arg.startsWith("-")) return null;
|
|
1513
|
+
break;
|
|
1514
|
+
}
|
|
1515
|
+
while (index < argv.length && /^[^=]+=/.test(argv[index] ?? "")) index++;
|
|
1516
|
+
return index;
|
|
1517
|
+
}
|
|
1518
|
+
/** `nice [-n N | -N | --adjustment=N] command…` */
|
|
1519
|
+
function unwrapNice(argv) {
|
|
1520
|
+
let index = 1;
|
|
1521
|
+
while (index < argv.length) {
|
|
1522
|
+
const arg = argv[index] ?? "";
|
|
1523
|
+
if (arg === "-n" || arg === "--adjustment") {
|
|
1524
|
+
index += 2;
|
|
1525
|
+
continue;
|
|
1526
|
+
}
|
|
1527
|
+
if (/^-n-?[0-9]+$/.test(arg) || /^--adjustment=-?[0-9]+$/.test(arg) || /^-[0-9]+$/.test(arg)) {
|
|
1528
|
+
index++;
|
|
1529
|
+
continue;
|
|
1530
|
+
}
|
|
1531
|
+
if (arg === "--") {
|
|
1532
|
+
index++;
|
|
1533
|
+
break;
|
|
1534
|
+
}
|
|
1535
|
+
if (arg.startsWith("-")) return null;
|
|
1536
|
+
break;
|
|
1537
|
+
}
|
|
1538
|
+
return index;
|
|
1539
|
+
}
|
|
1540
|
+
/** `timeout [options] DURATION command…` */
|
|
1541
|
+
function unwrapTimeout(argv) {
|
|
1542
|
+
let index = 1;
|
|
1543
|
+
while (index < argv.length) {
|
|
1544
|
+
const arg = argv[index] ?? "";
|
|
1545
|
+
if (arg === "-s" || arg === "--signal" || arg === "-k" || arg === "--kill-after") {
|
|
1546
|
+
index += 2;
|
|
1547
|
+
continue;
|
|
1548
|
+
}
|
|
1549
|
+
if (/^-[sk].+$/.test(arg) || arg.startsWith("--signal=") || arg.startsWith("--kill-after=") || arg === "--preserve-status" || arg === "--foreground" || arg === "-v" || arg === "--verbose") {
|
|
1550
|
+
index++;
|
|
1551
|
+
continue;
|
|
1552
|
+
}
|
|
1553
|
+
if (arg === "--") {
|
|
1554
|
+
index++;
|
|
1555
|
+
break;
|
|
1556
|
+
}
|
|
1557
|
+
if (arg.startsWith("-")) return null;
|
|
1558
|
+
break;
|
|
1559
|
+
}
|
|
1560
|
+
if (index >= argv.length - 1) return null;
|
|
1561
|
+
return index + 1;
|
|
1562
|
+
}
|
|
1563
|
+
/** `exec [-cl] [-a name] command…` (bash). */
|
|
1564
|
+
function unwrapExec(argv) {
|
|
1565
|
+
let index = 1;
|
|
1566
|
+
while (index < argv.length) {
|
|
1567
|
+
const arg = argv[index] ?? "";
|
|
1568
|
+
if (arg === "-c" || arg === "-l" || arg === "-cl" || arg === "-lc") {
|
|
1569
|
+
index++;
|
|
1570
|
+
continue;
|
|
1571
|
+
}
|
|
1572
|
+
if (arg === "-a") {
|
|
1573
|
+
index += 2;
|
|
1574
|
+
continue;
|
|
1575
|
+
}
|
|
1576
|
+
if (arg === "--") {
|
|
1577
|
+
index++;
|
|
1578
|
+
break;
|
|
1579
|
+
}
|
|
1580
|
+
if (arg.startsWith("-")) return null;
|
|
1581
|
+
break;
|
|
1582
|
+
}
|
|
1583
|
+
return index;
|
|
1584
|
+
}
|
|
1585
|
+
/** The commands `find -exec / -execdir / -ok / -okdir … ;|+` runs. */
|
|
1586
|
+
function unwrapFind(argv) {
|
|
1587
|
+
const inner = [];
|
|
1588
|
+
for (let index = 1; index < argv.length; index++) {
|
|
1589
|
+
const arg = argv[index] ?? "";
|
|
1590
|
+
if (arg !== "-exec" && arg !== "-execdir" && arg !== "-ok" && arg !== "-okdir") continue;
|
|
1591
|
+
const start = index + 1;
|
|
1592
|
+
let end = start;
|
|
1593
|
+
while (end < argv.length && argv[end] !== ";" && argv[end] !== "+") end++;
|
|
1594
|
+
if (end >= argv.length || end === start) return null;
|
|
1595
|
+
inner.push([start, end]);
|
|
1596
|
+
index = end;
|
|
1597
|
+
}
|
|
1598
|
+
return inner.length > 0 ? inner : void 0;
|
|
1599
|
+
}
|
|
1600
|
+
/** Build the argv-mode refusal for a line that is not a single argv. */
|
|
1601
|
+
function argvModeRefusal(command) {
|
|
1602
|
+
const analysis = analyzeArgvCommand(command);
|
|
1603
|
+
if (analysis.ok) return new Refusal("metacharacter", "The command cannot run as a single argv.");
|
|
1604
|
+
if (analysis.type === "empty") return new Refusal("not-allowed", "The command is empty.");
|
|
1605
|
+
if (analysis.type === "unbalanced-quotes") return new Refusal("unbalanced-quotes", `The command has an unbalanced \`${analysis.quote}\` quote. Close it, or quote the argument with the other kind of quote.`);
|
|
1606
|
+
const token = analysis.token;
|
|
1607
|
+
const suffix = " (run_shell executes a single command without a shell.)";
|
|
1608
|
+
const parsed = parseShellLine(command, "posix");
|
|
1609
|
+
const segments = parsed.ok ? parsed.segments : [];
|
|
1610
|
+
if ((token === "&&" || token === "||" || token === ";" || token === "\n") && segments.length >= 2) {
|
|
1611
|
+
const shownOperator = token === "\n" ? "A line break" : `\`${token}\``;
|
|
1612
|
+
if ((segments[0]?.source ?? "").trim().split(/\s+/)[0] === "cd") return new Refusal("metacharacter", `${shownOperator} is not supported in argv mode, and \`cd\` does not carry over: every command starts in the workspace root. Run ${segments.slice(1).map((segment) => show(segment.source)).join(", then ")} on its own, with paths relative to the root.${suffix}`);
|
|
1613
|
+
const steps = segments.map((segment) => show(segment.source));
|
|
1614
|
+
const joiner = token === "||" ? ", and if it fails, " : ", then ";
|
|
1615
|
+
return new Refusal("metacharacter", `${shownOperator} is not supported in argv mode: run ${steps.join(joiner)}, as separate commands.${suffix}`);
|
|
1616
|
+
}
|
|
1617
|
+
if (token === "&" && segments.length >= 1) {
|
|
1618
|
+
const steps = segments.map((segment) => show(segment.source));
|
|
1619
|
+
return new Refusal("metacharacter", `\`&\` is not supported in argv mode: ${steps.length === 1 ? `run ${steps[0] ?? ""} without it` : `run ${steps.join(", then ")} as separate commands`}; start a long-running command with background: true.${suffix}`);
|
|
1620
|
+
}
|
|
1621
|
+
if (token === "|") return new Refusal("metacharacter", `\`|\` (a pipe) is not supported in argv mode: run ${show(command.split("|").map((part) => part.trim()).filter(Boolean)[0] ?? command)} on its own and read its output yourself.${suffix}`);
|
|
1622
|
+
if (token === ">" || token === ">>" || token === ">&") return new Refusal("metacharacter", `\`${token}\` (redirection) is not supported in argv mode: run the command without it and save the output with write_file if you need it.${suffix}`);
|
|
1623
|
+
if (token === "<" || token === "<<" || token === "<&") return new Refusal("metacharacter", `\`${token}\` (input redirection) is not supported in argv mode: pass the file path to the command as an argument.${suffix}`);
|
|
1624
|
+
if (token === "$(" || token === "`") return new Refusal("metacharacter", `Command substitution (${token === "$(" ? "`$(…)`" : "backticks"}) is not supported in argv mode: run the inner command first, then pass its output literally.${suffix}`);
|
|
1625
|
+
if (token === "$" || token === "${") return new Refusal("metacharacter", `\`$\` (variable expansion) is not supported in argv mode: write the value literally, or put the \`$\` inside quotes to pass it as text.${suffix}`);
|
|
1626
|
+
if (token === "(" || token === ")") return new Refusal("metacharacter", `\`${token}\` is not supported in argv mode: put it inside quotes to pass it as text.${suffix}`);
|
|
1627
|
+
return new Refusal("metacharacter", `\`${token === "\n" ? "\\n" : token}\` is not supported in argv mode: run one command per call, and quote any argument that contains it.${suffix}`);
|
|
1628
|
+
}
|
|
1629
|
+
/** Check a shell-mode (`interpreter: "sh"`) line. */
|
|
1630
|
+
function checkShellLine(checker, policy, command, dialect) {
|
|
1631
|
+
if (/^[-+]/.test(command)) throw new Refusal("unsupported-syntax", "A command line may not start with `-` or `+`.");
|
|
1632
|
+
const parsed = parseShellLine(command, dialect);
|
|
1633
|
+
if (!parsed.ok) throw new Refusal(parsed.reason, `${parsed.message} The workspace shell policy only runs lines it can check.`);
|
|
1634
|
+
if (parsed.background) throw new Refusal("unsupported-syntax", "`&` (run in the background) is not supported in a command line: start a long-running command with background: true instead.");
|
|
1635
|
+
let cwds = [node_path.default.resolve(policy.cwd)];
|
|
1636
|
+
for (const simple of parsed.commands) cwds = checkSimpleCommand(checker, simple, cwds, dialect);
|
|
1637
|
+
}
|
|
1638
|
+
/** Check one parsed simple command; returns the possible working directories after it. */
|
|
1639
|
+
function checkSimpleCommand(checker, simple, cwds, dialect) {
|
|
1640
|
+
for (const assignment of simple.assignments) {
|
|
1641
|
+
const name = assignment.assignmentName ?? "";
|
|
1642
|
+
if (SENSITIVE_VARIABLES.has(name) || name.startsWith("BASH_FUNC_")) throw new Refusal("unsupported-syntax", `Assigning \`${name}\` is not supported: it changes how the shell runs the commands that follow.`);
|
|
1643
|
+
}
|
|
1644
|
+
for (const redirect of simple.redirects) if (redirect.type === "read" || redirect.type === "write") checker.checkRedirectTarget(redirect.target, redirect.type, cwds);
|
|
1645
|
+
const first = simple.words[0];
|
|
1646
|
+
if (first === void 0) return cwds;
|
|
1647
|
+
if (!first.dynamic && first.value.includes("=")) throw new Refusal("unsupported-syntax", `\`${first.raw}\` is not a plain command name or \`NAME=value\` assignment.`);
|
|
1648
|
+
if (/^[-+]/.test(first.value)) throw new Refusal("unsupported-syntax", `\`${first.raw}\` is not a command name.`);
|
|
1649
|
+
if (checker.hasArgumentHooks) {
|
|
1650
|
+
const dynamic = simple.words.find((word) => word.dynamic);
|
|
1651
|
+
if (dynamic !== void 0) throw new Refusal("unsupported-syntax", `\`${dynamic.raw}\` is decided when the line runs, so it cannot be checked against the workspace shell policy's command rules. Write the value literally (quote globs).`);
|
|
1652
|
+
}
|
|
1653
|
+
const executable = first.dynamic ? "" : executableBasename(first.value, dialect).toLowerCase();
|
|
1654
|
+
if (executable === "cd") return checker.checkCd(simple.words, cwds);
|
|
1655
|
+
if (dialect === "posix" && (executable === "pushd" || executable === "popd")) throw new Refusal("unsupported-syntax", `\`${executable}\` is not supported; use \`cd\`.`);
|
|
1656
|
+
checker.checkArgv(simple.words.map((word) => word.dynamic ? word.raw : word.value), simple.words.map((word) => word.dynamic), 0);
|
|
1657
|
+
return cwds;
|
|
1658
|
+
}
|
|
1659
|
+
/**
|
|
1660
|
+
* The shell policy's verdict on a command line, without touching the
|
|
1661
|
+
* filesystem.
|
|
1662
|
+
*
|
|
1663
|
+
* - **argv mode** (`interpreter: "none"`, the default): the line must
|
|
1664
|
+
* tokenize into one argv; otherwise it is refused with a
|
|
1665
|
+
* `"metacharacter"` / `"unbalanced-quotes"` reason and a message that
|
|
1666
|
+
* names the operator and suggests the split.
|
|
1667
|
+
* - **shell mode** (`interpreter: "sh"`): the line is parsed and every
|
|
1668
|
+
* simple command — inside `;`, `&&`, `||`, pipes, subshells, `$( )`,
|
|
1669
|
+
* backticks and here-documents — is checked; redirection targets and `cd`
|
|
1670
|
+
* directories must stay in the jail (checked lexically here; `exec`
|
|
1671
|
+
* re-checks them with symlinks resolved).
|
|
1672
|
+
*
|
|
1673
|
+
* Every argv goes through `deny` (case-insensitive, wins), `allow`
|
|
1674
|
+
* (exhaustive when non-empty), `denyPatterns` and `allowCommand`.
|
|
1675
|
+
* Wrappers (`env`, `time`, `timeout`, `nice`, `nohup`, `command`,
|
|
1676
|
+
* `builtin`, `exec`) and `find -exec` are followed to the command they run.
|
|
1677
|
+
* A runner the policy cannot see into (`bash -c`, `eval`, `xargs`, `sudo`,
|
|
1678
|
+
* …) is refused while the policy has deny rules, unless `allow` names it.
|
|
1679
|
+
*
|
|
1680
|
+
* @example
|
|
1681
|
+
* checkShellCommand({ cwd, shell: { allow: ["git"] } }, "git add -A && git commit -m wip");
|
|
1682
|
+
* // { allowed: false, reason: "metacharacter", message: "`&&` is not supported in argv mode: run `git add -A`, then `git commit -m wip`, …" }
|
|
1683
|
+
*/
|
|
1684
|
+
function checkShellCommand(policy, command) {
|
|
1685
|
+
return checkShellCommandInDialect(policy, command, shellDialect());
|
|
1686
|
+
}
|
|
1687
|
+
/**
|
|
1688
|
+
* {@link checkShellCommand} with the shell-mode dialect chosen explicitly
|
|
1689
|
+
* instead of from the platform (used to test the cmd.exe rules anywhere).
|
|
1690
|
+
* Argv mode ignores `dialect`.
|
|
1691
|
+
*/
|
|
1692
|
+
function checkShellCommandInDialect(policy, command, dialect) {
|
|
1693
|
+
const shell = policy.shell;
|
|
1694
|
+
if (!shell) return {
|
|
1695
|
+
allowed: false,
|
|
1696
|
+
reason: "not-allowed",
|
|
1697
|
+
message: "The workspace has no shell policy, so no command may run.",
|
|
1698
|
+
commands: [],
|
|
1699
|
+
paths: []
|
|
1700
|
+
};
|
|
1701
|
+
const interpreter = shell.interpreter ?? "none";
|
|
1702
|
+
const checker = new LineChecker(policy, shell, command, interpreter === "sh" ? dialect : "posix");
|
|
1703
|
+
try {
|
|
1704
|
+
if (command.trim() === "") throw new Refusal("not-allowed", "The command is empty.");
|
|
1705
|
+
if (interpreter === "sh") checkShellLine(checker, policy, command, dialect);
|
|
1706
|
+
else {
|
|
1707
|
+
const argv = tokenizeCommand(command);
|
|
1708
|
+
if (argv === null) throw argvModeRefusal(command);
|
|
1709
|
+
checker.checkArgv(argv, argv.map(() => false), 0);
|
|
1710
|
+
}
|
|
1711
|
+
return {
|
|
1712
|
+
allowed: true,
|
|
1713
|
+
commands: checker.commands,
|
|
1714
|
+
paths: checker.paths
|
|
1715
|
+
};
|
|
1716
|
+
} catch (error) {
|
|
1717
|
+
if (!(error instanceof Refusal)) throw error;
|
|
1718
|
+
return {
|
|
1719
|
+
allowed: false,
|
|
1720
|
+
reason: error.reason,
|
|
1721
|
+
message: error.message,
|
|
1722
|
+
...error.executable !== void 0 ? { executable: error.executable } : {},
|
|
1723
|
+
...error.refusedPath !== void 0 ? { path: error.refusedPath } : {},
|
|
1724
|
+
commands: checker.commands,
|
|
1725
|
+
paths: checker.paths
|
|
1726
|
+
};
|
|
162
1727
|
}
|
|
163
|
-
if (inToken) argv.push(current);
|
|
164
|
-
return argv.length > 0 ? argv : null;
|
|
165
1728
|
}
|
|
166
1729
|
|
|
167
1730
|
//#endregion
|
|
@@ -188,55 +1751,6 @@ async function canonicalize$1(target) {
|
|
|
188
1751
|
}
|
|
189
1752
|
}
|
|
190
1753
|
/**
|
|
191
|
-
* Whether `child` is contained within `root` (or equals it), comparing
|
|
192
|
-
* canonical absolute paths. Guards against the `/srv/app-evil` vs
|
|
193
|
-
* `/srv/app` prefix-collision by anchoring on a path separator.
|
|
194
|
-
*/
|
|
195
|
-
function isInside(child, root) {
|
|
196
|
-
const relative = node_path.default.relative(root, child);
|
|
197
|
-
return relative === "" || !relative.startsWith("..") && !node_path.default.isAbsolute(relative);
|
|
198
|
-
}
|
|
199
|
-
/**
|
|
200
|
-
* Translate a glob (the small subset used by `denyPaths` — `*`, `**`,
|
|
201
|
-
* `?`) into an anchored `RegExp` over a `/`-separated relative path.
|
|
202
|
-
* `**` spans path separators; a single `*` does not.
|
|
203
|
-
*/
|
|
204
|
-
function globToRegExp$1(glob) {
|
|
205
|
-
let source = "";
|
|
206
|
-
for (let index = 0; index < glob.length; index++) {
|
|
207
|
-
const char = glob[index];
|
|
208
|
-
if (char === "*") {
|
|
209
|
-
if (glob[index + 1] === "*") {
|
|
210
|
-
source += ".*";
|
|
211
|
-
index++;
|
|
212
|
-
if (glob[index + 1] === "/") index++;
|
|
213
|
-
} else source += "[^/]*";
|
|
214
|
-
continue;
|
|
215
|
-
}
|
|
216
|
-
if (char === "?") {
|
|
217
|
-
source += "[^/]";
|
|
218
|
-
continue;
|
|
219
|
-
}
|
|
220
|
-
source += char.replace(/[.+^${}()|[\]\\]/g, "\\$&");
|
|
221
|
-
}
|
|
222
|
-
return new RegExp(`^${source}$`);
|
|
223
|
-
}
|
|
224
|
-
/**
|
|
225
|
-
* Whether a workspace-relative (`/`-separated) path matches any of the
|
|
226
|
-
* policy's `denyPaths` globs. A deny glob also blocks everything beneath
|
|
227
|
-
* a matched directory (`".git/**"` blocks `.git/config`).
|
|
228
|
-
*/
|
|
229
|
-
function matchesDeny(relativePath, denyPaths) {
|
|
230
|
-
return denyPaths.some((glob) => {
|
|
231
|
-
if (globToRegExp$1(glob).test(relativePath)) return true;
|
|
232
|
-
if (!glob.includes("*") && !glob.includes("?")) {
|
|
233
|
-
const prefix = glob.endsWith("/") ? glob : `${glob}/`;
|
|
234
|
-
return relativePath.startsWith(prefix);
|
|
235
|
-
}
|
|
236
|
-
return false;
|
|
237
|
-
});
|
|
238
|
-
}
|
|
239
|
-
/**
|
|
240
1754
|
* Resolve and jail a single input path against a {@link WorkspacePolicy}.
|
|
241
1755
|
*
|
|
242
1756
|
* The input is resolved against `policy.cwd`, canonicalized (symlinks in
|
|
@@ -246,6 +1760,17 @@ function matchesDeny(relativePath, denyPaths) {
|
|
|
246
1760
|
* `denyPaths` glob even while inside `cwd`, is rejected with a
|
|
247
1761
|
* {@link WorkspacePolicyError} of type `"path-escape"`.
|
|
248
1762
|
*
|
|
1763
|
+
* Why this does not use `@warlock.js/fs`'s confined reader (`fs.confined`):
|
|
1764
|
+
* the two answer different questions. The confined reader refuses absolute
|
|
1765
|
+
* paths, refuses every symbolic link (or follows only links inside its one
|
|
1766
|
+
* root), requires the path to exist, and reads the file itself through a
|
|
1767
|
+
* no-follow, identity-checked open. This jail must instead accept absolute
|
|
1768
|
+
* paths inside `cwd`, several `allowPaths` roots, `denyPaths` globs, a
|
|
1769
|
+
* symlinked `cwd`, links whose target stays inside, and paths that do not
|
|
1770
|
+
* exist yet (`writeFile`, `mkdir`), and it returns a path that writes,
|
|
1771
|
+
* `exec`'s working directory and redirections use afterwards. Switching would
|
|
1772
|
+
* change what agents may touch, so the workspace keeps this resolver.
|
|
1773
|
+
*
|
|
249
1774
|
* @param policy - The bounding policy (its `cwd` is the jail root).
|
|
250
1775
|
* @param inputPath - A workspace-relative or absolute path to resolve.
|
|
251
1776
|
* @returns The canonical absolute path plus its `/`-separated relative form.
|
|
@@ -272,7 +1797,7 @@ async function resolveInJail(policy, inputPath) {
|
|
|
272
1797
|
});
|
|
273
1798
|
const relativeToJail = insideCwd ? node_path.default.relative(jailRoot, absolutePath).split(node_path.default.sep).join("/") : "";
|
|
274
1799
|
if (insideCwd && policy.denyPaths && policy.denyPaths.length > 0) {
|
|
275
|
-
if (
|
|
1800
|
+
if (matchesAnyGlob(relativeToJail, policy.denyPaths)) throw new WorkspacePolicyError(`Path "${inputPath}" is blocked by the workspace deny list.`, {
|
|
276
1801
|
type: "path-escape",
|
|
277
1802
|
path: inputPath
|
|
278
1803
|
});
|
|
@@ -283,32 +1808,22 @@ async function resolveInJail(policy, inputPath) {
|
|
|
283
1808
|
};
|
|
284
1809
|
}
|
|
285
1810
|
/**
|
|
286
|
-
*
|
|
287
|
-
*
|
|
288
|
-
* → `"node"` (the `.exe`/`.cmd`/`.bat`/`.com` Windows extension is
|
|
289
|
-
* stripped).
|
|
290
|
-
*/
|
|
291
|
-
function executableBasename(firstToken) {
|
|
292
|
-
return node_path.default.basename(firstToken).replace(/\.(exe|cmd|bat|com)$/i, "");
|
|
293
|
-
}
|
|
294
|
-
/**
|
|
295
|
-
* Whether a shell command is permitted by the policy's `shell` sub-policy.
|
|
1811
|
+
* Whether a command line is permitted by the policy's `shell` sub-policy —
|
|
1812
|
+
* `checkShellCommand(policy, command).allowed`.
|
|
296
1813
|
*
|
|
297
|
-
*
|
|
298
|
-
*
|
|
299
|
-
*
|
|
300
|
-
*
|
|
301
|
-
*
|
|
302
|
-
*
|
|
303
|
-
*
|
|
304
|
-
*
|
|
305
|
-
*
|
|
306
|
-
* allowlist); when `allow` is absent/empty, any non-denied command is
|
|
307
|
-
* permitted. An absent `shell` block means no command may run at all.
|
|
1814
|
+
* In argv mode (the default) the command must tokenize into a single argv
|
|
1815
|
+
* (no unquoted `;`, `&&`, `|`, backticks, `$`, redirection, …): the backend
|
|
1816
|
+
* spawns it with no shell, and unquoted metacharacters were exactly how an
|
|
1817
|
+
* injected command chain used to ride past the allowlist. In shell mode
|
|
1818
|
+
* (`interpreter: "sh"`) every simple command in the line is checked. Each
|
|
1819
|
+
* argv's executable basename is matched against `shell.deny` (wins, case
|
|
1820
|
+
* insensitive) then `shell.allow` (exhaustive when non-empty), then
|
|
1821
|
+
* `shell.denyPatterns` and `shell.allowCommand`. An absent `shell` block
|
|
1822
|
+
* means no command may run at all.
|
|
308
1823
|
*
|
|
309
|
-
* Returns a plain `boolean
|
|
310
|
-
* the
|
|
311
|
-
*
|
|
1824
|
+
* Returns a plain `boolean`; use {@link checkShellCommand} for the reason
|
|
1825
|
+
* and the model-facing message. Redirection targets are checked lexically
|
|
1826
|
+
* here; `exec` re-checks them with symlinks resolved.
|
|
312
1827
|
*
|
|
313
1828
|
* @example
|
|
314
1829
|
* isCommandAllowed({ cwd, shell: { allow: ["npm"] } }, "npm test"); // true
|
|
@@ -316,15 +1831,7 @@ function executableBasename(firstToken) {
|
|
|
316
1831
|
* isCommandAllowed({ cwd, shell: { allow: ["npm"] } }, "npm test; rm -rf /"); // false
|
|
317
1832
|
*/
|
|
318
1833
|
function isCommandAllowed(policy, command) {
|
|
319
|
-
|
|
320
|
-
if (!shell) return false;
|
|
321
|
-
const argv = tokenizeCommand(command);
|
|
322
|
-
if (argv === null) return false;
|
|
323
|
-
const executable = executableBasename(argv[0]);
|
|
324
|
-
if (executable === "") return false;
|
|
325
|
-
if (shell.deny && shell.deny.includes(executable)) return false;
|
|
326
|
-
if (shell.allow && shell.allow.length > 0) return shell.allow.includes(executable);
|
|
327
|
-
return true;
|
|
1834
|
+
return checkShellCommand(policy, command).allowed;
|
|
328
1835
|
}
|
|
329
1836
|
/**
|
|
330
1837
|
* Build the exact environment a spawned process receives — `process.env`
|
|
@@ -349,6 +1856,280 @@ function buildEnv(policy) {
|
|
|
349
1856
|
if (shell.env) for (const [key, value] of Object.entries(shell.env)) env[key] = value;
|
|
350
1857
|
return env;
|
|
351
1858
|
}
|
|
1859
|
+
/**
|
|
1860
|
+
* Variables removed from the environment of a shell-mode (`interpreter:
|
|
1861
|
+
* "sh"`) command: each changes how the shell itself starts, parses or
|
|
1862
|
+
* resolves the line (`ENV`/`BASH_ENV` run a startup file, `SHELLOPTS` +
|
|
1863
|
+
* `PS4` can run command substitutions, `BASH_FUNC_*` imports functions that
|
|
1864
|
+
* shadow commands, `CDPATH` moves `cd`, `IFS` re-splits words).
|
|
1865
|
+
*/
|
|
1866
|
+
const SHELL_STARTUP_VARIABLES = new Set([
|
|
1867
|
+
"ENV",
|
|
1868
|
+
"BASH_ENV",
|
|
1869
|
+
"SHELLOPTS",
|
|
1870
|
+
"BASHOPTS",
|
|
1871
|
+
"CDPATH",
|
|
1872
|
+
"IFS",
|
|
1873
|
+
"PS4",
|
|
1874
|
+
"GLOBIGNORE"
|
|
1875
|
+
]);
|
|
1876
|
+
/**
|
|
1877
|
+
* The environment a command actually receives: {@link buildEnv}, minus the
|
|
1878
|
+
* shell start-up variables when the policy runs lines through a shell.
|
|
1879
|
+
* Compared case-insensitively, because Windows environment names are.
|
|
1880
|
+
*/
|
|
1881
|
+
function buildCommandEnv(policy) {
|
|
1882
|
+
const env = buildEnv(policy);
|
|
1883
|
+
if ((policy.shell?.interpreter ?? "none") !== "sh") return env;
|
|
1884
|
+
for (const key of Object.keys(env)) {
|
|
1885
|
+
const upper = key.toUpperCase();
|
|
1886
|
+
if (SHELL_STARTUP_VARIABLES.has(upper) || upper.startsWith("BASH_FUNC_")) delete env[key];
|
|
1887
|
+
}
|
|
1888
|
+
return env;
|
|
1889
|
+
}
|
|
1890
|
+
|
|
1891
|
+
//#endregion
|
|
1892
|
+
//#region ../../packages/ai-workspace/src/shell-jobs.ts
|
|
1893
|
+
/** Finished commands kept for a last `shell_output` read before the oldest are pruned. */
|
|
1894
|
+
const MAX_FINISHED_JOBS = 16;
|
|
1895
|
+
/** Longest `waitMs` a `shellOutput` call may ask for. */
|
|
1896
|
+
const MAX_OUTPUT_WAIT_MS = 3e4;
|
|
1897
|
+
/** How long `kill()` / `dispose()` wait for a killed tree to report its exit. */
|
|
1898
|
+
const KILL_SETTLE_MS = 5e3;
|
|
1899
|
+
/** Resolve after `ms`, without keeping the event loop alive. */
|
|
1900
|
+
function delay(ms) {
|
|
1901
|
+
return new Promise((resolve) => {
|
|
1902
|
+
setTimeout(resolve, ms).unref?.();
|
|
1903
|
+
});
|
|
1904
|
+
}
|
|
1905
|
+
/**
|
|
1906
|
+
* Keep the last `maxBytes` UTF-8 bytes of `text`, cutting forward to a
|
|
1907
|
+
* character boundary so the kept tail never starts mid-character.
|
|
1908
|
+
*/
|
|
1909
|
+
function tailUtf8(text, maxBytes) {
|
|
1910
|
+
const buffer = Buffer.from(text, "utf8");
|
|
1911
|
+
if (buffer.byteLength <= maxBytes) return {
|
|
1912
|
+
text,
|
|
1913
|
+
bytes: buffer.byteLength
|
|
1914
|
+
};
|
|
1915
|
+
let start = buffer.byteLength - maxBytes;
|
|
1916
|
+
while (start < buffer.byteLength && ((buffer[start] ?? 0) & 192) === 128) start++;
|
|
1917
|
+
const kept = buffer.subarray(start);
|
|
1918
|
+
return {
|
|
1919
|
+
text: kept.toString("utf8"),
|
|
1920
|
+
bytes: kept.byteLength
|
|
1921
|
+
};
|
|
1922
|
+
}
|
|
1923
|
+
/** Bytes of `text` from byte `skip` on, moved forward to a character boundary. */
|
|
1924
|
+
function skipUtf8(text, skip) {
|
|
1925
|
+
const buffer = Buffer.from(text, "utf8");
|
|
1926
|
+
let start = Math.max(0, skip);
|
|
1927
|
+
while (start < buffer.byteLength && ((buffer[start] ?? 0) & 192) === 128) start++;
|
|
1928
|
+
return buffer.subarray(start).toString("utf8");
|
|
1929
|
+
}
|
|
1930
|
+
/** One background command and its retained output. */
|
|
1931
|
+
var ShellJob = class {
|
|
1932
|
+
constructor(id, command, retainBytes) {
|
|
1933
|
+
this.id = id;
|
|
1934
|
+
this.command = command;
|
|
1935
|
+
this.retainBytes = retainBytes;
|
|
1936
|
+
this.status = "running";
|
|
1937
|
+
this.exitCode = null;
|
|
1938
|
+
this.startedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
1939
|
+
this.produced = 0;
|
|
1940
|
+
this.readCursor = 0;
|
|
1941
|
+
this.chunks = [];
|
|
1942
|
+
this.retained = 0;
|
|
1943
|
+
this.listeners = /* @__PURE__ */ new Set();
|
|
1944
|
+
this.killRequested = false;
|
|
1945
|
+
this.timedOut = false;
|
|
1946
|
+
}
|
|
1947
|
+
append(stream, chunk) {
|
|
1948
|
+
if (chunk === "") return;
|
|
1949
|
+
const bytes = Buffer.byteLength(chunk, "utf8");
|
|
1950
|
+
this.chunks.push({
|
|
1951
|
+
stream,
|
|
1952
|
+
text: chunk,
|
|
1953
|
+
offset: this.produced,
|
|
1954
|
+
bytes
|
|
1955
|
+
});
|
|
1956
|
+
this.produced += bytes;
|
|
1957
|
+
this.retained += bytes;
|
|
1958
|
+
this.trim();
|
|
1959
|
+
this.notify();
|
|
1960
|
+
}
|
|
1961
|
+
/** Drop the oldest output until at most `retainBytes` are kept. */
|
|
1962
|
+
trim() {
|
|
1963
|
+
while (this.retained > this.retainBytes && this.chunks.length > 0) {
|
|
1964
|
+
const oldest = this.chunks[0];
|
|
1965
|
+
if (oldest === void 0) return;
|
|
1966
|
+
const excess = this.retained - this.retainBytes;
|
|
1967
|
+
if (oldest.bytes <= excess) {
|
|
1968
|
+
this.chunks.shift();
|
|
1969
|
+
this.retained -= oldest.bytes;
|
|
1970
|
+
continue;
|
|
1971
|
+
}
|
|
1972
|
+
const kept = tailUtf8(oldest.text, oldest.bytes - excess);
|
|
1973
|
+
this.retained -= oldest.bytes - kept.bytes;
|
|
1974
|
+
oldest.offset += oldest.bytes - kept.bytes;
|
|
1975
|
+
oldest.text = kept.text;
|
|
1976
|
+
oldest.bytes = kept.bytes;
|
|
1977
|
+
}
|
|
1978
|
+
}
|
|
1979
|
+
finish(exitCode) {
|
|
1980
|
+
if (this.status !== "running") return;
|
|
1981
|
+
this.exitCode = exitCode;
|
|
1982
|
+
this.status = this.timedOut ? "timed-out" : this.killRequested ? "killed" : "exited";
|
|
1983
|
+
this.endedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
1984
|
+
if (this.timer !== void 0) clearTimeout(this.timer);
|
|
1985
|
+
this.notify();
|
|
1986
|
+
}
|
|
1987
|
+
notify() {
|
|
1988
|
+
for (const listener of [...this.listeners]) listener();
|
|
1989
|
+
}
|
|
1990
|
+
/** Resolve on the next output or state change (or after `ms`, or on abort). */
|
|
1991
|
+
waitForChange(ms, signal) {
|
|
1992
|
+
return new Promise((resolve) => {
|
|
1993
|
+
if (signal?.aborted) {
|
|
1994
|
+
resolve();
|
|
1995
|
+
return;
|
|
1996
|
+
}
|
|
1997
|
+
let timer;
|
|
1998
|
+
const done = () => {
|
|
1999
|
+
if (timer !== void 0) clearTimeout(timer);
|
|
2000
|
+
this.listeners.delete(done);
|
|
2001
|
+
signal?.removeEventListener("abort", done);
|
|
2002
|
+
resolve();
|
|
2003
|
+
};
|
|
2004
|
+
timer = setTimeout(done, ms);
|
|
2005
|
+
timer.unref?.();
|
|
2006
|
+
this.listeners.add(done);
|
|
2007
|
+
signal?.addEventListener("abort", done, { once: true });
|
|
2008
|
+
});
|
|
2009
|
+
}
|
|
2010
|
+
info() {
|
|
2011
|
+
return {
|
|
2012
|
+
id: this.id,
|
|
2013
|
+
command: this.command,
|
|
2014
|
+
status: this.status,
|
|
2015
|
+
exitCode: this.exitCode,
|
|
2016
|
+
...this.process?.pid !== void 0 ? { pid: this.process.pid } : {},
|
|
2017
|
+
startedAt: this.startedAt,
|
|
2018
|
+
...this.endedAt !== void 0 ? { endedAt: this.endedAt } : {}
|
|
2019
|
+
};
|
|
2020
|
+
}
|
|
2021
|
+
/** Output produced after byte `since`, plus how much of it is gone. */
|
|
2022
|
+
read(since) {
|
|
2023
|
+
const from = Math.min(Math.max(0, Math.floor(since)), this.produced);
|
|
2024
|
+
const firstRetained = this.chunks[0]?.offset ?? this.produced;
|
|
2025
|
+
const droppedBytes = Math.max(0, firstRetained - from);
|
|
2026
|
+
let stdout = "";
|
|
2027
|
+
let stderr = "";
|
|
2028
|
+
for (const chunk of this.chunks) {
|
|
2029
|
+
if (chunk.offset + chunk.bytes <= from) continue;
|
|
2030
|
+
const text = chunk.offset >= from ? chunk.text : skipUtf8(chunk.text, from - chunk.offset);
|
|
2031
|
+
if (chunk.stream === "stdout") stdout += text;
|
|
2032
|
+
else stderr += text;
|
|
2033
|
+
}
|
|
2034
|
+
this.readCursor = this.produced;
|
|
2035
|
+
return {
|
|
2036
|
+
...this.info(),
|
|
2037
|
+
stdout,
|
|
2038
|
+
stderr,
|
|
2039
|
+
cursor: this.produced,
|
|
2040
|
+
droppedBytes,
|
|
2041
|
+
truncated: droppedBytes > 0
|
|
2042
|
+
};
|
|
2043
|
+
}
|
|
2044
|
+
};
|
|
2045
|
+
/**
|
|
2046
|
+
* The background commands one workspace owns. Shared by every projection
|
|
2047
|
+
* (`readonly()`, `scope()`) of the workspace, so `dispose()` on any of them
|
|
2048
|
+
* stops them all.
|
|
2049
|
+
*/
|
|
2050
|
+
var ShellJobs = class {
|
|
2051
|
+
constructor() {
|
|
2052
|
+
this.jobs = /* @__PURE__ */ new Map();
|
|
2053
|
+
this.nextId = 1;
|
|
2054
|
+
this.disposed = false;
|
|
2055
|
+
}
|
|
2056
|
+
/** Whether {@link dispose} has run. */
|
|
2057
|
+
get isDisposed() {
|
|
2058
|
+
return this.disposed;
|
|
2059
|
+
}
|
|
2060
|
+
/** Start a command as a background job. */
|
|
2061
|
+
start(spec) {
|
|
2062
|
+
if (this.disposed) throw new WorkspacePolicyError("The workspace has been disposed; it cannot start new commands.", {
|
|
2063
|
+
type: "denied-command",
|
|
2064
|
+
reason: "not-allowed",
|
|
2065
|
+
command: spec.command
|
|
2066
|
+
});
|
|
2067
|
+
const running = [...this.jobs.values()].filter((job) => job.status === "running").length;
|
|
2068
|
+
if (running >= spec.maxJobs) throw new WorkspacePolicyError(`${running} background commands are already running (the limit is ${spec.maxJobs}). Stop one with shell_kill first.`, {
|
|
2069
|
+
type: "denied-command",
|
|
2070
|
+
reason: "not-allowed",
|
|
2071
|
+
command: spec.command
|
|
2072
|
+
});
|
|
2073
|
+
const job = new ShellJob(`job-${this.nextId++}`, spec.command, spec.retainBytes);
|
|
2074
|
+
this.jobs.set(job.id, job);
|
|
2075
|
+
const handle = spec.launch((stream, chunk) => job.append(stream, chunk));
|
|
2076
|
+
job.process = handle;
|
|
2077
|
+
if (spec.timeoutMs !== void 0 && spec.timeoutMs > 0) {
|
|
2078
|
+
job.timer = setTimeout(() => {
|
|
2079
|
+
if (job.status === "running") {
|
|
2080
|
+
job.timedOut = true;
|
|
2081
|
+
handle.kill();
|
|
2082
|
+
}
|
|
2083
|
+
}, spec.timeoutMs);
|
|
2084
|
+
job.timer.unref?.();
|
|
2085
|
+
}
|
|
2086
|
+
handle.done.then(({ exitCode }) => job.finish(exitCode));
|
|
2087
|
+
this.prune();
|
|
2088
|
+
return job.info();
|
|
2089
|
+
}
|
|
2090
|
+
/** Forget the oldest finished jobs beyond {@link MAX_FINISHED_JOBS}. */
|
|
2091
|
+
prune() {
|
|
2092
|
+
const finished = [...this.jobs.values()].filter((job) => job.status !== "running");
|
|
2093
|
+
for (const job of finished.slice(0, Math.max(0, finished.length - MAX_FINISHED_JOBS))) this.jobs.delete(job.id);
|
|
2094
|
+
}
|
|
2095
|
+
get(id) {
|
|
2096
|
+
const job = this.jobs.get(id);
|
|
2097
|
+
if (job === void 0) throw new WorkspaceJobError(`No background command with id "${id}" exists in this workspace.`, {
|
|
2098
|
+
type: "not-found",
|
|
2099
|
+
jobId: id
|
|
2100
|
+
});
|
|
2101
|
+
return job;
|
|
2102
|
+
}
|
|
2103
|
+
/** New output of a job (see {@link ShellOutputOptions}). */
|
|
2104
|
+
async output(id, opts) {
|
|
2105
|
+
const job = this.get(id);
|
|
2106
|
+
const since = opts?.since ?? job.readCursor;
|
|
2107
|
+
const waitMs = Math.min(Math.max(0, opts?.waitMs ?? 0), MAX_OUTPUT_WAIT_MS);
|
|
2108
|
+
if (waitMs > 0 && job.status === "running" && job.produced <= since) await job.waitForChange(waitMs, opts?.signal);
|
|
2109
|
+
return job.read(since);
|
|
2110
|
+
}
|
|
2111
|
+
/** Kill a job's process tree and wait (bounded) for it to stop. */
|
|
2112
|
+
async kill(id) {
|
|
2113
|
+
const job = this.get(id);
|
|
2114
|
+
await this.stop(job);
|
|
2115
|
+
return job.info();
|
|
2116
|
+
}
|
|
2117
|
+
async stop(job) {
|
|
2118
|
+
if (job.status !== "running" || job.process === void 0) return;
|
|
2119
|
+
job.killRequested = true;
|
|
2120
|
+
job.process.kill();
|
|
2121
|
+
await Promise.race([job.process.done, delay(KILL_SETTLE_MS)]);
|
|
2122
|
+
}
|
|
2123
|
+
/** Every job, oldest first. */
|
|
2124
|
+
list() {
|
|
2125
|
+
return [...this.jobs.values()].map((job) => job.info());
|
|
2126
|
+
}
|
|
2127
|
+
/** Kill every running job and refuse new ones. Idempotent. */
|
|
2128
|
+
async dispose() {
|
|
2129
|
+
this.disposed = true;
|
|
2130
|
+
await Promise.all([...this.jobs.values()].map((job) => this.stop(job)));
|
|
2131
|
+
}
|
|
2132
|
+
};
|
|
352
2133
|
|
|
353
2134
|
//#endregion
|
|
354
2135
|
//#region ../../packages/ai-workspace/src/ops.ts
|
|
@@ -358,6 +2139,14 @@ const DEFAULT_READ_LINES = 2e3;
|
|
|
358
2139
|
const DEFAULT_MAX_GREP_MATCHES = 1e3;
|
|
359
2140
|
/** Default per-command output byte cap when the policy sets none. */
|
|
360
2141
|
const DEFAULT_MAX_OUTPUT_BYTES = 1e6;
|
|
2142
|
+
/** Default `list.ignore` when the policy sets none. */
|
|
2143
|
+
const DEFAULT_LIST_IGNORE = ["node_modules", ".git"];
|
|
2144
|
+
/** Default deepest `depth` a listing may ask for. */
|
|
2145
|
+
const DEFAULT_LIST_MAX_DEPTH = 5;
|
|
2146
|
+
/** Default most entries one listing returns. */
|
|
2147
|
+
const DEFAULT_LIST_MAX_ENTRIES = 500;
|
|
2148
|
+
/** Most edits one `multiEdit` call may carry. */
|
|
2149
|
+
const MAX_MULTI_EDITS = 100;
|
|
361
2150
|
/**
|
|
362
2151
|
* Exit code reported for a command refused because its signal was already
|
|
363
2152
|
* aborted (the shell convention for an interrupted command). Callers should
|
|
@@ -448,32 +2237,6 @@ function capOutput(value, maxBytes) {
|
|
|
448
2237
|
return clipUtf8(value, maxBytes);
|
|
449
2238
|
}
|
|
450
2239
|
/**
|
|
451
|
-
* Minimal glob match over a `/`-separated relative path. Supports `**`
|
|
452
|
-
* (spans separators), `*` (within a segment), and `?` (one non-separator
|
|
453
|
-
* char) — enough for the workspace's `glob`/grep narrowing without
|
|
454
|
-
* pulling in a runtime dependency.
|
|
455
|
-
*/
|
|
456
|
-
function globToRegExp(glob) {
|
|
457
|
-
let source = "";
|
|
458
|
-
for (let index = 0; index < glob.length; index++) {
|
|
459
|
-
const char = glob[index];
|
|
460
|
-
if (char === "*") {
|
|
461
|
-
if (glob[index + 1] === "*") {
|
|
462
|
-
source += ".*";
|
|
463
|
-
index++;
|
|
464
|
-
if (glob[index + 1] === "/") index++;
|
|
465
|
-
} else source += "[^/]*";
|
|
466
|
-
continue;
|
|
467
|
-
}
|
|
468
|
-
if (char === "?") {
|
|
469
|
-
source += "[^/]";
|
|
470
|
-
continue;
|
|
471
|
-
}
|
|
472
|
-
source += char.replace(/[.+^${}()|[\]\\]/g, "\\$&");
|
|
473
|
-
}
|
|
474
|
-
return new RegExp(`^${source}$`);
|
|
475
|
-
}
|
|
476
|
-
/**
|
|
477
2240
|
* A quantifier: `+`, `*`, `?`, or a `{m,n}` bound.
|
|
478
2241
|
*/
|
|
479
2242
|
const QUANTIFIER_SOURCE = String.raw`[+*?]|\{\d*,?\d*\}`;
|
|
@@ -495,17 +2258,11 @@ function isSafeGrepPattern(pattern) {
|
|
|
495
2258
|
if (pattern.length > MAX_GREP_PATTERN_LENGTH) return false;
|
|
496
2259
|
return !NESTED_QUANTIFIER_PATTERN.test(pattern);
|
|
497
2260
|
}
|
|
498
|
-
/**
|
|
499
|
-
* The internal, single-instance implementation of {@link WorkspaceOps}.
|
|
500
|
-
* Holds the backend + policy and is the one place the jail, command
|
|
501
|
-
* gating, read caps, and the read-before-edit guard are enforced — both
|
|
502
|
-
* the agent-facing tools and the human-facing direct methods funnel
|
|
503
|
-
* through this object, so there is exactly one set of rules.
|
|
504
|
-
*/
|
|
505
2261
|
var Ops = class {
|
|
506
|
-
constructor(backend, policy) {
|
|
2262
|
+
constructor(backend, policy, runtime) {
|
|
507
2263
|
this.backend = backend;
|
|
508
2264
|
this.policy = policy;
|
|
2265
|
+
this.runtime = runtime;
|
|
509
2266
|
this.pathLocks = /* @__PURE__ */ new Map();
|
|
510
2267
|
}
|
|
511
2268
|
/** Run `task` after every earlier mutation of `absolutePath` has settled. */
|
|
@@ -599,30 +2356,76 @@ var Ops = class {
|
|
|
599
2356
|
expectedHash: input.expectHash,
|
|
600
2357
|
actualHash: currentHash
|
|
601
2358
|
});
|
|
602
|
-
const
|
|
603
|
-
if (occurrences === 0) throw new WorkspaceEditError(`The text to replace was not found in "${input.path}".`, {
|
|
604
|
-
type: "not-found",
|
|
605
|
-
path: relativePath || input.path,
|
|
606
|
-
matches: 0
|
|
607
|
-
});
|
|
608
|
-
if (occurrences > 1 && !input.replaceAll) throw new WorkspaceEditError(`The text to replace is not unique in "${input.path}" (${occurrences} matches); pass replaceAll or include more surrounding context.`, {
|
|
609
|
-
type: "not-unique",
|
|
610
|
-
path: relativePath || input.path,
|
|
611
|
-
matches: occurrences
|
|
612
|
-
});
|
|
613
|
-
const updated = input.replaceAll ? current.split(input.oldString).join(input.newString) : replaceFirst(current, input.oldString, input.newString);
|
|
2359
|
+
const { content: updated, replacements } = applyReplacement(current, input, input.path, relativePath || input.path);
|
|
614
2360
|
await this.backend.writeFile(absolutePath, updated);
|
|
615
2361
|
return {
|
|
616
2362
|
path: relativePath || input.path,
|
|
617
|
-
replacements
|
|
2363
|
+
replacements,
|
|
618
2364
|
hash: _warlock_js_fs.fs.hash.string(updated)
|
|
619
2365
|
};
|
|
620
2366
|
}
|
|
621
|
-
async
|
|
622
|
-
|
|
2367
|
+
async multiEdit(input) {
|
|
2368
|
+
const { absolutePath, relativePath } = await resolveInJail(this.policy, input.path);
|
|
2369
|
+
const shownPath = relativePath || input.path;
|
|
2370
|
+
if (input.edits.length === 0 || input.edits.length > 100) throw new RangeError(`A multi-edit needs between 1 and ${100} edits (got ${input.edits.length}).`);
|
|
2371
|
+
return this.withPathLock(absolutePath, async () => {
|
|
2372
|
+
const current = await this.backend.readFile(absolutePath);
|
|
2373
|
+
const currentHash = _warlock_js_fs.fs.hash.string(current);
|
|
2374
|
+
if (input.expectHash !== void 0 && input.expectHash !== currentHash) throw new WorkspaceEditError(`File "${input.path}" changed since it was read; none of the edits were applied.`, {
|
|
2375
|
+
type: "stale-hash",
|
|
2376
|
+
path: shownPath,
|
|
2377
|
+
expectedHash: input.expectHash,
|
|
2378
|
+
actualHash: currentHash
|
|
2379
|
+
});
|
|
2380
|
+
let content = current;
|
|
2381
|
+
let replacements = 0;
|
|
2382
|
+
input.edits.forEach((edit, editIndex) => {
|
|
2383
|
+
const applied = applyReplacement(content, edit, input.path, shownPath, editIndex);
|
|
2384
|
+
content = applied.content;
|
|
2385
|
+
replacements += applied.replacements;
|
|
2386
|
+
});
|
|
2387
|
+
await this.backend.writeFile(absolutePath, content);
|
|
2388
|
+
return {
|
|
2389
|
+
path: shownPath,
|
|
2390
|
+
edits: input.edits.length,
|
|
2391
|
+
replacements,
|
|
2392
|
+
hash: _warlock_js_fs.fs.hash.string(content)
|
|
2393
|
+
};
|
|
2394
|
+
});
|
|
2395
|
+
}
|
|
2396
|
+
/**
|
|
2397
|
+
* Check a command line against the shell policy: every command in it
|
|
2398
|
+
* (argv or shell mode), then — shell mode — every redirection target and
|
|
2399
|
+
* `cd` directory against the jail with symlinks resolved.
|
|
2400
|
+
*/
|
|
2401
|
+
async assertCommandAllowed(command) {
|
|
2402
|
+
const verdict = checkShellCommand(this.policy, command);
|
|
2403
|
+
if (!verdict.allowed) throw new WorkspacePolicyError(verdict.message ?? `Command is not permitted by the workspace shell policy: ${command}`, {
|
|
2404
|
+
type: "denied-command",
|
|
2405
|
+
command,
|
|
2406
|
+
...verdict.reason !== void 0 ? { reason: verdict.reason } : {},
|
|
2407
|
+
...verdict.executable !== void 0 ? { executable: verdict.executable } : {},
|
|
2408
|
+
...verdict.path !== void 0 ? { path: verdict.path } : {}
|
|
2409
|
+
});
|
|
2410
|
+
for (const target of verdict.paths) try {
|
|
2411
|
+
await resolveInJail(this.policy, target.path);
|
|
2412
|
+
} catch (error) {
|
|
2413
|
+
if (!(error instanceof WorkspacePolicyError)) throw error;
|
|
2414
|
+
throw new WorkspacePolicyError(target.access === "cwd" ? `\`cd ${target.target}\` resolves (through a symlink) outside the workspace jail or onto a denied path.` : `The redirection target \`${target.target}\` resolves (through a symlink) outside the workspace jail or onto a denied path.`, {
|
|
2415
|
+
type: "denied-command",
|
|
2416
|
+
reason: "path-escape",
|
|
2417
|
+
command,
|
|
2418
|
+
path: target.target
|
|
2419
|
+
});
|
|
2420
|
+
}
|
|
2421
|
+
if (this.runtime.jobs.isDisposed) throw new WorkspacePolicyError("The workspace has been disposed; it cannot run new commands.", {
|
|
623
2422
|
type: "denied-command",
|
|
2423
|
+
reason: "not-allowed",
|
|
624
2424
|
command
|
|
625
2425
|
});
|
|
2426
|
+
}
|
|
2427
|
+
async exec(command, opts) {
|
|
2428
|
+
await this.assertCommandAllowed(command);
|
|
626
2429
|
const signal = opts?.signal;
|
|
627
2430
|
if (signal?.aborted) return {
|
|
628
2431
|
exitCode: CANCELLED_BEFORE_START_EXIT_CODE,
|
|
@@ -635,11 +2438,13 @@ var Ops = class {
|
|
|
635
2438
|
const shell = this.policy.shell;
|
|
636
2439
|
const timeoutMs = opts?.timeoutMs ?? shell?.timeoutMs;
|
|
637
2440
|
const maxOutputBytes = shell?.maxOutputBytes ?? DEFAULT_MAX_OUTPUT_BYTES;
|
|
2441
|
+
const interpreter = shell?.interpreter ?? "none";
|
|
638
2442
|
const result = await this.backend.exec(command, {
|
|
639
2443
|
cwd: this.policy.cwd,
|
|
640
2444
|
timeoutMs,
|
|
641
|
-
env:
|
|
642
|
-
...signal ? { signal } : {}
|
|
2445
|
+
env: buildCommandEnv(this.policy),
|
|
2446
|
+
...signal ? { signal } : {},
|
|
2447
|
+
...interpreter !== "none" ? { interpreter } : {}
|
|
643
2448
|
});
|
|
644
2449
|
const stdout = capOutput(result.stdout, maxOutputBytes);
|
|
645
2450
|
const stderr = capOutput(result.stderr, maxOutputBytes);
|
|
@@ -653,6 +2458,120 @@ var Ops = class {
|
|
|
653
2458
|
cancelled
|
|
654
2459
|
};
|
|
655
2460
|
}
|
|
2461
|
+
async startShell(command, opts) {
|
|
2462
|
+
const background = this.policy.shell?.background;
|
|
2463
|
+
if (background === void 0) throw new WorkspacePolicyError("Background commands are not enabled for this workspace. Run the command without background, or ask for shell.background to be enabled.", {
|
|
2464
|
+
type: "denied-command",
|
|
2465
|
+
reason: "not-allowed",
|
|
2466
|
+
command
|
|
2467
|
+
});
|
|
2468
|
+
await this.assertCommandAllowed(command);
|
|
2469
|
+
const start = this.backend.start?.bind(this.backend);
|
|
2470
|
+
if (start === void 0) throw new WorkspacePolicyError("This workspace backend cannot run background commands.", {
|
|
2471
|
+
type: "denied-command",
|
|
2472
|
+
reason: "not-allowed",
|
|
2473
|
+
command
|
|
2474
|
+
});
|
|
2475
|
+
const shell = this.policy.shell;
|
|
2476
|
+
const interpreter = shell?.interpreter ?? "none";
|
|
2477
|
+
const timeoutMs = lowerOf(background.timeoutMs, opts?.timeoutMs);
|
|
2478
|
+
const env = buildCommandEnv(this.policy);
|
|
2479
|
+
return this.runtime.jobs.start({
|
|
2480
|
+
command,
|
|
2481
|
+
retainBytes: shell?.maxOutputBytes ?? DEFAULT_MAX_OUTPUT_BYTES,
|
|
2482
|
+
maxJobs: background.maxJobs ?? 4,
|
|
2483
|
+
...timeoutMs !== void 0 ? { timeoutMs } : {},
|
|
2484
|
+
launch: (onOutput) => start(command, {
|
|
2485
|
+
cwd: this.policy.cwd,
|
|
2486
|
+
env,
|
|
2487
|
+
...interpreter !== "none" ? { interpreter } : {},
|
|
2488
|
+
onOutput
|
|
2489
|
+
})
|
|
2490
|
+
});
|
|
2491
|
+
}
|
|
2492
|
+
shellOutput(id, opts) {
|
|
2493
|
+
return this.runtime.jobs.output(id, opts);
|
|
2494
|
+
}
|
|
2495
|
+
shellKill(id) {
|
|
2496
|
+
return this.runtime.jobs.kill(id);
|
|
2497
|
+
}
|
|
2498
|
+
listShells() {
|
|
2499
|
+
return this.runtime.jobs.list();
|
|
2500
|
+
}
|
|
2501
|
+
dispose() {
|
|
2502
|
+
return this.runtime.jobs.dispose();
|
|
2503
|
+
}
|
|
2504
|
+
async listDir(inputPath = ".", opts) {
|
|
2505
|
+
const { absolutePath: root, relativePath: rootRelative } = await resolveInJail(this.policy, inputPath);
|
|
2506
|
+
const { absolutePath: jailRoot } = await resolveInJail(this.policy, ".");
|
|
2507
|
+
const listPolicy = this.policy.list;
|
|
2508
|
+
const maxDepth = Math.max(1, Math.floor(listPolicy?.maxDepth ?? DEFAULT_LIST_MAX_DEPTH));
|
|
2509
|
+
const depth = Math.min(Math.max(1, Math.floor(opts?.depth ?? 1)), maxDepth);
|
|
2510
|
+
const maxEntries = Math.max(1, Math.floor(listPolicy?.maxEntries ?? DEFAULT_LIST_MAX_ENTRIES));
|
|
2511
|
+
const ignore = [...listPolicy?.ignore ?? DEFAULT_LIST_IGNORE, ...opts?.ignore ?? []];
|
|
2512
|
+
const insideCwd = isInside(root, jailRoot);
|
|
2513
|
+
if (!await this.isDirectory(root)) throw new Error(`"${inputPath}" is not a directory in the workspace.`);
|
|
2514
|
+
const entries = [];
|
|
2515
|
+
let level = [root];
|
|
2516
|
+
let truncated = false;
|
|
2517
|
+
for (let currentDepth = 1; currentDepth <= depth && level.length > 0; currentDepth++) {
|
|
2518
|
+
const nextLevel = [];
|
|
2519
|
+
for (const directory of level) {
|
|
2520
|
+
let children;
|
|
2521
|
+
try {
|
|
2522
|
+
children = await this.backend.list(directory);
|
|
2523
|
+
} catch {
|
|
2524
|
+
continue;
|
|
2525
|
+
}
|
|
2526
|
+
children.sort((left, right) => left < right ? -1 : left > right ? 1 : 0);
|
|
2527
|
+
for (const child of children) {
|
|
2528
|
+
const relativeToJail = insideCwd ? node_path.default.relative(jailRoot, child).split(node_path.default.sep).join("/") : child;
|
|
2529
|
+
if (insideCwd && (this.isDenied(relativeToJail) || matchesAnyGlob(relativeToJail, ignore))) continue;
|
|
2530
|
+
const relativeToRoot = node_path.default.relative(root, child).split(node_path.default.sep).join("/");
|
|
2531
|
+
if (!insideCwd && matchesAnyGlob(relativeToRoot, ignore)) continue;
|
|
2532
|
+
if (entries.length >= maxEntries) {
|
|
2533
|
+
truncated = true;
|
|
2534
|
+
break;
|
|
2535
|
+
}
|
|
2536
|
+
const described = await this.describe(child);
|
|
2537
|
+
entries.push({
|
|
2538
|
+
path: relativeToJail,
|
|
2539
|
+
type: described.type,
|
|
2540
|
+
...described.size !== void 0 ? { size: described.size } : {},
|
|
2541
|
+
depth: currentDepth
|
|
2542
|
+
});
|
|
2543
|
+
if (described.type === "directory") nextLevel.push(child);
|
|
2544
|
+
}
|
|
2545
|
+
if (truncated) break;
|
|
2546
|
+
}
|
|
2547
|
+
if (truncated) break;
|
|
2548
|
+
level = nextLevel;
|
|
2549
|
+
}
|
|
2550
|
+
return {
|
|
2551
|
+
path: insideCwd ? rootRelative || "." : root,
|
|
2552
|
+
entries,
|
|
2553
|
+
truncated
|
|
2554
|
+
};
|
|
2555
|
+
}
|
|
2556
|
+
/** Kind and size of a listed entry; symlinks are reported, never followed. */
|
|
2557
|
+
async describe(absPath) {
|
|
2558
|
+
if (this.backend.stat) try {
|
|
2559
|
+
const stats = await this.backend.stat(absPath);
|
|
2560
|
+
return stats.type === "file" ? {
|
|
2561
|
+
type: "file",
|
|
2562
|
+
size: stats.size
|
|
2563
|
+
} : { type: stats.type };
|
|
2564
|
+
} catch {
|
|
2565
|
+
return { type: "other" };
|
|
2566
|
+
}
|
|
2567
|
+
if (await this.isDirectory(absPath)) try {
|
|
2568
|
+
await resolveInJail(this.policy, absPath);
|
|
2569
|
+
return { type: "directory" };
|
|
2570
|
+
} catch {
|
|
2571
|
+
return { type: "other" };
|
|
2572
|
+
}
|
|
2573
|
+
return { type: "file" };
|
|
2574
|
+
}
|
|
656
2575
|
async grep(pattern, opts) {
|
|
657
2576
|
if (!isSafeGrepPattern(pattern)) throw new WorkspacePolicyError(`Grep pattern is too long or too likely to cause catastrophic regex backtracking: ${pattern}`, {
|
|
658
2577
|
type: "unsafe-pattern",
|
|
@@ -676,7 +2595,7 @@ var Ops = class {
|
|
|
676
2595
|
}
|
|
677
2596
|
const lines = content.split("\n");
|
|
678
2597
|
for (let index = 0; index < lines.length; index++) {
|
|
679
|
-
const line = lines[index];
|
|
2598
|
+
const line = lines[index] ?? "";
|
|
680
2599
|
if (line.length > MAX_GREP_LINE_SCAN_LENGTH) continue;
|
|
681
2600
|
if (regex.test(line)) {
|
|
682
2601
|
matches.push({
|
|
@@ -723,17 +2642,40 @@ var Ops = class {
|
|
|
723
2642
|
/** Whether a `/`-separated relative path hits the policy deny list. */
|
|
724
2643
|
isDenied(relativePath) {
|
|
725
2644
|
const denyPaths = this.policy.denyPaths;
|
|
726
|
-
|
|
727
|
-
return denyPaths.some((glob) => {
|
|
728
|
-
if (globToRegExp(glob).test(relativePath)) return true;
|
|
729
|
-
if (!glob.includes("*") && !glob.includes("?")) {
|
|
730
|
-
const prefix = glob.endsWith("/") ? glob : `${glob}/`;
|
|
731
|
-
return relativePath.startsWith(prefix);
|
|
732
|
-
}
|
|
733
|
-
return false;
|
|
734
|
-
});
|
|
2645
|
+
return denyPaths !== void 0 && denyPaths.length > 0 && matchesAnyGlob(relativePath, denyPaths);
|
|
735
2646
|
}
|
|
736
2647
|
};
|
|
2648
|
+
/** The lower of two optional limits (either may be absent). */
|
|
2649
|
+
function lowerOf(policyLimit, requested) {
|
|
2650
|
+
if (requested === void 0 || requested <= 0) return policyLimit;
|
|
2651
|
+
return policyLimit === void 0 ? requested : Math.min(policyLimit, requested);
|
|
2652
|
+
}
|
|
2653
|
+
/**
|
|
2654
|
+
* Apply one exact-string replacement to `content` under the edit rules:
|
|
2655
|
+
* `oldString` must occur, and exactly once unless `replaceAll`.
|
|
2656
|
+
*/
|
|
2657
|
+
function applyReplacement(content, edit, inputPath, shownPath, editIndex) {
|
|
2658
|
+
const occurrences = countOccurrences(content, edit.oldString);
|
|
2659
|
+
const which = editIndex === void 0 ? "" : `Edit ${editIndex + 1}: `;
|
|
2660
|
+
const nothingApplied = editIndex === void 0 ? "" : " None of the edits were applied.";
|
|
2661
|
+
const indexOption = editIndex === void 0 ? {} : { editIndex };
|
|
2662
|
+
if (occurrences === 0) throw new WorkspaceEditError(`${which}The text to replace was not found in "${inputPath}".${nothingApplied}`, {
|
|
2663
|
+
type: "not-found",
|
|
2664
|
+
path: shownPath,
|
|
2665
|
+
matches: 0,
|
|
2666
|
+
...indexOption
|
|
2667
|
+
});
|
|
2668
|
+
if (occurrences > 1 && !edit.replaceAll) throw new WorkspaceEditError(`${which}The text to replace is not unique in "${inputPath}" (${occurrences} matches); pass replaceAll or include more surrounding context.${nothingApplied}`, {
|
|
2669
|
+
type: "not-unique",
|
|
2670
|
+
path: shownPath,
|
|
2671
|
+
matches: occurrences,
|
|
2672
|
+
...indexOption
|
|
2673
|
+
});
|
|
2674
|
+
return {
|
|
2675
|
+
content: edit.replaceAll ? content.split(edit.oldString).join(edit.newString) : replaceFirst(content, edit.oldString, edit.newString),
|
|
2676
|
+
replacements: edit.replaceAll ? occurrences : 1
|
|
2677
|
+
};
|
|
2678
|
+
}
|
|
737
2679
|
/** Count non-overlapping occurrences of `needle` in `haystack`. */
|
|
738
2680
|
function countOccurrences(haystack, needle) {
|
|
739
2681
|
if (needle === "") return 0;
|
|
@@ -765,13 +2707,15 @@ function replaceFirst(haystack, needle, replacement) {
|
|
|
765
2707
|
*
|
|
766
2708
|
* @param backend - The dumb IO executor (local disk or in-memory mock).
|
|
767
2709
|
* @param policy - The policy that bounds every operation.
|
|
2710
|
+
* @param runtime - State shared with other views of the same workspace
|
|
2711
|
+
* (its background commands); a fresh one when omitted.
|
|
768
2712
|
*
|
|
769
2713
|
* @example
|
|
770
2714
|
* const ops = createOps(localBackend, { cwd: "/srv/api", shell: { allow: ["npm"] } });
|
|
771
2715
|
* const { content, hash } = await ops.readFile("src/index.ts");
|
|
772
2716
|
*/
|
|
773
|
-
function createOps(backend, policy) {
|
|
774
|
-
return new Ops(backend, policy);
|
|
2717
|
+
function createOps(backend, policy, runtime) {
|
|
2718
|
+
return new Ops(backend, policy, runtime ?? { jobs: new ShellJobs() });
|
|
775
2719
|
}
|
|
776
2720
|
|
|
777
2721
|
//#endregion
|
|
@@ -866,6 +2810,74 @@ function toWin32CmdInvocation(argv) {
|
|
|
866
2810
|
]
|
|
867
2811
|
};
|
|
868
2812
|
}
|
|
2813
|
+
/** The shell a POSIX shell-mode line runs through — an absolute path, never a PATH lookup. */
|
|
2814
|
+
const POSIX_SHELL = "/bin/sh";
|
|
2815
|
+
/**
|
|
2816
|
+
* Work out the spawn for a command line.
|
|
2817
|
+
*
|
|
2818
|
+
* - argv mode: tokenize (no shell semantics) and spawn the argv directly —
|
|
2819
|
+
* on Windows through an individually-quoted `cmd.exe` wrapper.
|
|
2820
|
+
* - shell mode: `/bin/sh -c <line>`, or `cmd.exe /d /v:off /s /c "<line>"`
|
|
2821
|
+
* on Windows (`/d` skips AutoRun, `/v:off` disables delayed `!` expansion,
|
|
2822
|
+
* `/s` keeps the line verbatim inside the outer quotes). The ops layer has
|
|
2823
|
+
* already checked every command in the line.
|
|
2824
|
+
*/
|
|
2825
|
+
function planSpawn(command, interpreter) {
|
|
2826
|
+
if (interpreter === "sh") {
|
|
2827
|
+
if (node_process.platform === "win32") return {
|
|
2828
|
+
file: process.env.ComSpec ?? "cmd.exe",
|
|
2829
|
+
args: [
|
|
2830
|
+
"/d",
|
|
2831
|
+
"/v:off",
|
|
2832
|
+
"/s",
|
|
2833
|
+
"/c",
|
|
2834
|
+
`"${command}"`
|
|
2835
|
+
],
|
|
2836
|
+
windowsVerbatimArguments: true
|
|
2837
|
+
};
|
|
2838
|
+
return {
|
|
2839
|
+
file: POSIX_SHELL,
|
|
2840
|
+
args: ["-c", command],
|
|
2841
|
+
windowsVerbatimArguments: false
|
|
2842
|
+
};
|
|
2843
|
+
}
|
|
2844
|
+
const argv = tokenizeCommand(command);
|
|
2845
|
+
const executable = argv?.[0];
|
|
2846
|
+
if (argv === null || executable === void 0) return { refused: "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." };
|
|
2847
|
+
if (node_process.platform === "win32") {
|
|
2848
|
+
const invocation = toWin32CmdInvocation(argv);
|
|
2849
|
+
if (invocation === null) return { refused: "Command was not executed: on Windows, arguments containing \", %, or newlines cannot be passed to cmd.exe safely." };
|
|
2850
|
+
return {
|
|
2851
|
+
...invocation,
|
|
2852
|
+
windowsVerbatimArguments: true
|
|
2853
|
+
};
|
|
2854
|
+
}
|
|
2855
|
+
return {
|
|
2856
|
+
file: executable,
|
|
2857
|
+
args: argv.slice(1),
|
|
2858
|
+
windowsVerbatimArguments: false
|
|
2859
|
+
};
|
|
2860
|
+
}
|
|
2861
|
+
/**
|
|
2862
|
+
* Kill functions of every background process still running, so they can
|
|
2863
|
+
* be reaped if the Node process exits without the workspace being disposed
|
|
2864
|
+
* (detached process groups would otherwise outlive it).
|
|
2865
|
+
*/
|
|
2866
|
+
const liveBackgroundKills = /* @__PURE__ */ new Set();
|
|
2867
|
+
let exitHookInstalled = false;
|
|
2868
|
+
/** Track a live background tree; installs (once) the exit hook that kills them. */
|
|
2869
|
+
function trackBackgroundProcess(kill) {
|
|
2870
|
+
liveBackgroundKills.add(kill);
|
|
2871
|
+
if (!exitHookInstalled) {
|
|
2872
|
+
exitHookInstalled = true;
|
|
2873
|
+
process.on("exit", () => {
|
|
2874
|
+
for (const killTreeNow of liveBackgroundKills) killTreeNow();
|
|
2875
|
+
});
|
|
2876
|
+
}
|
|
2877
|
+
return () => {
|
|
2878
|
+
liveBackgroundKills.delete(kill);
|
|
2879
|
+
};
|
|
2880
|
+
}
|
|
869
2881
|
/**
|
|
870
2882
|
* The real-disk executor: every filesystem method delegates to
|
|
871
2883
|
* `@warlock.js/fs` (never `node:fs`), and {@link LocalBackend.exec} spawns a
|
|
@@ -922,6 +2934,17 @@ var LocalBackend = class {
|
|
|
922
2934
|
return _warlock_js_fs.fs.dirs.list(absDir);
|
|
923
2935
|
}
|
|
924
2936
|
/**
|
|
2937
|
+
* Describe a path without following a final symlink (`lstat` — like
|
|
2938
|
+
* `realpath`, a filesystem operation `@warlock.js/fs` does not expose).
|
|
2939
|
+
*/
|
|
2940
|
+
async stat(absPath) {
|
|
2941
|
+
const stats = await (0, node_fs_promises.lstat)(absPath);
|
|
2942
|
+
return {
|
|
2943
|
+
type: stats.isSymbolicLink() ? "symlink" : stats.isDirectory() ? "directory" : stats.isFile() ? "file" : "other",
|
|
2944
|
+
size: stats.size
|
|
2945
|
+
};
|
|
2946
|
+
}
|
|
2947
|
+
/**
|
|
925
2948
|
* Resolve symlinks and `..` segments to a canonical absolute path — the
|
|
926
2949
|
* primitive the ops-layer jail uses to detect escapes. Delegates to
|
|
927
2950
|
* `node:fs/promises` `realpath`, the one filesystem operation `@warlock.js/fs`
|
|
@@ -931,14 +2954,17 @@ var LocalBackend = class {
|
|
|
931
2954
|
return (0, node_fs_promises.realpath)(absPath);
|
|
932
2955
|
}
|
|
933
2956
|
/**
|
|
934
|
-
* Run a command and capture its outcome.
|
|
935
|
-
* into an argv (quotes respected, NO shell
|
|
936
|
-
* `tokenizeCommand`) and spawned **without a shell**, so
|
|
937
|
-
* like `;`, `&&`, `|`, backticks, and `$()` can never
|
|
938
|
-
* commands past the ops layer's allowlist; a command they
|
|
939
|
-
* unquoted in is refused with exit code 127. On Windows the argv
|
|
940
|
-
* through a `cmd.exe /d /s /c` wrapper (batch shims like `npm.cmd`
|
|
2957
|
+
* Run a command and capture its outcome. In argv mode (the default) the
|
|
2958
|
+
* command line is tokenized into an argv (quotes respected, NO shell
|
|
2959
|
+
* semantics — see `tokenizeCommand`) and spawned **without a shell**, so
|
|
2960
|
+
* metacharacters like `;`, `&&`, `|`, backticks, and `$()` can never
|
|
2961
|
+
* chain extra commands past the ops layer's allowlist; a command they
|
|
2962
|
+
* appear unquoted in is refused with exit code 127. On Windows the argv
|
|
2963
|
+
* runs through a `cmd.exe /d /s /c` wrapper (batch shims like `npm.cmd`
|
|
941
2964
|
* cannot be spawned shell-less) with every element individually quoted.
|
|
2965
|
+
* With `interpreter: "sh"` the line runs through `/bin/sh -c` (or
|
|
2966
|
+
* `cmd.exe /d /v:off /s /c` on Windows) — only after the ops layer has
|
|
2967
|
+
* checked every command in it.
|
|
942
2968
|
* `cwd`, `env`, and the timeout are taken verbatim from the ops layer
|
|
943
2969
|
* (the environment is NOT merged with `process.env`). On timeout the
|
|
944
2970
|
* process tree is SIGKILLed and `timedOut` is set. When `signal` aborts
|
|
@@ -970,31 +2996,17 @@ var LocalBackend = class {
|
|
|
970
2996
|
});
|
|
971
2997
|
return;
|
|
972
2998
|
}
|
|
973
|
-
const
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
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.");
|
|
2999
|
+
const plan = planSpawn(command, opts.interpreter ?? "none");
|
|
3000
|
+
if ("refused" in plan) {
|
|
3001
|
+
refuse(plan.refused);
|
|
977
3002
|
return;
|
|
978
3003
|
}
|
|
979
|
-
|
|
980
|
-
let args = argv.slice(1);
|
|
981
|
-
let windowsVerbatimArguments = false;
|
|
982
|
-
if (node_process.platform === "win32") {
|
|
983
|
-
const invocation = toWin32CmdInvocation(argv);
|
|
984
|
-
if (invocation === null) {
|
|
985
|
-
refuse("Command was not executed: on Windows, arguments containing \", %, or newlines cannot be passed to cmd.exe safely.");
|
|
986
|
-
return;
|
|
987
|
-
}
|
|
988
|
-
file = invocation.file;
|
|
989
|
-
args = invocation.args;
|
|
990
|
-
windowsVerbatimArguments = true;
|
|
991
|
-
}
|
|
992
|
-
const child = (0, node_child_process.spawn)(file, args, {
|
|
3004
|
+
const child = (0, node_child_process.spawn)(plan.file, plan.args, {
|
|
993
3005
|
cwd: opts.cwd,
|
|
994
3006
|
env: opts.env,
|
|
995
3007
|
shell: false,
|
|
996
3008
|
windowsHide: true,
|
|
997
|
-
windowsVerbatimArguments,
|
|
3009
|
+
windowsVerbatimArguments: plan.windowsVerbatimArguments,
|
|
998
3010
|
detached: node_process.platform !== "win32"
|
|
999
3011
|
});
|
|
1000
3012
|
const stdoutChunks = [];
|
|
@@ -1050,6 +3062,75 @@ var LocalBackend = class {
|
|
|
1050
3062
|
});
|
|
1051
3063
|
});
|
|
1052
3064
|
}
|
|
3065
|
+
/**
|
|
3066
|
+
* Start a long-running command and return at once. Spawned like
|
|
3067
|
+
* {@link exec} (same argv/shell planning, own process group on POSIX), with
|
|
3068
|
+
* stdin closed. Output is decoded per stream with a `StringDecoder`, so a
|
|
3069
|
+
* chunk never ends inside a character. `kill()` kills the whole tree; a
|
|
3070
|
+
* still-running tree is also killed when the Node process exits.
|
|
3071
|
+
*/
|
|
3072
|
+
start(command, opts) {
|
|
3073
|
+
const plan = planSpawn(command, opts.interpreter ?? "none");
|
|
3074
|
+
if ("refused" in plan) {
|
|
3075
|
+
opts.onOutput("stderr", plan.refused);
|
|
3076
|
+
return {
|
|
3077
|
+
done: Promise.resolve({ exitCode: 127 }),
|
|
3078
|
+
kill: () => {}
|
|
3079
|
+
};
|
|
3080
|
+
}
|
|
3081
|
+
const child = (0, node_child_process.spawn)(plan.file, plan.args, {
|
|
3082
|
+
cwd: opts.cwd,
|
|
3083
|
+
env: opts.env,
|
|
3084
|
+
shell: false,
|
|
3085
|
+
windowsHide: true,
|
|
3086
|
+
windowsVerbatimArguments: plan.windowsVerbatimArguments,
|
|
3087
|
+
detached: node_process.platform !== "win32",
|
|
3088
|
+
stdio: [
|
|
3089
|
+
"ignore",
|
|
3090
|
+
"pipe",
|
|
3091
|
+
"pipe"
|
|
3092
|
+
]
|
|
3093
|
+
});
|
|
3094
|
+
const stdoutDecoder = new node_string_decoder.StringDecoder("utf8");
|
|
3095
|
+
const stderrDecoder = new node_string_decoder.StringDecoder("utf8");
|
|
3096
|
+
let finished = false;
|
|
3097
|
+
const kill = () => {
|
|
3098
|
+
if (!finished) killTree(child.pid, child);
|
|
3099
|
+
};
|
|
3100
|
+
const untrack = trackBackgroundProcess(kill);
|
|
3101
|
+
child.stdout?.on("data", (chunk) => {
|
|
3102
|
+
const text = stdoutDecoder.write(chunk);
|
|
3103
|
+
if (text !== "") opts.onOutput("stdout", text);
|
|
3104
|
+
});
|
|
3105
|
+
child.stderr?.on("data", (chunk) => {
|
|
3106
|
+
const text = stderrDecoder.write(chunk);
|
|
3107
|
+
if (text !== "") opts.onOutput("stderr", text);
|
|
3108
|
+
});
|
|
3109
|
+
const done = new Promise((resolve) => {
|
|
3110
|
+
const finish = (exitCode) => {
|
|
3111
|
+
if (finished) return;
|
|
3112
|
+
finished = true;
|
|
3113
|
+
untrack();
|
|
3114
|
+
const stdoutTail = stdoutDecoder.end();
|
|
3115
|
+
const stderrTail = stderrDecoder.end();
|
|
3116
|
+
if (stdoutTail !== "") opts.onOutput("stdout", stdoutTail);
|
|
3117
|
+
if (stderrTail !== "") opts.onOutput("stderr", stderrTail);
|
|
3118
|
+
resolve({ exitCode });
|
|
3119
|
+
};
|
|
3120
|
+
child.on("error", (error) => {
|
|
3121
|
+
opts.onOutput("stderr", `Command could not be started: ${error.message}`);
|
|
3122
|
+
finish(127);
|
|
3123
|
+
});
|
|
3124
|
+
child.on("close", (code, signal) => {
|
|
3125
|
+
finish(code === null ? 128 + (signal === "SIGKILL" ? 9 : 1) : code);
|
|
3126
|
+
});
|
|
3127
|
+
});
|
|
3128
|
+
return {
|
|
3129
|
+
...child.pid !== void 0 ? { pid: child.pid } : {},
|
|
3130
|
+
done,
|
|
3131
|
+
kill
|
|
3132
|
+
};
|
|
3133
|
+
}
|
|
1053
3134
|
};
|
|
1054
3135
|
/**
|
|
1055
3136
|
* Create the **local** workspace backend — the default executor that runs the
|
|
@@ -1171,6 +3252,40 @@ var MockBackend = class {
|
|
|
1171
3252
|
async realpath(absPath) {
|
|
1172
3253
|
return canonicalize(absPath);
|
|
1173
3254
|
}
|
|
3255
|
+
async stat(absPath) {
|
|
3256
|
+
const canonical = canonicalize(absPath);
|
|
3257
|
+
const content = this.files.get(canonical);
|
|
3258
|
+
if (content !== void 0) return {
|
|
3259
|
+
type: "file",
|
|
3260
|
+
size: Buffer.byteLength(content, "utf8")
|
|
3261
|
+
};
|
|
3262
|
+
if (this.directories.has(canonical)) return {
|
|
3263
|
+
type: "directory",
|
|
3264
|
+
size: 0
|
|
3265
|
+
};
|
|
3266
|
+
throw new Error(`Mock backend: no such file or directory: ${canonical}`);
|
|
3267
|
+
}
|
|
3268
|
+
/**
|
|
3269
|
+
* Scripted background start: emits the registered `stdout` / `stderr`
|
|
3270
|
+
* synchronously, then exits with `exitCode` — or, when scripted with
|
|
3271
|
+
* `running: true`, stays running until `kill()` (exit code 137).
|
|
3272
|
+
*/
|
|
3273
|
+
start(command, opts) {
|
|
3274
|
+
const scripted = this.commands.get(command);
|
|
3275
|
+
if (scripted?.stdout) opts.onOutput("stdout", scripted.stdout);
|
|
3276
|
+
if (scripted?.stderr) opts.onOutput("stderr", scripted.stderr);
|
|
3277
|
+
if (scripted?.running !== true) return {
|
|
3278
|
+
done: Promise.resolve({ exitCode: scripted?.exitCode ?? 0 }),
|
|
3279
|
+
kill: () => {}
|
|
3280
|
+
};
|
|
3281
|
+
let stop = () => {};
|
|
3282
|
+
return {
|
|
3283
|
+
done: new Promise((resolve) => {
|
|
3284
|
+
stop = resolve;
|
|
3285
|
+
}),
|
|
3286
|
+
kill: () => stop({ exitCode: 137 })
|
|
3287
|
+
};
|
|
3288
|
+
}
|
|
1174
3289
|
async exec(command, opts) {
|
|
1175
3290
|
if (opts?.signal?.aborted) return {
|
|
1176
3291
|
exitCode: 130,
|
|
@@ -1261,6 +3376,10 @@ function isMockBackendSeed(seed) {
|
|
|
1261
3376
|
*/
|
|
1262
3377
|
/** The vendor tag stamped on every issue these builders produce. */
|
|
1263
3378
|
const VENDOR = "ai-workspace";
|
|
3379
|
+
/** Attach a JSON Schema description to a hand-rolled Standard Schema. */
|
|
3380
|
+
function withJsonSchema(schema, jsonSchema) {
|
|
3381
|
+
return Object.assign(schema, { jsonSchema });
|
|
3382
|
+
}
|
|
1264
3383
|
/** Required string field — rejects anything that is not a string. */
|
|
1265
3384
|
function stringField() {
|
|
1266
3385
|
return (value, key) => {
|
|
@@ -1311,24 +3430,25 @@ function optionalBooleanField() {
|
|
|
1311
3430
|
};
|
|
1312
3431
|
}
|
|
1313
3432
|
/**
|
|
1314
|
-
*
|
|
1315
|
-
*
|
|
1316
|
-
* object; each declared field is validated and the (possibly coerced)
|
|
1317
|
-
* values are collected into the typed result. All field issues are merged
|
|
1318
|
-
* so the caller sees every problem at once.
|
|
1319
|
-
*
|
|
1320
|
-
* `T` is constrained to `object` rather than `Record<string, unknown>` so
|
|
1321
|
-
* the tool IO `interface`s (which carry no implicit string index
|
|
1322
|
-
* signature) satisfy it directly — only the declared keys in `shape` are
|
|
1323
|
-
* ever read, so a string index signature is never required.
|
|
1324
|
-
*
|
|
1325
|
-
* @example
|
|
1326
|
-
* const schema = objectSchema<{ path: string; limit?: number }>({
|
|
1327
|
-
* path: stringField(),
|
|
1328
|
-
* limit: optionalNumberField(),
|
|
1329
|
-
* });
|
|
3433
|
+
* Optional array-of-strings field — accepts `undefined` or an array whose
|
|
3434
|
+
* every element is a string (at most `maxItems` of them).
|
|
1330
3435
|
*/
|
|
1331
|
-
function
|
|
3436
|
+
function optionalStringArrayField(maxItems = 50) {
|
|
3437
|
+
return (value, key) => {
|
|
3438
|
+
if (value === void 0) return { value: void 0 };
|
|
3439
|
+
if (Array.isArray(value) && value.length <= maxItems && value.every((item) => typeof item === "string")) return { value };
|
|
3440
|
+
return { issues: [{
|
|
3441
|
+
message: `"${key}" must be an array of at most ${maxItems} strings when provided`,
|
|
3442
|
+
path: [key]
|
|
3443
|
+
}] };
|
|
3444
|
+
};
|
|
3445
|
+
}
|
|
3446
|
+
function objectSchema(shape, jsonSchema) {
|
|
3447
|
+
const schema = buildObjectSchema(shape);
|
|
3448
|
+
return jsonSchema === void 0 ? schema : withJsonSchema(schema, jsonSchema);
|
|
3449
|
+
}
|
|
3450
|
+
/** The validator behind {@link objectSchema}. */
|
|
3451
|
+
function buildObjectSchema(shape) {
|
|
1332
3452
|
return { "~standard": {
|
|
1333
3453
|
version: 1,
|
|
1334
3454
|
vendor: VENDOR,
|
|
@@ -1352,18 +3472,191 @@ function objectSchema(shape) {
|
|
|
1352
3472
|
} };
|
|
1353
3473
|
}
|
|
1354
3474
|
|
|
3475
|
+
//#endregion
|
|
3476
|
+
//#region ../../packages/ai-workspace/src/tools/json-schemas.ts
|
|
3477
|
+
/**
|
|
3478
|
+
* The JSON Schemas the workspace tools publish for their input (read by
|
|
3479
|
+
* `extractJsonSchema` and sent to the provider with each tool), kept short
|
|
3480
|
+
* because they ride along on every model call. They describe what the
|
|
3481
|
+
* hand-rolled validators accept; the validators remain the authority.
|
|
3482
|
+
*/
|
|
3483
|
+
const string = (description) => ({
|
|
3484
|
+
type: "string",
|
|
3485
|
+
description
|
|
3486
|
+
});
|
|
3487
|
+
const integer = (description) => ({
|
|
3488
|
+
type: "integer",
|
|
3489
|
+
description
|
|
3490
|
+
});
|
|
3491
|
+
const boolean = (description) => ({
|
|
3492
|
+
type: "boolean",
|
|
3493
|
+
description
|
|
3494
|
+
});
|
|
3495
|
+
/** `read_file`. */
|
|
3496
|
+
const READ_FILE_JSON_SCHEMA = {
|
|
3497
|
+
type: "object",
|
|
3498
|
+
properties: {
|
|
3499
|
+
path: string("Workspace-relative file path."),
|
|
3500
|
+
startLine: integer("1-based first line to return."),
|
|
3501
|
+
limit: integer("Most lines to return.")
|
|
3502
|
+
},
|
|
3503
|
+
required: ["path"]
|
|
3504
|
+
};
|
|
3505
|
+
/** `edit_file`. */
|
|
3506
|
+
const EDIT_FILE_JSON_SCHEMA = {
|
|
3507
|
+
type: "object",
|
|
3508
|
+
properties: {
|
|
3509
|
+
path: string("Workspace-relative file path."),
|
|
3510
|
+
oldString: string("Exact text to replace; must be unique unless replaceAll."),
|
|
3511
|
+
newString: string("Replacement text."),
|
|
3512
|
+
replaceAll: boolean("Replace every occurrence."),
|
|
3513
|
+
expectHash: string("Hash from read_file; rejects the edit if the file changed.")
|
|
3514
|
+
},
|
|
3515
|
+
required: [
|
|
3516
|
+
"path",
|
|
3517
|
+
"oldString",
|
|
3518
|
+
"newString"
|
|
3519
|
+
]
|
|
3520
|
+
};
|
|
3521
|
+
/** `multi_edit`. */
|
|
3522
|
+
const MULTI_EDIT_JSON_SCHEMA = {
|
|
3523
|
+
type: "object",
|
|
3524
|
+
properties: {
|
|
3525
|
+
path: string("Workspace-relative file path."),
|
|
3526
|
+
edits: {
|
|
3527
|
+
type: "array",
|
|
3528
|
+
description: "Replacements applied in order, each to the result of the previous; all or none.",
|
|
3529
|
+
minItems: 1,
|
|
3530
|
+
maxItems: 100,
|
|
3531
|
+
items: {
|
|
3532
|
+
type: "object",
|
|
3533
|
+
properties: {
|
|
3534
|
+
oldString: string("Exact text to replace; must be unique unless replaceAll."),
|
|
3535
|
+
newString: string("Replacement text."),
|
|
3536
|
+
replaceAll: boolean("Replace every occurrence.")
|
|
3537
|
+
},
|
|
3538
|
+
required: ["oldString", "newString"]
|
|
3539
|
+
}
|
|
3540
|
+
},
|
|
3541
|
+
expectHash: string("Hash from read_file; rejects every edit if the file changed.")
|
|
3542
|
+
},
|
|
3543
|
+
required: ["path", "edits"]
|
|
3544
|
+
};
|
|
3545
|
+
/** `write_file`. */
|
|
3546
|
+
const WRITE_FILE_JSON_SCHEMA = {
|
|
3547
|
+
type: "object",
|
|
3548
|
+
properties: {
|
|
3549
|
+
path: string("Workspace-relative file path."),
|
|
3550
|
+
content: string("Full file content.")
|
|
3551
|
+
},
|
|
3552
|
+
required: ["path", "content"]
|
|
3553
|
+
};
|
|
3554
|
+
/** `list_dir`. */
|
|
3555
|
+
const LIST_DIR_JSON_SCHEMA = {
|
|
3556
|
+
type: "object",
|
|
3557
|
+
properties: {
|
|
3558
|
+
path: string("Workspace-relative directory (default \".\")."),
|
|
3559
|
+
depth: integer("Levels to list; 1 = direct children (default)."),
|
|
3560
|
+
ignore: {
|
|
3561
|
+
type: "array",
|
|
3562
|
+
items: { type: "string" },
|
|
3563
|
+
description: "Extra globs to leave out."
|
|
3564
|
+
}
|
|
3565
|
+
}
|
|
3566
|
+
};
|
|
3567
|
+
/** `run_shell` (the `background` property is only listed when background commands are enabled). */
|
|
3568
|
+
function runShellJsonSchema(background) {
|
|
3569
|
+
return {
|
|
3570
|
+
type: "object",
|
|
3571
|
+
properties: {
|
|
3572
|
+
command: string("The command line to run."),
|
|
3573
|
+
timeoutMs: integer("Wall-clock cap in ms (can only lower the workspace limit)."),
|
|
3574
|
+
...background ? { background: boolean("Start it in the background and return its id at once (dev servers, watchers).") } : {}
|
|
3575
|
+
},
|
|
3576
|
+
required: ["command"]
|
|
3577
|
+
};
|
|
3578
|
+
}
|
|
3579
|
+
/** `shell_output`. */
|
|
3580
|
+
const SHELL_OUTPUT_JSON_SCHEMA = {
|
|
3581
|
+
type: "object",
|
|
3582
|
+
properties: {
|
|
3583
|
+
id: string("Background command id from run_shell."),
|
|
3584
|
+
since: integer("A cursor from an earlier result; omit for output since the last read."),
|
|
3585
|
+
waitMs: integer("Wait up to this long (max 30000) for new output.")
|
|
3586
|
+
},
|
|
3587
|
+
required: ["id"]
|
|
3588
|
+
};
|
|
3589
|
+
/** `shell_kill`. */
|
|
3590
|
+
const SHELL_KILL_JSON_SCHEMA = {
|
|
3591
|
+
type: "object",
|
|
3592
|
+
properties: { id: string("Background command id from run_shell.") },
|
|
3593
|
+
required: ["id"]
|
|
3594
|
+
};
|
|
3595
|
+
/** `run_tests`. */
|
|
3596
|
+
const RUN_TESTS_JSON_SCHEMA = {
|
|
3597
|
+
type: "object",
|
|
3598
|
+
properties: { pattern: string("Optional test path or name filter.") }
|
|
3599
|
+
};
|
|
3600
|
+
/** `grep`. */
|
|
3601
|
+
const GREP_JSON_SCHEMA = {
|
|
3602
|
+
type: "object",
|
|
3603
|
+
properties: {
|
|
3604
|
+
pattern: string("Regular expression to search for."),
|
|
3605
|
+
glob: string("Only search files matching this glob (e.g. \"src/**/*.ts\")."),
|
|
3606
|
+
ignoreCase: boolean("Case-insensitive match.")
|
|
3607
|
+
},
|
|
3608
|
+
required: ["pattern"]
|
|
3609
|
+
};
|
|
3610
|
+
/** `glob`. */
|
|
3611
|
+
const GLOB_JSON_SCHEMA = {
|
|
3612
|
+
type: "object",
|
|
3613
|
+
properties: { pattern: string("Glob such as \"src/**/*.ts\".") },
|
|
3614
|
+
required: ["pattern"]
|
|
3615
|
+
};
|
|
3616
|
+
/** `todo_write`. */
|
|
3617
|
+
const TODO_WRITE_JSON_SCHEMA = {
|
|
3618
|
+
type: "object",
|
|
3619
|
+
properties: { todos: {
|
|
3620
|
+
type: "array",
|
|
3621
|
+
description: "The complete updated list (replaces the old one).",
|
|
3622
|
+
maxItems: 100,
|
|
3623
|
+
items: {
|
|
3624
|
+
type: "object",
|
|
3625
|
+
properties: {
|
|
3626
|
+
id: string("Stable id; omit to number items by position."),
|
|
3627
|
+
content: string("The step."),
|
|
3628
|
+
status: {
|
|
3629
|
+
type: "string",
|
|
3630
|
+
enum: [
|
|
3631
|
+
"pending",
|
|
3632
|
+
"in_progress",
|
|
3633
|
+
"completed"
|
|
3634
|
+
]
|
|
3635
|
+
}
|
|
3636
|
+
},
|
|
3637
|
+
required: ["content", "status"]
|
|
3638
|
+
}
|
|
3639
|
+
} },
|
|
3640
|
+
required: ["todos"]
|
|
3641
|
+
};
|
|
3642
|
+
/** `todo_read`. */
|
|
3643
|
+
const TODO_READ_JSON_SCHEMA = {
|
|
3644
|
+
type: "object",
|
|
3645
|
+
properties: {}
|
|
3646
|
+
};
|
|
3647
|
+
|
|
1355
3648
|
//#endregion
|
|
1356
3649
|
//#region ../../packages/ai-workspace/src/tools/edit-file.ts
|
|
1357
3650
|
/** Default tool name exposed to the LLM. */
|
|
1358
|
-
const DEFAULT_NAME$
|
|
3651
|
+
const DEFAULT_NAME$6 = "edit_file";
|
|
1359
3652
|
/** Input schema for the `edit_file` tool. */
|
|
1360
|
-
const inputSchema$
|
|
3653
|
+
const inputSchema$6 = objectSchema({
|
|
1361
3654
|
path: stringField(),
|
|
1362
3655
|
oldString: stringField(),
|
|
1363
3656
|
newString: stringField(),
|
|
1364
3657
|
replaceAll: optionalBooleanField(),
|
|
1365
3658
|
expectHash: optionalStringField()
|
|
1366
|
-
});
|
|
3659
|
+
}, EDIT_FILE_JSON_SCHEMA);
|
|
1367
3660
|
/**
|
|
1368
3661
|
* Build the agent-facing `edit_file` tool over a workspace's policy-
|
|
1369
3662
|
* enforced {@link WorkspaceOps}.
|
|
@@ -1395,9 +3688,9 @@ const inputSchema$2 = objectSchema({
|
|
|
1395
3688
|
*/
|
|
1396
3689
|
function makeEditFileTool(ops, options) {
|
|
1397
3690
|
return (0, _warlock_js_ai.tool)({
|
|
1398
|
-
name: options?.name ?? DEFAULT_NAME$
|
|
3691
|
+
name: options?.name ?? DEFAULT_NAME$6,
|
|
1399
3692
|
description: "Replace an exact substring in a workspace file. oldString must match uniquely unless replaceAll is set. Pass expectHash (from read_file) to reject the edit if the file changed since you read it. Returns the number of replacements and the new content hash.",
|
|
1400
|
-
input: inputSchema$
|
|
3693
|
+
input: inputSchema$6,
|
|
1401
3694
|
async execute(input) {
|
|
1402
3695
|
return ops.editFile(input);
|
|
1403
3696
|
}
|
|
@@ -1412,7 +3705,7 @@ function makeEditFileTool(ops, options) {
|
|
|
1412
3705
|
* (no schema library, matching the validator idiom the `@warlock.js/ai`
|
|
1413
3706
|
* tool runtime expects).
|
|
1414
3707
|
*/
|
|
1415
|
-
const globInputSchema = objectSchema({ pattern: stringField() });
|
|
3708
|
+
const globInputSchema = objectSchema({ pattern: stringField() }, GLOB_JSON_SCHEMA);
|
|
1416
3709
|
/**
|
|
1417
3710
|
* Build the agent-facing `glob` tool — resolve a glob pattern to the
|
|
1418
3711
|
* matching workspace-relative paths within the jail. The returned
|
|
@@ -1456,7 +3749,7 @@ const grepInputSchema = objectSchema({
|
|
|
1456
3749
|
pattern: stringField(),
|
|
1457
3750
|
glob: optionalStringField(),
|
|
1458
3751
|
ignoreCase: optionalBooleanField()
|
|
1459
|
-
});
|
|
3752
|
+
}, GREP_JSON_SCHEMA);
|
|
1460
3753
|
/**
|
|
1461
3754
|
* Build the agent-facing `grep` tool — a regex content search across the
|
|
1462
3755
|
* jailed file set. The returned {@link ToolContract} validates the LLM's
|
|
@@ -1490,16 +3783,174 @@ function makeGrepTool(ops, options) {
|
|
|
1490
3783
|
});
|
|
1491
3784
|
}
|
|
1492
3785
|
|
|
3786
|
+
//#endregion
|
|
3787
|
+
//#region ../../packages/ai-workspace/src/tools/list-dir.ts
|
|
3788
|
+
/** Default tool name exposed to the LLM. */
|
|
3789
|
+
const DEFAULT_NAME$5 = "list_dir";
|
|
3790
|
+
/** Input schema for the `list_dir` tool. */
|
|
3791
|
+
const inputSchema$5 = objectSchema({
|
|
3792
|
+
path: optionalStringField(),
|
|
3793
|
+
depth: optionalNumberField(),
|
|
3794
|
+
ignore: optionalStringArrayField()
|
|
3795
|
+
}, LIST_DIR_JSON_SCHEMA);
|
|
3796
|
+
/**
|
|
3797
|
+
* Build the agent-facing `list_dir` tool over a workspace's policy-enforced
|
|
3798
|
+
* {@link WorkspaceOps}: a bounded, jailed listing of one directory.
|
|
3799
|
+
*
|
|
3800
|
+
* Entries come back breadth-first (each level sorted by name) with their
|
|
3801
|
+
* type and — for files — size, down to `depth` levels (capped by
|
|
3802
|
+
* `list.maxDepth`) and at most `list.maxEntries` entries (`truncated` says
|
|
3803
|
+
* when the cap hit). `denyPaths` are always left out, as are the
|
|
3804
|
+
* `list.ignore` globs (default `node_modules`, `.git`) and any `ignore`
|
|
3805
|
+
* globs the model passes. Symlinks are reported as `"symlink"` and never
|
|
3806
|
+
* followed. A path outside the jail surfaces as a `WorkspacePolicyError` in
|
|
3807
|
+
* the result's `error` field.
|
|
3808
|
+
*
|
|
3809
|
+
* @param ops - The shared, policy-enforced operation layer.
|
|
3810
|
+
* @param options - Optional overrides; `name` renames the LLM-visible tool.
|
|
3811
|
+
*
|
|
3812
|
+
* @example
|
|
3813
|
+
* const listDir = makeListDirTool(ops);
|
|
3814
|
+
* const { data } = await listDir.invoke({ path: "src", depth: 2 });
|
|
3815
|
+
* for (const entry of data?.entries ?? []) console.log(entry.type, entry.path, entry.size);
|
|
3816
|
+
*/
|
|
3817
|
+
function makeListDirTool(ops, options) {
|
|
3818
|
+
return (0, _warlock_js_ai.tool)({
|
|
3819
|
+
name: options?.name ?? DEFAULT_NAME$5,
|
|
3820
|
+
description: "List a workspace directory: entries with their type and file size, breadth-first, `depth` levels deep (default 1). Dependency and VCS folders are left out unless you list them directly.",
|
|
3821
|
+
action: (input) => `Listing ${typeof input.path === "string" ? input.path : "."}`,
|
|
3822
|
+
input: inputSchema$5,
|
|
3823
|
+
execute: (input) => ops.listDir(input.path ?? ".", {
|
|
3824
|
+
...input.depth !== void 0 ? { depth: input.depth } : {},
|
|
3825
|
+
...input.ignore !== void 0 ? { ignore: input.ignore } : {}
|
|
3826
|
+
})
|
|
3827
|
+
});
|
|
3828
|
+
}
|
|
3829
|
+
|
|
3830
|
+
//#endregion
|
|
3831
|
+
//#region ../../packages/ai-workspace/src/tools/multi-edit.ts
|
|
3832
|
+
/** Default tool name exposed to the LLM. */
|
|
3833
|
+
const DEFAULT_NAME$4 = "multi_edit";
|
|
3834
|
+
/** Validate one element of `edits`. */
|
|
3835
|
+
function validateEdit(value, index) {
|
|
3836
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) return { issues: [{
|
|
3837
|
+
message: `edits[${index}] must be an object`,
|
|
3838
|
+
path: ["edits", index]
|
|
3839
|
+
}] };
|
|
3840
|
+
const candidate = value;
|
|
3841
|
+
const issues = [];
|
|
3842
|
+
if (typeof candidate.oldString !== "string" || candidate.oldString === "") issues.push({
|
|
3843
|
+
message: `edits[${index}].oldString must be a non-empty string`,
|
|
3844
|
+
path: [
|
|
3845
|
+
"edits",
|
|
3846
|
+
index,
|
|
3847
|
+
"oldString"
|
|
3848
|
+
]
|
|
3849
|
+
});
|
|
3850
|
+
if (typeof candidate.newString !== "string") issues.push({
|
|
3851
|
+
message: `edits[${index}].newString must be a string`,
|
|
3852
|
+
path: [
|
|
3853
|
+
"edits",
|
|
3854
|
+
index,
|
|
3855
|
+
"newString"
|
|
3856
|
+
]
|
|
3857
|
+
});
|
|
3858
|
+
if (candidate.replaceAll !== void 0 && typeof candidate.replaceAll !== "boolean") issues.push({
|
|
3859
|
+
message: `edits[${index}].replaceAll must be a boolean when provided`,
|
|
3860
|
+
path: [
|
|
3861
|
+
"edits",
|
|
3862
|
+
index,
|
|
3863
|
+
"replaceAll"
|
|
3864
|
+
]
|
|
3865
|
+
});
|
|
3866
|
+
if (issues.length > 0 || typeof candidate.oldString !== "string" || typeof candidate.newString !== "string") return { issues };
|
|
3867
|
+
return { edit: {
|
|
3868
|
+
oldString: candidate.oldString,
|
|
3869
|
+
newString: candidate.newString,
|
|
3870
|
+
...candidate.replaceAll === true ? { replaceAll: true } : {}
|
|
3871
|
+
} };
|
|
3872
|
+
}
|
|
3873
|
+
/** Input schema for the `multi_edit` tool. */
|
|
3874
|
+
const inputSchema$4 = withJsonSchema({ "~standard": {
|
|
3875
|
+
version: 1,
|
|
3876
|
+
vendor: "ai-workspace",
|
|
3877
|
+
validate(value) {
|
|
3878
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) return { issues: [{ message: "input must be an object" }] };
|
|
3879
|
+
const candidate = value;
|
|
3880
|
+
const issues = [];
|
|
3881
|
+
const edits = [];
|
|
3882
|
+
if (typeof candidate.path !== "string" || candidate.path === "") issues.push({
|
|
3883
|
+
message: "\"path\" must be a non-empty string",
|
|
3884
|
+
path: ["path"]
|
|
3885
|
+
});
|
|
3886
|
+
if (candidate.expectHash !== void 0 && typeof candidate.expectHash !== "string") issues.push({
|
|
3887
|
+
message: "\"expectHash\" must be a string when provided",
|
|
3888
|
+
path: ["expectHash"]
|
|
3889
|
+
});
|
|
3890
|
+
if (!Array.isArray(candidate.edits) || candidate.edits.length === 0 || candidate.edits.length > 100) issues.push({
|
|
3891
|
+
message: `"edits" must be an array of 1 to ${100} edits`,
|
|
3892
|
+
path: ["edits"]
|
|
3893
|
+
});
|
|
3894
|
+
else candidate.edits.forEach((item, index) => {
|
|
3895
|
+
const outcome = validateEdit(item, index);
|
|
3896
|
+
if ("issues" in outcome) issues.push(...outcome.issues);
|
|
3897
|
+
else edits.push(outcome.edit);
|
|
3898
|
+
});
|
|
3899
|
+
if (issues.length > 0 || typeof candidate.path !== "string") return { issues };
|
|
3900
|
+
return { value: {
|
|
3901
|
+
path: candidate.path,
|
|
3902
|
+
edits,
|
|
3903
|
+
...typeof candidate.expectHash === "string" ? { expectHash: candidate.expectHash } : {}
|
|
3904
|
+
} };
|
|
3905
|
+
}
|
|
3906
|
+
} }, MULTI_EDIT_JSON_SCHEMA);
|
|
3907
|
+
/**
|
|
3908
|
+
* Build the agent-facing `multi_edit` tool over a workspace's policy-
|
|
3909
|
+
* enforced {@link WorkspaceOps}: several exact-string replacements in one
|
|
3910
|
+
* file, **atomic** — every edit applies, in order (each against the result
|
|
3911
|
+
* of the previous ones), and the file is written once; or one edit fails
|
|
3912
|
+
* and nothing is written.
|
|
3913
|
+
*
|
|
3914
|
+
* Each edit follows the `edit_file` rules: `oldString` must be found, and
|
|
3915
|
+
* found exactly once unless `replaceAll`. An optional `expectHash` (from
|
|
3916
|
+
* `read_file`) rejects the whole call when the file changed since it was
|
|
3917
|
+
* read. A failure surfaces as a `WorkspaceEditError` in the result's
|
|
3918
|
+
* `error` field, with `editIndex` naming the failing edit.
|
|
3919
|
+
*
|
|
3920
|
+
* @param ops - The shared, policy-enforced operation layer.
|
|
3921
|
+
* @param options - Optional overrides; `name` renames the LLM-visible tool.
|
|
3922
|
+
*
|
|
3923
|
+
* @example
|
|
3924
|
+
* const multiEdit = makeMultiEditTool(ops);
|
|
3925
|
+
* const { data, error } = await multiEdit.invoke({
|
|
3926
|
+
* path: "src/cart.ts",
|
|
3927
|
+
* expectHash,
|
|
3928
|
+
* edits: [
|
|
3929
|
+
* { oldString: "let total = 0;", newString: "let total = 0n;" },
|
|
3930
|
+
* { oldString: "total += price;", newString: "total += BigInt(price);" },
|
|
3931
|
+
* ],
|
|
3932
|
+
* });
|
|
3933
|
+
*/
|
|
3934
|
+
function makeMultiEditTool(ops, options) {
|
|
3935
|
+
return (0, _warlock_js_ai.tool)({
|
|
3936
|
+
name: options?.name ?? DEFAULT_NAME$4,
|
|
3937
|
+
description: "Make several exact-string replacements in one workspace file at once. Edits apply in order, each to the result of the previous; if any edit fails (text not found, or not unique without replaceAll) none are applied. Pass expectHash (from read_file) to reject the call if the file changed since you read it.",
|
|
3938
|
+
action: (input) => `Editing ${typeof input.path === "string" ? input.path : "a file"}`,
|
|
3939
|
+
input: inputSchema$4,
|
|
3940
|
+
execute: (input) => ops.multiEdit(input)
|
|
3941
|
+
});
|
|
3942
|
+
}
|
|
3943
|
+
|
|
1493
3944
|
//#endregion
|
|
1494
3945
|
//#region ../../packages/ai-workspace/src/tools/read-file.ts
|
|
1495
3946
|
/** Default tool name exposed to the LLM. */
|
|
1496
|
-
const DEFAULT_NAME$
|
|
3947
|
+
const DEFAULT_NAME$3 = "read_file";
|
|
1497
3948
|
/** Input schema for the `read_file` tool. */
|
|
1498
|
-
const inputSchema$
|
|
3949
|
+
const inputSchema$3 = objectSchema({
|
|
1499
3950
|
path: stringField(),
|
|
1500
3951
|
startLine: optionalNumberField(),
|
|
1501
3952
|
limit: optionalNumberField()
|
|
1502
|
-
});
|
|
3953
|
+
}, READ_FILE_JSON_SCHEMA);
|
|
1503
3954
|
/**
|
|
1504
3955
|
* Build the agent-facing `read_file` tool over a workspace's policy-
|
|
1505
3956
|
* enforced {@link WorkspaceOps}.
|
|
@@ -1526,9 +3977,9 @@ const inputSchema$1 = objectSchema({
|
|
|
1526
3977
|
*/
|
|
1527
3978
|
function makeReadFileTool(ops, options) {
|
|
1528
3979
|
return (0, _warlock_js_ai.tool)({
|
|
1529
|
-
name: options?.name ?? DEFAULT_NAME$
|
|
3980
|
+
name: options?.name ?? DEFAULT_NAME$3,
|
|
1530
3981
|
description: "Read a file from the workspace, returning a numbered line window plus the file's content hash. Pass the hash to edit_file's expectHash to guard against editing a stale version. Use startLine/limit to page through large files. Output is byte-capped by the workspace policy; when bytesCapped is true the last returned line may be cut short.",
|
|
1531
|
-
input: inputSchema$
|
|
3982
|
+
input: inputSchema$3,
|
|
1532
3983
|
async execute(input) {
|
|
1533
3984
|
return ops.readFile(input.path, {
|
|
1534
3985
|
offset: input.startLine !== void 0 ? Math.max(1, input.startLine) : 1,
|
|
@@ -1545,64 +3996,67 @@ const DEFAULT_RUN_SHELL_TOOL_NAME = "run_shell";
|
|
|
1545
3996
|
/**
|
|
1546
3997
|
* Hand-rolled Standard Schema for {@link RunShellInput}. We validate the
|
|
1547
3998
|
* model's arguments without a runtime schema dependency: `command` must be
|
|
1548
|
-
* a non-empty string,
|
|
1549
|
-
* Invalid args surface as a
|
|
1550
|
-
* `error` field rather than
|
|
1551
|
-
|
|
1552
|
-
const runShellInputSchema = { "~standard": {
|
|
1553
|
-
version: 1,
|
|
1554
|
-
vendor: "@warlock.js/ai-workspace",
|
|
1555
|
-
validate: (value) => {
|
|
1556
|
-
if (typeof value !== "object" || value === null) return { issues: [{ message: "expected an object" }] };
|
|
1557
|
-
const candidate = value;
|
|
1558
|
-
if (typeof candidate.command !== "string" || candidate.command.length === 0) return { issues: [{
|
|
1559
|
-
message: "command must be a non-empty string",
|
|
1560
|
-
path: ["command"]
|
|
1561
|
-
}] };
|
|
1562
|
-
if (candidate.timeoutMs !== void 0 && (typeof candidate.timeoutMs !== "number" || candidate.timeoutMs <= 0)) return { issues: [{
|
|
1563
|
-
message: "timeoutMs must be a positive number",
|
|
1564
|
-
path: ["timeoutMs"]
|
|
1565
|
-
}] };
|
|
1566
|
-
const result = { command: candidate.command };
|
|
1567
|
-
if (candidate.timeoutMs !== void 0) result.timeoutMs = candidate.timeoutMs;
|
|
1568
|
-
return { value: result };
|
|
1569
|
-
}
|
|
1570
|
-
} };
|
|
1571
|
-
/**
|
|
1572
|
-
* Build the `run_shell` tool — a {@link ToolContract} that runs a single
|
|
1573
|
-
* shell command through the policy-enforced {@link WorkspaceOps} layer.
|
|
1574
|
-
*
|
|
1575
|
-
* The command's leading executable basename is gated against the shell
|
|
1576
|
-
* allow/deny policy by `ops.exec`; a blocked command throws a
|
|
1577
|
-
* `WorkspacePolicyError` which the `tool()` runtime catches and surfaces
|
|
1578
|
-
* in the result's `error` field (never a thrown run-killer), so the agent
|
|
1579
|
-
* reads the refusal as tool data and self-corrects. A command that runs
|
|
1580
|
-
* but exits non-zero is *not* an error — its `exitCode`/`stderr` come back
|
|
1581
|
-
* in `data` for the agent to inspect.
|
|
1582
|
-
*
|
|
1583
|
-
* The agent run's cancellation `signal` (from the dispatch `ctx`) is passed
|
|
1584
|
-
* to `ops.exec` per invocation: a cancelled run never starts the command,
|
|
1585
|
-
* and one cancelled mid-command kills its process tree (`cancelled: true`).
|
|
1586
|
-
*
|
|
1587
|
-
* @param ops - The policy-enforced operation layer to delegate `exec` to.
|
|
1588
|
-
* @param options - Optional tool-name override.
|
|
1589
|
-
*
|
|
1590
|
-
* @example
|
|
1591
|
-
* const runShell = makeRunShellTool(ops);
|
|
1592
|
-
* const { data, error } = await runShell.invoke({ command: "npm run build" });
|
|
1593
|
-
* if (error) handleDenied(error);
|
|
1594
|
-
* else console.log(data.exitCode, data.stdout);
|
|
3999
|
+
* a non-empty string, `timeoutMs` (when present) a positive number and
|
|
4000
|
+
* `background` (when present) a boolean. Invalid args surface as a
|
|
4001
|
+
* `SchemaValidationError` in the tool result's `error` field rather than
|
|
4002
|
+
* reaching `ops.exec`.
|
|
1595
4003
|
*/
|
|
4004
|
+
function runShellInputSchema(background) {
|
|
4005
|
+
return withJsonSchema({ "~standard": {
|
|
4006
|
+
version: 1,
|
|
4007
|
+
vendor: "@warlock.js/ai-workspace",
|
|
4008
|
+
validate: (value) => {
|
|
4009
|
+
if (typeof value !== "object" || value === null) return { issues: [{ message: "expected an object" }] };
|
|
4010
|
+
const candidate = value;
|
|
4011
|
+
if (typeof candidate.command !== "string" || candidate.command.length === 0) return { issues: [{
|
|
4012
|
+
message: "command must be a non-empty string",
|
|
4013
|
+
path: ["command"]
|
|
4014
|
+
}] };
|
|
4015
|
+
if (candidate.timeoutMs !== void 0 && (typeof candidate.timeoutMs !== "number" || candidate.timeoutMs <= 0)) return { issues: [{
|
|
4016
|
+
message: "timeoutMs must be a positive number",
|
|
4017
|
+
path: ["timeoutMs"]
|
|
4018
|
+
}] };
|
|
4019
|
+
if (candidate.background !== void 0 && typeof candidate.background !== "boolean") return { issues: [{
|
|
4020
|
+
message: "background must be a boolean",
|
|
4021
|
+
path: ["background"]
|
|
4022
|
+
}] };
|
|
4023
|
+
const result = { command: candidate.command };
|
|
4024
|
+
if (typeof candidate.timeoutMs === "number") result.timeoutMs = candidate.timeoutMs;
|
|
4025
|
+
if (candidate.background === true) result.background = true;
|
|
4026
|
+
return { value: result };
|
|
4027
|
+
}
|
|
4028
|
+
} }, runShellJsonSchema(background));
|
|
4029
|
+
}
|
|
4030
|
+
/** The lower of the model's timeout and the cap (either may be absent). */
|
|
4031
|
+
function cappedTimeout(requested, cap) {
|
|
4032
|
+
if (requested === void 0) return cap;
|
|
4033
|
+
return cap === void 0 ? requested : Math.min(requested, cap);
|
|
4034
|
+
}
|
|
1596
4035
|
function makeRunShellTool(ops, options) {
|
|
4036
|
+
const background = options?.background === true;
|
|
1597
4037
|
return (0, _warlock_js_ai.tool)({
|
|
1598
4038
|
name: options?.name ?? DEFAULT_RUN_SHELL_TOOL_NAME,
|
|
1599
|
-
description: "Run a
|
|
4039
|
+
description: "Run a shell command inside the workspace. Every command in it must be permitted by the shell policy; a refusal explains how to fix the command. Output is byte-capped and the run is time-limited. A non-zero exit code is returned as data, not an error." + (background ? " Pass background: true for a long-running command (dev server, watcher): it returns an id at once; read its output with shell_output and stop it with shell_kill." : ""),
|
|
1600
4040
|
action: (input) => `Running \`${input.command}\``,
|
|
1601
|
-
input: runShellInputSchema,
|
|
1602
|
-
execute: (input, ctx) =>
|
|
1603
|
-
|
|
1604
|
-
|
|
1605
|
-
|
|
4041
|
+
input: runShellInputSchema(background),
|
|
4042
|
+
execute: async (input, ctx) => {
|
|
4043
|
+
if (input.background === true) {
|
|
4044
|
+
if (!background) throw new WorkspacePolicyError("Background commands are not enabled for this workspace; run the command without background.", {
|
|
4045
|
+
type: "denied-command",
|
|
4046
|
+
reason: "not-allowed",
|
|
4047
|
+
command: input.command
|
|
4048
|
+
});
|
|
4049
|
+
return {
|
|
4050
|
+
...await ops.startShell(input.command, input.timeoutMs !== void 0 ? { timeoutMs: input.timeoutMs } : void 0),
|
|
4051
|
+
background: true
|
|
4052
|
+
};
|
|
4053
|
+
}
|
|
4054
|
+
const timeoutMs = cappedTimeout(input.timeoutMs, options?.maxTimeoutMs);
|
|
4055
|
+
return ops.exec(input.command, {
|
|
4056
|
+
...timeoutMs !== void 0 ? { timeoutMs } : {},
|
|
4057
|
+
...ctx?.signal ? { signal: ctx.signal } : {}
|
|
4058
|
+
});
|
|
4059
|
+
}
|
|
1606
4060
|
});
|
|
1607
4061
|
}
|
|
1608
4062
|
|
|
@@ -1621,7 +4075,7 @@ const DEFAULT_TEST_COMMAND = "npm test";
|
|
|
1621
4075
|
* happens without a runtime schema dependency, mirroring the wider tool
|
|
1622
4076
|
* layer.
|
|
1623
4077
|
*/
|
|
1624
|
-
const runTestsInputSchema = { "~standard": {
|
|
4078
|
+
const runTestsInputSchema = withJsonSchema({ "~standard": {
|
|
1625
4079
|
version: 1,
|
|
1626
4080
|
vendor: "@warlock.js/ai-workspace",
|
|
1627
4081
|
validate: (value) => {
|
|
@@ -1640,7 +4094,12 @@ const runTestsInputSchema = { "~standard": {
|
|
|
1640
4094
|
if (candidate.pattern !== void 0) result.pattern = candidate.pattern;
|
|
1641
4095
|
return { value: result };
|
|
1642
4096
|
}
|
|
1643
|
-
} };
|
|
4097
|
+
} }, RUN_TESTS_JSON_SCHEMA);
|
|
4098
|
+
/** Quote the test pattern as one literal argument for the line's interpreter. */
|
|
4099
|
+
function quotePattern(pattern, interpreter) {
|
|
4100
|
+
if (interpreter === "sh" && process.platform !== "win32") return `'${pattern.replace(/'/g, "'\\''")}'`;
|
|
4101
|
+
return `"${pattern}"`;
|
|
4102
|
+
}
|
|
1644
4103
|
/**
|
|
1645
4104
|
* Build the `run_tests` tool — a {@link ToolContract} convenience over
|
|
1646
4105
|
* `run_shell` that runs the workspace's configured test command through
|
|
@@ -1673,12 +4132,275 @@ function makeRunTestsTool(ops, options) {
|
|
|
1673
4132
|
action: (input) => input.pattern ? `Running tests matching "${input.pattern}"` : "Running tests",
|
|
1674
4133
|
input: runTestsInputSchema,
|
|
1675
4134
|
execute: (input, ctx) => {
|
|
1676
|
-
const command = input.pattern ? `${baseCommand}
|
|
4135
|
+
const command = input.pattern ? `${baseCommand} ${quotePattern(input.pattern, options?.interpreter ?? "none")}` : baseCommand;
|
|
1677
4136
|
return ops.exec(command, ctx?.signal ? { signal: ctx.signal } : void 0);
|
|
1678
4137
|
}
|
|
1679
4138
|
});
|
|
1680
4139
|
}
|
|
1681
4140
|
|
|
4141
|
+
//#endregion
|
|
4142
|
+
//#region ../../packages/ai-workspace/src/tools/shell-kill.ts
|
|
4143
|
+
/** Default tool name exposed to the LLM. */
|
|
4144
|
+
const DEFAULT_NAME$2 = "shell_kill";
|
|
4145
|
+
/** Input schema for the `shell_kill` tool. */
|
|
4146
|
+
const inputSchema$2 = objectSchema({ id: stringField() }, SHELL_KILL_JSON_SCHEMA);
|
|
4147
|
+
/**
|
|
4148
|
+
* Build the agent-facing `shell_kill` tool: stop a background command
|
|
4149
|
+
* started with `run_shell({ background: true })`, killing its whole process
|
|
4150
|
+
* tree, and return its final state. Killing a command that already stopped
|
|
4151
|
+
* just reports its state. An unknown id surfaces as a `WorkspaceJobError`
|
|
4152
|
+
* in the result's `error` field.
|
|
4153
|
+
*
|
|
4154
|
+
* @param ops - The shared, policy-enforced operation layer.
|
|
4155
|
+
* @param options - Optional overrides; `name` renames the LLM-visible tool.
|
|
4156
|
+
*
|
|
4157
|
+
* @example
|
|
4158
|
+
* const { data } = await makeShellKillTool(ops).invoke({ id: "job-1" });
|
|
4159
|
+
* console.log(data?.status); // "killed"
|
|
4160
|
+
*/
|
|
4161
|
+
function makeShellKillTool(ops, options) {
|
|
4162
|
+
return (0, _warlock_js_ai.tool)({
|
|
4163
|
+
name: options?.name ?? DEFAULT_NAME$2,
|
|
4164
|
+
description: "Stop a background command started with run_shell (background: true), killing it and everything it started. Returns its final state.",
|
|
4165
|
+
action: (input) => `Stopping ${typeof input.id === "string" ? input.id : "a background command"}`,
|
|
4166
|
+
input: inputSchema$2,
|
|
4167
|
+
execute: (input) => ops.shellKill(input.id)
|
|
4168
|
+
});
|
|
4169
|
+
}
|
|
4170
|
+
|
|
4171
|
+
//#endregion
|
|
4172
|
+
//#region ../../packages/ai-workspace/src/tools/shell-output.ts
|
|
4173
|
+
/** Default tool name exposed to the LLM. */
|
|
4174
|
+
const DEFAULT_NAME$1 = "shell_output";
|
|
4175
|
+
/** Input schema for the `shell_output` tool. */
|
|
4176
|
+
const inputSchema$1 = objectSchema({
|
|
4177
|
+
id: stringField(),
|
|
4178
|
+
since: optionalNumberField(),
|
|
4179
|
+
waitMs: optionalNumberField()
|
|
4180
|
+
}, SHELL_OUTPUT_JSON_SCHEMA);
|
|
4181
|
+
/**
|
|
4182
|
+
* Build the agent-facing `shell_output` tool: the new output of a
|
|
4183
|
+
* background command started with `run_shell({ background: true })`, plus
|
|
4184
|
+
* its state (`running` / `exited` / `killed` / `timed-out`, `exitCode`).
|
|
4185
|
+
*
|
|
4186
|
+
* Without `since` it returns everything produced since the previous read;
|
|
4187
|
+
* `cursor` in the result is the position to pass as `since` next time.
|
|
4188
|
+
* `waitMs` (at most 30 s) waits for new output or for the command to stop
|
|
4189
|
+
* when there is nothing new yet — the agent run's signal ends the wait
|
|
4190
|
+
* early. Only the newest `shell.maxOutputBytes` of a command's output are
|
|
4191
|
+
* kept; `droppedBytes` says how much the reader missed.
|
|
4192
|
+
*
|
|
4193
|
+
* @param ops - The shared, policy-enforced operation layer.
|
|
4194
|
+
* @param options - Optional overrides; `name` renames the LLM-visible tool.
|
|
4195
|
+
*
|
|
4196
|
+
* @example
|
|
4197
|
+
* const { data } = await makeShellOutputTool(ops).invoke({ id: "job-1", waitMs: 2_000 });
|
|
4198
|
+
* if (data?.stdout.includes("ready")) console.log("dev server is up");
|
|
4199
|
+
*/
|
|
4200
|
+
function makeShellOutputTool(ops, options) {
|
|
4201
|
+
return (0, _warlock_js_ai.tool)({
|
|
4202
|
+
name: options?.name ?? DEFAULT_NAME$1,
|
|
4203
|
+
description: "Read new output of a background command started with run_shell (background: true) and see whether it is still running. Returns output since the last read; waitMs waits briefly for more.",
|
|
4204
|
+
action: (input) => `Reading output of ${typeof input.id === "string" ? input.id : "a background command"}`,
|
|
4205
|
+
input: inputSchema$1,
|
|
4206
|
+
execute: (input, ctx) => ops.shellOutput(input.id, {
|
|
4207
|
+
...input.since !== void 0 ? { since: input.since } : {},
|
|
4208
|
+
...input.waitMs !== void 0 ? { waitMs: input.waitMs } : {},
|
|
4209
|
+
...ctx?.signal ? { signal: ctx.signal } : {}
|
|
4210
|
+
})
|
|
4211
|
+
});
|
|
4212
|
+
}
|
|
4213
|
+
|
|
4214
|
+
//#endregion
|
|
4215
|
+
//#region ../../packages/ai-workspace/src/todos.ts
|
|
4216
|
+
/** Most items a todo list may hold. */
|
|
4217
|
+
const MAX_TODOS = 100;
|
|
4218
|
+
/** Longest `content` one todo item may have. */
|
|
4219
|
+
const MAX_TODO_CONTENT_LENGTH = 1e3;
|
|
4220
|
+
/** The statuses a todo item may have, in display order. */
|
|
4221
|
+
const TODO_STATUSES = [
|
|
4222
|
+
"pending",
|
|
4223
|
+
"in_progress",
|
|
4224
|
+
"completed"
|
|
4225
|
+
];
|
|
4226
|
+
/**
|
|
4227
|
+
* Validate a whole replacement list. Returns the problems found (empty when
|
|
4228
|
+
* the list is valid) so the tool schema and the direct method share one
|
|
4229
|
+
* set of rules.
|
|
4230
|
+
*/
|
|
4231
|
+
function todoListProblems(todos) {
|
|
4232
|
+
const problems = [];
|
|
4233
|
+
const ids = /* @__PURE__ */ new Set();
|
|
4234
|
+
if (todos.length > 100) problems.push(`a todo list holds at most ${100} items (got ${todos.length})`);
|
|
4235
|
+
todos.forEach((todo, index) => {
|
|
4236
|
+
if (todo.content.trim() === "") problems.push(`todos[${index}].content must not be empty`);
|
|
4237
|
+
if (todo.content.length > 1e3) problems.push(`todos[${index}].content is longer than ${MAX_TODO_CONTENT_LENGTH} characters`);
|
|
4238
|
+
if (!TODO_STATUSES.includes(todo.status)) problems.push(`todos[${index}].status must be one of ${TODO_STATUSES.join(", ")}`);
|
|
4239
|
+
const id = todo.id ?? String(index + 1);
|
|
4240
|
+
if (ids.has(id)) problems.push(`todos[${index}].id "${id}" is used more than once`);
|
|
4241
|
+
ids.add(id);
|
|
4242
|
+
});
|
|
4243
|
+
return problems;
|
|
4244
|
+
}
|
|
4245
|
+
/**
|
|
4246
|
+
* The in-memory todo list of one workspace (shared by its projections).
|
|
4247
|
+
* `write` replaces the whole list, the way an agent's plan tool usually
|
|
4248
|
+
* works: the model sends the full updated list every time.
|
|
4249
|
+
*/
|
|
4250
|
+
var TodoStore = class {
|
|
4251
|
+
constructor() {
|
|
4252
|
+
this.items = [];
|
|
4253
|
+
}
|
|
4254
|
+
/** The current list and a count per status. */
|
|
4255
|
+
read() {
|
|
4256
|
+
const counts = {
|
|
4257
|
+
pending: 0,
|
|
4258
|
+
in_progress: 0,
|
|
4259
|
+
completed: 0
|
|
4260
|
+
};
|
|
4261
|
+
for (const item of this.items) counts[item.status]++;
|
|
4262
|
+
return {
|
|
4263
|
+
todos: this.items.map((item) => ({ ...item })),
|
|
4264
|
+
counts
|
|
4265
|
+
};
|
|
4266
|
+
}
|
|
4267
|
+
/**
|
|
4268
|
+
* Replace the list. Items without an `id` get their 1-based position as
|
|
4269
|
+
* one. Throws a `RangeError` naming every problem when the list is invalid
|
|
4270
|
+
* (too long, empty content, unknown status, duplicate id); the old list is
|
|
4271
|
+
* kept in that case.
|
|
4272
|
+
*/
|
|
4273
|
+
write(todos) {
|
|
4274
|
+
const problems = todoListProblems(todos);
|
|
4275
|
+
if (problems.length > 0) throw new RangeError(`Invalid todo list: ${problems.join("; ")}.`);
|
|
4276
|
+
this.items = todos.map((todo, index) => ({
|
|
4277
|
+
id: todo.id ?? String(index + 1),
|
|
4278
|
+
content: todo.content.trim(),
|
|
4279
|
+
status: todo.status
|
|
4280
|
+
}));
|
|
4281
|
+
return this.read();
|
|
4282
|
+
}
|
|
4283
|
+
};
|
|
4284
|
+
|
|
4285
|
+
//#endregion
|
|
4286
|
+
//#region ../../packages/ai-workspace/src/tools/todo.ts
|
|
4287
|
+
/** Whether `value` is a todo status. */
|
|
4288
|
+
function isTodoStatus(value) {
|
|
4289
|
+
return typeof value === "string" && TODO_STATUSES.includes(value);
|
|
4290
|
+
}
|
|
4291
|
+
/** Input schema for the `todo_write` tool. */
|
|
4292
|
+
const todoWriteSchema = withJsonSchema({ "~standard": {
|
|
4293
|
+
version: 1,
|
|
4294
|
+
vendor: "ai-workspace",
|
|
4295
|
+
validate(value) {
|
|
4296
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) return { issues: [{ message: "input must be an object" }] };
|
|
4297
|
+
const raw = value.todos;
|
|
4298
|
+
if (!Array.isArray(raw) || raw.length > 100) return { issues: [{
|
|
4299
|
+
message: `"todos" must be an array of at most ${100} items`,
|
|
4300
|
+
path: ["todos"]
|
|
4301
|
+
}] };
|
|
4302
|
+
const issues = [];
|
|
4303
|
+
const todos = [];
|
|
4304
|
+
raw.forEach((item, index) => {
|
|
4305
|
+
if (typeof item !== "object" || item === null || Array.isArray(item)) {
|
|
4306
|
+
issues.push({
|
|
4307
|
+
message: `todos[${index}] must be an object`,
|
|
4308
|
+
path: ["todos", index]
|
|
4309
|
+
});
|
|
4310
|
+
return;
|
|
4311
|
+
}
|
|
4312
|
+
const candidate = item;
|
|
4313
|
+
if (typeof candidate.content !== "string") issues.push({
|
|
4314
|
+
message: `todos[${index}].content must be a string`,
|
|
4315
|
+
path: [
|
|
4316
|
+
"todos",
|
|
4317
|
+
index,
|
|
4318
|
+
"content"
|
|
4319
|
+
]
|
|
4320
|
+
});
|
|
4321
|
+
if (!isTodoStatus(candidate.status)) issues.push({
|
|
4322
|
+
message: `todos[${index}].status must be one of ${TODO_STATUSES.join(", ")}`,
|
|
4323
|
+
path: [
|
|
4324
|
+
"todos",
|
|
4325
|
+
index,
|
|
4326
|
+
"status"
|
|
4327
|
+
]
|
|
4328
|
+
});
|
|
4329
|
+
if (candidate.id !== void 0 && typeof candidate.id !== "string") issues.push({
|
|
4330
|
+
message: `todos[${index}].id must be a string when provided`,
|
|
4331
|
+
path: [
|
|
4332
|
+
"todos",
|
|
4333
|
+
index,
|
|
4334
|
+
"id"
|
|
4335
|
+
]
|
|
4336
|
+
});
|
|
4337
|
+
if (typeof candidate.content === "string" && isTodoStatus(candidate.status)) todos.push({
|
|
4338
|
+
content: candidate.content,
|
|
4339
|
+
status: candidate.status,
|
|
4340
|
+
...typeof candidate.id === "string" ? { id: candidate.id } : {}
|
|
4341
|
+
});
|
|
4342
|
+
});
|
|
4343
|
+
if (issues.length === 0) for (const problem of todoListProblems(todos)) issues.push({
|
|
4344
|
+
message: problem,
|
|
4345
|
+
path: ["todos"]
|
|
4346
|
+
});
|
|
4347
|
+
return issues.length > 0 ? { issues } : { value: { todos } };
|
|
4348
|
+
}
|
|
4349
|
+
} }, TODO_WRITE_JSON_SCHEMA);
|
|
4350
|
+
/** Input schema for the `todo_read` tool (no arguments). */
|
|
4351
|
+
const todoReadSchema = withJsonSchema({ "~standard": {
|
|
4352
|
+
version: 1,
|
|
4353
|
+
vendor: "ai-workspace",
|
|
4354
|
+
validate: () => ({ value: {} })
|
|
4355
|
+
} }, TODO_READ_JSON_SCHEMA);
|
|
4356
|
+
/**
|
|
4357
|
+
* Build the agent-facing `todo_write` tool over a workspace's todo list:
|
|
4358
|
+
* the model sends the **complete** updated list (each item `content` +
|
|
4359
|
+
* `status` of `pending` / `in_progress` / `completed`, optional `id`) and it
|
|
4360
|
+
* replaces the old one. The list lives in memory, per workspace (shared by
|
|
4361
|
+
* its `readonly()` / `scope()` projections), for the length of the run.
|
|
4362
|
+
*
|
|
4363
|
+
* @param store - The workspace's todo list.
|
|
4364
|
+
* @param options - Optional overrides; `name` renames the LLM-visible tool.
|
|
4365
|
+
*
|
|
4366
|
+
* @example
|
|
4367
|
+
* await makeTodoWriteTool(store).invoke({
|
|
4368
|
+
* todos: [
|
|
4369
|
+
* { content: "Reproduce the failing test", status: "completed" },
|
|
4370
|
+
* { content: "Fix the rounding bug", status: "in_progress" },
|
|
4371
|
+
* ],
|
|
4372
|
+
* });
|
|
4373
|
+
*/
|
|
4374
|
+
function makeTodoWriteTool(store, options) {
|
|
4375
|
+
return (0, _warlock_js_ai.tool)({
|
|
4376
|
+
name: options?.name ?? "todo_write",
|
|
4377
|
+
description: "Replace your todo list for this task with the complete updated list (each item: content and status pending | in_progress | completed). Use it to plan multi-step work and to mark progress.",
|
|
4378
|
+
action: "Updating the todo list",
|
|
4379
|
+
input: todoWriteSchema,
|
|
4380
|
+
execute: async (input) => store.write(input.todos)
|
|
4381
|
+
});
|
|
4382
|
+
}
|
|
4383
|
+
/**
|
|
4384
|
+
* Build the agent-facing `todo_read` tool: the workspace's current todo
|
|
4385
|
+
* list and a count per status.
|
|
4386
|
+
*
|
|
4387
|
+
* @param store - The workspace's todo list.
|
|
4388
|
+
* @param options - Optional overrides; `name` renames the LLM-visible tool.
|
|
4389
|
+
*
|
|
4390
|
+
* @example
|
|
4391
|
+
* const { data } = await makeTodoReadTool(store).invoke({});
|
|
4392
|
+
* console.log(data?.counts.pending);
|
|
4393
|
+
*/
|
|
4394
|
+
function makeTodoReadTool(store, options) {
|
|
4395
|
+
return (0, _warlock_js_ai.tool)({
|
|
4396
|
+
name: options?.name ?? "todo_read",
|
|
4397
|
+
description: "Read your current todo list for this task.",
|
|
4398
|
+
action: "Reading the todo list",
|
|
4399
|
+
input: todoReadSchema,
|
|
4400
|
+
execute: async () => store.read()
|
|
4401
|
+
});
|
|
4402
|
+
}
|
|
4403
|
+
|
|
1682
4404
|
//#endregion
|
|
1683
4405
|
//#region ../../packages/ai-workspace/src/tools/write-file.ts
|
|
1684
4406
|
/** Default tool name exposed to the LLM. */
|
|
@@ -1687,7 +4409,7 @@ const DEFAULT_NAME = "write_file";
|
|
|
1687
4409
|
const inputSchema = objectSchema({
|
|
1688
4410
|
path: stringField(),
|
|
1689
4411
|
content: stringField()
|
|
1690
|
-
});
|
|
4412
|
+
}, WRITE_FILE_JSON_SCHEMA);
|
|
1691
4413
|
/**
|
|
1692
4414
|
* Build the agent-facing `write_file` tool over a workspace's policy-
|
|
1693
4415
|
* enforced {@link WorkspaceOps}.
|
|
@@ -1735,23 +4457,35 @@ function makeWriteFileTool(ops, options) {
|
|
|
1735
4457
|
const ALL_TOOL_NAMES = [
|
|
1736
4458
|
"readFile",
|
|
1737
4459
|
"editFile",
|
|
4460
|
+
"multiEdit",
|
|
1738
4461
|
"writeFile",
|
|
4462
|
+
"listDir",
|
|
1739
4463
|
"runShell",
|
|
4464
|
+
"shellOutput",
|
|
4465
|
+
"shellKill",
|
|
1740
4466
|
"runTests",
|
|
1741
4467
|
"grep",
|
|
1742
|
-
"glob"
|
|
4468
|
+
"glob",
|
|
4469
|
+
"todoWrite",
|
|
4470
|
+
"todoRead"
|
|
1743
4471
|
];
|
|
1744
4472
|
/**
|
|
1745
4473
|
* The subset a {@link Workspace.readonly} projection exposes — the
|
|
1746
|
-
*
|
|
1747
|
-
* `runTests` are deliberately omitted so a
|
|
1748
|
-
* change
|
|
4474
|
+
* tools that cannot change the tree. `editFile` / `multiEdit` /
|
|
4475
|
+
* `writeFile` / the shell tools / `runTests` are deliberately omitted so a
|
|
4476
|
+
* reviewer agent has no path to change it. The todo tools stay: they only
|
|
4477
|
+
* touch the in-memory plan.
|
|
1749
4478
|
*/
|
|
1750
4479
|
const READONLY_TOOL_NAMES = [
|
|
1751
4480
|
"readFile",
|
|
4481
|
+
"listDir",
|
|
1752
4482
|
"grep",
|
|
1753
|
-
"glob"
|
|
4483
|
+
"glob",
|
|
4484
|
+
"todoWrite",
|
|
4485
|
+
"todoRead"
|
|
1754
4486
|
];
|
|
4487
|
+
/** Tools vended only when the policy enables background commands. */
|
|
4488
|
+
const BACKGROUND_TOOL_NAMES = new Set(["shellOutput", "shellKill"]);
|
|
1755
4489
|
/**
|
|
1756
4490
|
* Choose the dumb IO executor for a policy. `"mock"` selects the
|
|
1757
4491
|
* in-memory backend (hermetic tests); anything else — including the
|
|
@@ -1776,11 +4510,15 @@ function selectBackend(policy) {
|
|
|
1776
4510
|
* Constructed via {@link workspace}; the class itself is internal.
|
|
1777
4511
|
*/
|
|
1778
4512
|
var WorkspaceImpl = class WorkspaceImpl {
|
|
1779
|
-
constructor(policy, allowedTools = ALL_TOOL_NAMES) {
|
|
4513
|
+
constructor(policy, allowedTools = ALL_TOOL_NAMES, state) {
|
|
1780
4514
|
this.policy = policy;
|
|
1781
4515
|
this.allowedTools = new Set(allowedTools);
|
|
4516
|
+
this.state = state ?? {
|
|
4517
|
+
jobs: new ShellJobs(),
|
|
4518
|
+
todos: new TodoStore()
|
|
4519
|
+
};
|
|
1782
4520
|
const backend = selectBackend(policy);
|
|
1783
|
-
this.ops = createOps(backend, policy);
|
|
4521
|
+
this.ops = createOps(backend, policy, { jobs: this.state.jobs });
|
|
1784
4522
|
this.tools = this.buildTools();
|
|
1785
4523
|
}
|
|
1786
4524
|
/**
|
|
@@ -1792,26 +4530,49 @@ var WorkspaceImpl = class WorkspaceImpl {
|
|
|
1792
4530
|
*/
|
|
1793
4531
|
buildTools() {
|
|
1794
4532
|
const erase = (contract) => contract;
|
|
4533
|
+
const shell = this.policy.shell;
|
|
4534
|
+
const background = shell?.background !== void 0;
|
|
4535
|
+
const interpreter = shell?.interpreter ?? "none";
|
|
1795
4536
|
const factories = {
|
|
1796
4537
|
readFile: (opts) => erase(makeReadFileTool(this.ops, opts)),
|
|
1797
4538
|
editFile: (opts) => erase(makeEditFileTool(this.ops, opts)),
|
|
4539
|
+
multiEdit: (opts) => erase(makeMultiEditTool(this.ops, opts)),
|
|
1798
4540
|
writeFile: (opts) => erase(makeWriteFileTool(this.ops, opts)),
|
|
1799
|
-
|
|
1800
|
-
|
|
4541
|
+
listDir: (opts) => erase(makeListDirTool(this.ops, opts)),
|
|
4542
|
+
runShell: (opts) => erase(makeRunShellTool(this.ops, {
|
|
4543
|
+
...opts?.name !== void 0 ? { name: opts.name } : {},
|
|
4544
|
+
...shell?.timeoutMs !== void 0 ? { maxTimeoutMs: shell.timeoutMs } : {},
|
|
4545
|
+
background
|
|
4546
|
+
})),
|
|
4547
|
+
shellOutput: (opts) => erase(makeShellOutputTool(this.ops, opts)),
|
|
4548
|
+
shellKill: (opts) => erase(makeShellKillTool(this.ops, opts)),
|
|
4549
|
+
runTests: (opts) => erase(makeRunTestsTool(this.ops, {
|
|
4550
|
+
...opts,
|
|
4551
|
+
interpreter
|
|
4552
|
+
})),
|
|
1801
4553
|
grep: (opts) => erase(makeGrepTool(this.ops, opts)),
|
|
1802
|
-
glob: (opts) => erase(makeGlobTool(this.ops, opts))
|
|
4554
|
+
glob: (opts) => erase(makeGlobTool(this.ops, opts)),
|
|
4555
|
+
todoWrite: (opts) => erase(makeTodoWriteTool(this.state.todos, opts)),
|
|
4556
|
+
todoRead: (opts) => erase(makeTodoReadTool(this.state.todos, opts))
|
|
1803
4557
|
};
|
|
1804
4558
|
const build = (name, opts) => factories[name](opts);
|
|
4559
|
+
const vendedByAll = (name) => this.allowedTools.has(name) && (background || !BACKGROUND_TOOL_NAMES.has(name));
|
|
1805
4560
|
return {
|
|
1806
|
-
all: () => ALL_TOOL_NAMES.filter(
|
|
4561
|
+
all: () => ALL_TOOL_NAMES.filter(vendedByAll).map((name) => build(name)),
|
|
1807
4562
|
pick: (...names) => names.filter((name) => this.allowedTools.has(name)).map((name) => build(name)),
|
|
1808
4563
|
readFile: (opts) => build("readFile", opts),
|
|
1809
4564
|
editFile: (opts) => build("editFile", opts),
|
|
4565
|
+
multiEdit: (opts) => build("multiEdit", opts),
|
|
1810
4566
|
writeFile: (opts) => build("writeFile", opts),
|
|
4567
|
+
listDir: (opts) => build("listDir", opts),
|
|
1811
4568
|
runShell: (opts) => build("runShell", opts),
|
|
4569
|
+
shellOutput: (opts) => build("shellOutput", opts),
|
|
4570
|
+
shellKill: (opts) => build("shellKill", opts),
|
|
1812
4571
|
runTests: (opts) => build("runTests", opts),
|
|
1813
4572
|
grep: (opts) => build("grep", opts),
|
|
1814
|
-
glob: (opts) => build("glob", opts)
|
|
4573
|
+
glob: (opts) => build("glob", opts),
|
|
4574
|
+
todoWrite: (opts) => build("todoWrite", opts),
|
|
4575
|
+
todoRead: (opts) => build("todoRead", opts)
|
|
1815
4576
|
};
|
|
1816
4577
|
}
|
|
1817
4578
|
/**
|
|
@@ -1837,10 +4598,40 @@ var WorkspaceImpl = class WorkspaceImpl {
|
|
|
1837
4598
|
this.assertWritable("editFile");
|
|
1838
4599
|
return this.ops.editFile(input);
|
|
1839
4600
|
}
|
|
4601
|
+
async multiEdit(input) {
|
|
4602
|
+
this.assertWritable("multiEdit");
|
|
4603
|
+
return this.ops.multiEdit(input);
|
|
4604
|
+
}
|
|
4605
|
+
listDir(dirPath, opts) {
|
|
4606
|
+
return this.ops.listDir(dirPath, opts);
|
|
4607
|
+
}
|
|
1840
4608
|
async exec(command, opts) {
|
|
1841
4609
|
this.assertWritable("exec");
|
|
1842
4610
|
return this.ops.exec(command, opts);
|
|
1843
4611
|
}
|
|
4612
|
+
async startShell(command, opts) {
|
|
4613
|
+
this.assertWritable("startShell");
|
|
4614
|
+
return this.ops.startShell(command, opts);
|
|
4615
|
+
}
|
|
4616
|
+
shellOutput(id, opts) {
|
|
4617
|
+
return this.ops.shellOutput(id, opts);
|
|
4618
|
+
}
|
|
4619
|
+
async shellKill(id) {
|
|
4620
|
+
this.assertWritable("shellKill");
|
|
4621
|
+
return this.ops.shellKill(id);
|
|
4622
|
+
}
|
|
4623
|
+
listShells() {
|
|
4624
|
+
return this.ops.listShells();
|
|
4625
|
+
}
|
|
4626
|
+
readTodos() {
|
|
4627
|
+
return this.state.todos.read();
|
|
4628
|
+
}
|
|
4629
|
+
writeTodos(todos) {
|
|
4630
|
+
return this.state.todos.write(todos);
|
|
4631
|
+
}
|
|
4632
|
+
dispose() {
|
|
4633
|
+
return this.ops.dispose();
|
|
4634
|
+
}
|
|
1844
4635
|
grep(pattern, opts) {
|
|
1845
4636
|
return this.ops.grep(pattern, opts);
|
|
1846
4637
|
}
|
|
@@ -1859,44 +4650,49 @@ var WorkspaceImpl = class WorkspaceImpl {
|
|
|
1859
4650
|
return this.ops.remove(filePath);
|
|
1860
4651
|
}
|
|
1861
4652
|
/**
|
|
1862
|
-
* A read-only projection over the SAME policy — only the read/
|
|
1863
|
-
* tools are vended and every mutating direct method
|
|
1864
|
-
* {@link WorkspacePolicyError}. A fresh ops/backend is
|
|
1865
|
-
* identical policy, so the projection sees the same jailed
|
|
4653
|
+
* A read-only projection over the SAME policy — only the read / list /
|
|
4654
|
+
* grep / glob / todo tools are vended and every mutating direct method
|
|
4655
|
+
* rejects with a {@link WorkspacePolicyError}. A fresh ops/backend is
|
|
4656
|
+
* built from the identical policy, so the projection sees the same jailed
|
|
4657
|
+
* tree; it shares this workspace's background commands and todo list.
|
|
1866
4658
|
*/
|
|
1867
4659
|
readonly() {
|
|
1868
|
-
return new WorkspaceImpl(this.policy, READONLY_TOOL_NAMES);
|
|
4660
|
+
return new WorkspaceImpl(this.policy, READONLY_TOOL_NAMES.filter((name) => this.allowedTools.has(name)), this.state);
|
|
1869
4661
|
}
|
|
1870
4662
|
/**
|
|
1871
4663
|
* A sub-jailed view rooted at `subdir` (relative to this workspace's
|
|
1872
4664
|
* `cwd`). Returns a brand-new workspace whose policy is this policy
|
|
1873
4665
|
* with `cwd` narrowed to `join(cwd, subdir)` — same backend selection,
|
|
1874
|
-
* same allow/deny/shell/read sub-policies, but a tighter jail root.
|
|
4666
|
+
* same allow/deny/shell/read sub-policies, but a tighter jail root. It
|
|
4667
|
+
* shares this workspace's background commands and todo list.
|
|
1875
4668
|
*/
|
|
1876
4669
|
scope(subdir) {
|
|
1877
4670
|
return new WorkspaceImpl({
|
|
1878
4671
|
...this.policy,
|
|
1879
4672
|
cwd: node_path.default.join(this.policy.cwd, subdir)
|
|
1880
|
-
}, [...this.allowedTools]);
|
|
4673
|
+
}, [...this.allowedTools], this.state);
|
|
1881
4674
|
}
|
|
1882
4675
|
};
|
|
1883
4676
|
/**
|
|
1884
4677
|
* Build a {@link Workspace} — the integrator that wires a
|
|
1885
4678
|
* {@link WorkspacePolicy} to a backend, the shared policy-enforced ops
|
|
1886
|
-
* layer, and the
|
|
4679
|
+
* layer, and the agent-facing tool factories.
|
|
1887
4680
|
*
|
|
1888
4681
|
* The backend is chosen from `policy.backend`: `"mock"` runs in memory
|
|
1889
4682
|
* (hermetic tests); the `"local"` default (and any absent value) runs
|
|
1890
4683
|
* over the real disk via `@warlock.js/fs` + `node:child_process`. The
|
|
1891
4684
|
* returned workspace exposes:
|
|
1892
4685
|
*
|
|
1893
|
-
* - **`tools.*`** — `readFile` / `editFile` / `
|
|
1894
|
-
* `
|
|
1895
|
-
* `
|
|
1896
|
-
*
|
|
1897
|
-
*
|
|
1898
|
-
*
|
|
1899
|
-
*
|
|
4686
|
+
* - **`tools.*`** — `readFile` / `editFile` / `multiEdit` / `writeFile` /
|
|
4687
|
+
* `listDir` / `runShell` / `shellOutput` / `shellKill` / `runTests` /
|
|
4688
|
+
* `grep` / `glob` / `todoWrite` / `todoRead`, plus `all()` (every tool)
|
|
4689
|
+
* and `pick(...)` (a least-privilege subset).
|
|
4690
|
+
* - **direct methods** — `readFile` / `writeFile` / `editFile` /
|
|
4691
|
+
* `multiEdit` / `listDir` / `exec` / `startShell` / `shellOutput` /
|
|
4692
|
+
* `shellKill` / `listShells` / `grep` / `glob` / `exists` / `mkdir` /
|
|
4693
|
+
* `remove` / `readTodos` / `writeTodos`, each delegating to the shared
|
|
4694
|
+
* ops layer, and `dispose()`.
|
|
4695
|
+
* - **`readonly()`** — a projection that vends only the read-side tools and
|
|
1900
4696
|
* rejects every mutating direct method.
|
|
1901
4697
|
* - **`scope(subdir)`** — a sub-jailed workspace rooted at `subdir`.
|
|
1902
4698
|
*
|
|
@@ -1921,9 +4717,13 @@ function workspace(policy) {
|
|
|
1921
4717
|
_warlock_js_ai.ai.workspace = workspace;
|
|
1922
4718
|
|
|
1923
4719
|
//#endregion
|
|
4720
|
+
exports.TodoStore = TodoStore;
|
|
1924
4721
|
exports.WorkspaceEditError = WorkspaceEditError;
|
|
4722
|
+
exports.WorkspaceJobError = WorkspaceJobError;
|
|
1925
4723
|
exports.WorkspacePolicyError = WorkspacePolicyError;
|
|
4724
|
+
exports.analyzeArgvCommand = analyzeArgvCommand;
|
|
1926
4725
|
exports.buildEnv = buildEnv;
|
|
4726
|
+
exports.checkShellCommand = checkShellCommand;
|
|
1927
4727
|
exports.createLocalBackend = createLocalBackend;
|
|
1928
4728
|
exports.createMockBackend = createMockBackend;
|
|
1929
4729
|
exports.createOps = createOps;
|
|
@@ -1931,10 +4731,17 @@ exports.isCommandAllowed = isCommandAllowed;
|
|
|
1931
4731
|
exports.makeEditFileTool = makeEditFileTool;
|
|
1932
4732
|
exports.makeGlobTool = makeGlobTool;
|
|
1933
4733
|
exports.makeGrepTool = makeGrepTool;
|
|
4734
|
+
exports.makeListDirTool = makeListDirTool;
|
|
4735
|
+
exports.makeMultiEditTool = makeMultiEditTool;
|
|
1934
4736
|
exports.makeReadFileTool = makeReadFileTool;
|
|
1935
4737
|
exports.makeRunShellTool = makeRunShellTool;
|
|
1936
4738
|
exports.makeRunTestsTool = makeRunTestsTool;
|
|
4739
|
+
exports.makeShellKillTool = makeShellKillTool;
|
|
4740
|
+
exports.makeShellOutputTool = makeShellOutputTool;
|
|
4741
|
+
exports.makeTodoReadTool = makeTodoReadTool;
|
|
4742
|
+
exports.makeTodoWriteTool = makeTodoWriteTool;
|
|
1937
4743
|
exports.makeWriteFileTool = makeWriteFileTool;
|
|
1938
4744
|
exports.resolveInJail = resolveInJail;
|
|
4745
|
+
exports.tokenizeCommand = tokenizeCommand;
|
|
1939
4746
|
exports.workspace = workspace;
|
|
1940
4747
|
//# sourceMappingURL=index.cjs.map
|