@warlock.js/ai-workspace 5.30.0 → 5.32.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 +52 -2
- package/README.md +34 -21
- package/cjs/index.cjs +3134 -315
- package/cjs/index.cjs.map +1 -1
- package/esm/backends/local.d.mts +1 -3
- package/esm/backends/local.d.mts.map +1 -1
- package/esm/backends/local.mjs +166 -28
- package/esm/backends/local.mjs.map +1 -1
- package/esm/backends/mock.d.mts +14 -9
- 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 +5 -4
- package/esm/contracts/tool-io.type.d.mts +216 -19
- package/esm/contracts/tool-io.type.d.mts.map +1 -1
- package/esm/contracts/workspace-backend.contract.d.mts +62 -4
- package/esm/contracts/workspace-backend.contract.d.mts.map +1 -1
- package/esm/contracts/workspace-ops.contract.d.mts +33 -6
- package/esm/contracts/workspace-ops.contract.d.mts.map +1 -1
- package/esm/contracts/workspace-policy.type.d.mts +136 -16
- package/esm/contracts/workspace-policy.type.d.mts.map +1 -1
- package/esm/contracts/workspace.contract.d.mts +65 -13
- package/esm/contracts/workspace.contract.d.mts.map +1 -1
- package/esm/errors.d.mts +103 -17
- 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 +14 -5
- package/esm/index.mjs +10 -2
- package/esm/ops.d.mts +21 -3
- 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 +30 -20
- package/esm/policy/policy.d.mts.map +1 -1
- package/esm/policy/policy.mjs +64 -87
- package/esm/policy/policy.mjs.map +1 -1
- package/esm/policy/shell-check.d.mts +63 -0
- package/esm/policy/shell-check.d.mts.map +1 -0
- package/esm/policy/shell-check.mjs +638 -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 +62 -0
- package/esm/policy/tokenize-command.d.mts.map +1 -0
- package/esm/policy/tokenize-command.mjs +76 -24
- 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 +2 -3
- 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 +3 -4
- 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 +3 -4
- 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 +31 -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 +37 -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 +2 -3
- 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 +36 -15
- 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 +11 -4
- 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 +24 -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 +29 -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 +42 -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 +2 -3
- 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 +14 -11
- 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
|
@@ -20,7 +20,7 @@ var __copyProps = (to, from, except, desc) => {
|
|
|
20
20
|
}
|
|
21
21
|
return to;
|
|
22
22
|
};
|
|
23
|
-
var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", {
|
|
23
|
+
var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(isNodeMode || !mod || !mod.__esModule || !__hasOwnProp.call(mod, "default") ? __defProp(target, "default", {
|
|
24
24
|
value: mod,
|
|
25
25
|
enumerable: true
|
|
26
26
|
}) : target, mod));
|
|
@@ -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 = /* @__PURE__ */ 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 = /* @__PURE__ */ 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
|
|
@@ -99,7 +990,7 @@ var WorkspaceEditError = class extends _warlock_js_ai.AIError {
|
|
|
99
990
|
* rejected instead of silently running commands past the allowlist. Inside
|
|
100
991
|
* quotes they are ordinary literal bytes and pass through as argument data.
|
|
101
992
|
*/
|
|
102
|
-
const UNQUOTED_METACHARACTERS = new Set([
|
|
993
|
+
const UNQUOTED_METACHARACTERS = /* @__PURE__ */ new Set([
|
|
103
994
|
";",
|
|
104
995
|
"&",
|
|
105
996
|
"|",
|
|
@@ -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 = /* @__PURE__ */ 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,639 @@ 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 = /* @__PURE__ */ 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 = /* @__PURE__ */ 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 = /* @__PURE__ */ 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 = /* @__PURE__ */ 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 = /* @__PURE__ */ 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 = /* @__PURE__ */ new Set([
|
|
1241
|
+
"/dev/null",
|
|
1242
|
+
"/dev/stdout",
|
|
1243
|
+
"/dev/stderr"
|
|
1244
|
+
]);
|
|
1245
|
+
/** cmd.exe's null device. */
|
|
1246
|
+
const CMD_DEVICE_TARGETS = /* @__PURE__ */ 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
|
+
const explanation = typeof verdict === "string" && verdict.trim() !== "" ? ` ${verdict.trim()}` : "";
|
|
1346
|
+
throw new Refusal("not-allowed", `${shown} was refused by the workspace shell policy.${explanation}`, executableBasename(argv[0] ?? "", this.dialect));
|
|
1347
|
+
}
|
|
1348
|
+
/** Whether the policy names `executable` in a non-empty `allow` list. */
|
|
1349
|
+
explicitlyAllowed(executable) {
|
|
1350
|
+
return this.allow.includes(executable);
|
|
1351
|
+
}
|
|
1352
|
+
/** Refuse a command runner the policy cannot see into, unless explicitly allowed. */
|
|
1353
|
+
checkOpaque(executable, argv) {
|
|
1354
|
+
if (this.explicitlyAllowed(executable) || !this.hasCommandRules) return;
|
|
1355
|
+
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);
|
|
1356
|
+
}
|
|
1357
|
+
/**
|
|
1358
|
+
* Check one argv: `deny`, `allow`, `denyPatterns`, `allowCommand`, then
|
|
1359
|
+
* follow wrappers and `find -exec` to the command they run. `dynamic[i]`
|
|
1360
|
+
* says whether `argv[i]` is decided at run time (shell mode).
|
|
1361
|
+
*/
|
|
1362
|
+
checkArgv(argv, dynamic, depth) {
|
|
1363
|
+
const first = argv[0] ?? "";
|
|
1364
|
+
const executable = executableBasename(first, this.dialect);
|
|
1365
|
+
const key = executable.toLowerCase();
|
|
1366
|
+
if (executable === "") throw new Refusal("not-allowed", "The command has no executable name.");
|
|
1367
|
+
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.`);
|
|
1368
|
+
if (this.deny.has(key)) throw new Refusal("denied-executable", `\`${executable}\` is denied by the workspace shell policy (in ${show(argv.join(" "))}).`, executable);
|
|
1369
|
+
if (this.allow.length > 0 && !this.allow.includes(executable)) {
|
|
1370
|
+
const listed = this.allow.slice(0, 20).join(", ");
|
|
1371
|
+
throw new Refusal("not-allowed", `\`${executable}\` is not in the workspace shell allowlist. Allowed commands: ${listed}${this.allow.length > 20 ? ", …" : ""}.`, executable);
|
|
1372
|
+
}
|
|
1373
|
+
this.checkHooks(argv, depth > 0);
|
|
1374
|
+
this.commands.push([...argv]);
|
|
1375
|
+
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)) {
|
|
1376
|
+
this.checkOpaque(executable, argv);
|
|
1377
|
+
return;
|
|
1378
|
+
}
|
|
1379
|
+
if (this.shellMode && this.dialect === "posix" && ARITHMETIC_SENSITIVE_BUILTINS.has(key)) this.checkArithmeticSensitive(executable, argv, dynamic);
|
|
1380
|
+
const ranges = this.unwrap(key, argv);
|
|
1381
|
+
if (ranges === void 0) return;
|
|
1382
|
+
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);
|
|
1383
|
+
for (const [start, end] of ranges) this.checkArgv(argv.slice(start, end), dynamic.slice(start, end), depth + 1);
|
|
1384
|
+
}
|
|
1385
|
+
/**
|
|
1386
|
+
* Builtins bash may evaluate arguments of as arithmetic (array subscripts
|
|
1387
|
+
* like `a[$(cmd)]` run the substitution): arguments must be literal, free
|
|
1388
|
+
* of `[`, and must not name a variable that changes how the shell runs.
|
|
1389
|
+
*/
|
|
1390
|
+
checkArithmeticSensitive(executable, argv, dynamic) {
|
|
1391
|
+
for (let index = 1; index < argv.length; index++) {
|
|
1392
|
+
const argument = argv[index] ?? "";
|
|
1393
|
+
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);
|
|
1394
|
+
if (argument.includes("[") && argument !== "[" && argument !== "]") throw new Refusal("unsupported-syntax", `\`${executable}\` arguments with \`[\` (array subscripts, evaluated as arithmetic) are not supported.`, executable);
|
|
1395
|
+
const name = argument.split("=")[0] ?? "";
|
|
1396
|
+
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);
|
|
1397
|
+
}
|
|
1398
|
+
}
|
|
1399
|
+
/**
|
|
1400
|
+
* The argv ranges a wrapper runs. `undefined` — not a wrapper (or one that
|
|
1401
|
+
* runs nothing, like a bare `env`); `null` — a wrapper whose options the
|
|
1402
|
+
* policy cannot read (refused).
|
|
1403
|
+
*/
|
|
1404
|
+
unwrap(key, argv) {
|
|
1405
|
+
switch (key) {
|
|
1406
|
+
case "env": return suffixFrom(argv, unwrapEnv(argv));
|
|
1407
|
+
case "nice": return suffixFrom(argv, unwrapNice(argv));
|
|
1408
|
+
case "nohup": return suffixFrom(argv, unwrapSimple(argv, []));
|
|
1409
|
+
case "time": return suffixFrom(argv, unwrapSimple(argv, ["-p"]));
|
|
1410
|
+
case "timeout": return suffixFrom(argv, unwrapTimeout(argv));
|
|
1411
|
+
case "command": return argv.slice(1).some((arg) => arg === "-v" || arg === "-V") ? void 0 : suffixFrom(argv, unwrapSimple(argv, ["-p"]));
|
|
1412
|
+
case "builtin": return suffixFrom(argv, unwrapSimple(argv, []));
|
|
1413
|
+
case "exec": return this.dialect === "posix" ? suffixFrom(argv, unwrapExec(argv)) : void 0;
|
|
1414
|
+
case "find": return unwrapFind(argv);
|
|
1415
|
+
default: return;
|
|
1416
|
+
}
|
|
1417
|
+
}
|
|
1418
|
+
/** Check a `cd` (shell mode): literal, one operand, inside the jail from every possible cwd. */
|
|
1419
|
+
checkCd(words, cwds) {
|
|
1420
|
+
const argv = words.map((word) => word.value);
|
|
1421
|
+
if (this.deny.has("cd")) throw new Refusal("denied-executable", "`cd` is denied by the workspace shell policy.", "cd");
|
|
1422
|
+
this.checkHooks(argv, false);
|
|
1423
|
+
this.commands.push(argv);
|
|
1424
|
+
const operands = words.slice(1);
|
|
1425
|
+
while (this.dialect === "posix" && operands.length > 0 && [
|
|
1426
|
+
"-P",
|
|
1427
|
+
"-L",
|
|
1428
|
+
"--"
|
|
1429
|
+
].includes(operands[0]?.value ?? "")) operands.shift();
|
|
1430
|
+
const target = operands[0];
|
|
1431
|
+
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");
|
|
1432
|
+
const next = new Set(cwds);
|
|
1433
|
+
for (const cwd of cwds) {
|
|
1434
|
+
const resolved = this.resolveTarget(cwd, target.value);
|
|
1435
|
+
this.assertInJail(resolved, target.value, "cd");
|
|
1436
|
+
this.paths.push({
|
|
1437
|
+
path: resolved,
|
|
1438
|
+
access: "cwd",
|
|
1439
|
+
target: target.value
|
|
1440
|
+
});
|
|
1441
|
+
next.add(resolved);
|
|
1442
|
+
}
|
|
1443
|
+
if (next.size > MAX_CD_CANDIDATES) throw new Refusal("unsupported-syntax", "The line changes directory too many times.", "cd");
|
|
1444
|
+
return [...next];
|
|
1445
|
+
}
|
|
1446
|
+
/** Check a redirection target from every possible cwd. */
|
|
1447
|
+
checkRedirectTarget(word, access, cwds) {
|
|
1448
|
+
if (word.dynamic) throw new Refusal("unsupported-syntax", `The redirection target \`${word.raw}\` must be a literal path (no variables, globs or \`~\`).`);
|
|
1449
|
+
const value = word.value;
|
|
1450
|
+
if (this.dialect === "posix" && DEVICE_TARGETS.has(value) || this.dialect === "cmd" && CMD_DEVICE_TARGETS.has(value.toLowerCase())) return;
|
|
1451
|
+
for (const cwd of cwds) {
|
|
1452
|
+
const resolved = this.resolveTarget(cwd, value);
|
|
1453
|
+
this.assertInJail(resolved, value, "redirect");
|
|
1454
|
+
this.paths.push({
|
|
1455
|
+
path: resolved,
|
|
1456
|
+
access,
|
|
1457
|
+
target: value
|
|
1458
|
+
});
|
|
1459
|
+
}
|
|
1460
|
+
}
|
|
1461
|
+
resolveTarget(cwd, target) {
|
|
1462
|
+
return node_path.default.resolve(cwd, target);
|
|
1463
|
+
}
|
|
1464
|
+
assertInJail(resolved, target, kind) {
|
|
1465
|
+
if (isInsideJailLexically(this.policy, resolved)) return;
|
|
1466
|
+
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);
|
|
1467
|
+
}
|
|
1468
|
+
};
|
|
1469
|
+
/**
|
|
1470
|
+
* Turn a wrapper's "inner command starts at `start`" into a range; a
|
|
1471
|
+
* wrapper that runs nothing (`start === argv.length`) becomes `undefined`.
|
|
1472
|
+
*/
|
|
1473
|
+
function suffixFrom(argv, start) {
|
|
1474
|
+
if (start === null) return null;
|
|
1475
|
+
return start < argv.length ? [[start, argv.length]] : void 0;
|
|
1476
|
+
}
|
|
1477
|
+
/** `nohup` / `time` / `command` / `builtin`: skip the listed flags and `--`. */
|
|
1478
|
+
function unwrapSimple(argv, flags) {
|
|
1479
|
+
let index = 1;
|
|
1480
|
+
while (index < argv.length) {
|
|
1481
|
+
const arg = argv[index] ?? "";
|
|
1482
|
+
if (arg === "--") {
|
|
1483
|
+
index++;
|
|
1484
|
+
break;
|
|
1485
|
+
}
|
|
1486
|
+
if (flags.includes(arg)) {
|
|
1487
|
+
index++;
|
|
147
1488
|
continue;
|
|
148
1489
|
}
|
|
149
|
-
if (
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
1490
|
+
if (arg.startsWith("-") && arg !== "-") return null;
|
|
1491
|
+
break;
|
|
1492
|
+
}
|
|
1493
|
+
return index;
|
|
1494
|
+
}
|
|
1495
|
+
/** `env [-i] [-u NAME] [NAME=value]… command…` */
|
|
1496
|
+
function unwrapEnv(argv) {
|
|
1497
|
+
let index = 1;
|
|
1498
|
+
while (index < argv.length) {
|
|
1499
|
+
const arg = argv[index] ?? "";
|
|
1500
|
+
if (arg === "-i" || arg === "-" || arg === "--ignore-environment") {
|
|
1501
|
+
index++;
|
|
1502
|
+
continue;
|
|
1503
|
+
}
|
|
1504
|
+
if (arg === "-u" || arg === "--unset") {
|
|
1505
|
+
index += 2;
|
|
1506
|
+
continue;
|
|
1507
|
+
}
|
|
1508
|
+
if (arg.startsWith("--unset=") || arg.startsWith("-u") && arg.length > 2) {
|
|
1509
|
+
index++;
|
|
1510
|
+
continue;
|
|
1511
|
+
}
|
|
1512
|
+
if (arg === "--") {
|
|
1513
|
+
index++;
|
|
1514
|
+
break;
|
|
1515
|
+
}
|
|
1516
|
+
if (arg.startsWith("-")) return null;
|
|
1517
|
+
break;
|
|
1518
|
+
}
|
|
1519
|
+
while (index < argv.length && /^[^=]+=/.test(argv[index] ?? "")) index++;
|
|
1520
|
+
return index;
|
|
1521
|
+
}
|
|
1522
|
+
/** `nice [-n N | -N | --adjustment=N] command…` */
|
|
1523
|
+
function unwrapNice(argv) {
|
|
1524
|
+
let index = 1;
|
|
1525
|
+
while (index < argv.length) {
|
|
1526
|
+
const arg = argv[index] ?? "";
|
|
1527
|
+
if (arg === "-n" || arg === "--adjustment") {
|
|
1528
|
+
index += 2;
|
|
1529
|
+
continue;
|
|
1530
|
+
}
|
|
1531
|
+
if (/^-n-?[0-9]+$/.test(arg) || /^--adjustment=-?[0-9]+$/.test(arg) || /^-[0-9]+$/.test(arg)) {
|
|
1532
|
+
index++;
|
|
1533
|
+
continue;
|
|
1534
|
+
}
|
|
1535
|
+
if (arg === "--") {
|
|
1536
|
+
index++;
|
|
1537
|
+
break;
|
|
1538
|
+
}
|
|
1539
|
+
if (arg.startsWith("-")) return null;
|
|
1540
|
+
break;
|
|
1541
|
+
}
|
|
1542
|
+
return index;
|
|
1543
|
+
}
|
|
1544
|
+
/** `timeout [options] DURATION command…` */
|
|
1545
|
+
function unwrapTimeout(argv) {
|
|
1546
|
+
let index = 1;
|
|
1547
|
+
while (index < argv.length) {
|
|
1548
|
+
const arg = argv[index] ?? "";
|
|
1549
|
+
if (arg === "-s" || arg === "--signal" || arg === "-k" || arg === "--kill-after") {
|
|
1550
|
+
index += 2;
|
|
1551
|
+
continue;
|
|
1552
|
+
}
|
|
1553
|
+
if (/^-[sk].+$/.test(arg) || arg.startsWith("--signal=") || arg.startsWith("--kill-after=") || arg === "--preserve-status" || arg === "--foreground" || arg === "-v" || arg === "--verbose") {
|
|
1554
|
+
index++;
|
|
1555
|
+
continue;
|
|
1556
|
+
}
|
|
1557
|
+
if (arg === "--") {
|
|
1558
|
+
index++;
|
|
1559
|
+
break;
|
|
1560
|
+
}
|
|
1561
|
+
if (arg.startsWith("-")) return null;
|
|
1562
|
+
break;
|
|
1563
|
+
}
|
|
1564
|
+
if (index >= argv.length - 1) return null;
|
|
1565
|
+
return index + 1;
|
|
1566
|
+
}
|
|
1567
|
+
/** `exec [-cl] [-a name] command…` (bash). */
|
|
1568
|
+
function unwrapExec(argv) {
|
|
1569
|
+
let index = 1;
|
|
1570
|
+
while (index < argv.length) {
|
|
1571
|
+
const arg = argv[index] ?? "";
|
|
1572
|
+
if (arg === "-c" || arg === "-l" || arg === "-cl" || arg === "-lc") {
|
|
1573
|
+
index++;
|
|
1574
|
+
continue;
|
|
1575
|
+
}
|
|
1576
|
+
if (arg === "-a") {
|
|
1577
|
+
index += 2;
|
|
1578
|
+
continue;
|
|
1579
|
+
}
|
|
1580
|
+
if (arg === "--") {
|
|
155
1581
|
index++;
|
|
156
|
-
|
|
1582
|
+
break;
|
|
157
1583
|
}
|
|
158
|
-
if (
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
1584
|
+
if (arg.startsWith("-")) return null;
|
|
1585
|
+
break;
|
|
1586
|
+
}
|
|
1587
|
+
return index;
|
|
1588
|
+
}
|
|
1589
|
+
/** The commands `find -exec / -execdir / -ok / -okdir … ;|+` runs. */
|
|
1590
|
+
function unwrapFind(argv) {
|
|
1591
|
+
const inner = [];
|
|
1592
|
+
for (let index = 1; index < argv.length; index++) {
|
|
1593
|
+
const arg = argv[index] ?? "";
|
|
1594
|
+
if (arg !== "-exec" && arg !== "-execdir" && arg !== "-ok" && arg !== "-okdir") continue;
|
|
1595
|
+
const start = index + 1;
|
|
1596
|
+
let end = start;
|
|
1597
|
+
while (end < argv.length && argv[end] !== ";" && argv[end] !== "+") end++;
|
|
1598
|
+
if (end >= argv.length || end === start) return null;
|
|
1599
|
+
inner.push([start, end]);
|
|
1600
|
+
index = end;
|
|
1601
|
+
}
|
|
1602
|
+
return inner.length > 0 ? inner : void 0;
|
|
1603
|
+
}
|
|
1604
|
+
/** Build the argv-mode refusal for a line that is not a single argv. */
|
|
1605
|
+
function argvModeRefusal(command) {
|
|
1606
|
+
const analysis = analyzeArgvCommand(command);
|
|
1607
|
+
if (analysis.ok) return new Refusal("metacharacter", "The command cannot run as a single argv.");
|
|
1608
|
+
if (analysis.type === "empty") return new Refusal("not-allowed", "The command is empty.");
|
|
1609
|
+
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.`);
|
|
1610
|
+
const token = analysis.token;
|
|
1611
|
+
const suffix = " (run_shell executes a single command without a shell.)";
|
|
1612
|
+
const parsed = parseShellLine(command, "posix");
|
|
1613
|
+
const segments = parsed.ok ? parsed.segments : [];
|
|
1614
|
+
if ((token === "&&" || token === "||" || token === ";" || token === "\n") && segments.length >= 2) {
|
|
1615
|
+
const shownOperator = token === "\n" ? "A line break" : `\`${token}\``;
|
|
1616
|
+
if ((segments[0]?.source ?? "").trim().split(/\s+/)[0] === "cd") {
|
|
1617
|
+
const rest = segments.slice(1).map((segment) => show(segment.source));
|
|
1618
|
+
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 ${rest.join(", then ")} on its own, with paths relative to the root.${suffix}`);
|
|
1619
|
+
}
|
|
1620
|
+
const steps = segments.map((segment) => show(segment.source));
|
|
1621
|
+
const joiner = token === "||" ? ", and if it fails, " : ", then ";
|
|
1622
|
+
return new Refusal("metacharacter", `${shownOperator} is not supported in argv mode: run ${steps.join(joiner)}, as separate commands.${suffix}`);
|
|
1623
|
+
}
|
|
1624
|
+
if (token === "&" && segments.length >= 1) {
|
|
1625
|
+
const steps = segments.map((segment) => show(segment.source));
|
|
1626
|
+
const run = steps.length === 1 ? `run ${steps[0] ?? ""} without it` : `run ${steps.join(", then ")} as separate commands`;
|
|
1627
|
+
return new Refusal("metacharacter", `\`&\` is not supported in argv mode: ${run}; start a long-running command with background: true.${suffix}`);
|
|
1628
|
+
}
|
|
1629
|
+
if (token === "|") {
|
|
1630
|
+
const pipeline = command.split("|").map((part) => part.trim()).filter(Boolean);
|
|
1631
|
+
return new Refusal("metacharacter", `\`|\` (a pipe) is not supported in argv mode: run ${show(pipeline[0] ?? command)} on its own and read its output yourself.${suffix}`);
|
|
1632
|
+
}
|
|
1633
|
+
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}`);
|
|
1634
|
+
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}`);
|
|
1635
|
+
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}`);
|
|
1636
|
+
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}`);
|
|
1637
|
+
if (token === "(" || token === ")") return new Refusal("metacharacter", `\`${token}\` is not supported in argv mode: put it inside quotes to pass it as text.${suffix}`);
|
|
1638
|
+
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}`);
|
|
1639
|
+
}
|
|
1640
|
+
/** Check a shell-mode (`interpreter: "sh"`) line. */
|
|
1641
|
+
function checkShellLine(checker, policy, command, dialect) {
|
|
1642
|
+
if (/^[-+]/.test(command)) throw new Refusal("unsupported-syntax", "A command line may not start with `-` or `+`.");
|
|
1643
|
+
const parsed = parseShellLine(command, dialect);
|
|
1644
|
+
if (!parsed.ok) throw new Refusal(parsed.reason, `${parsed.message} The workspace shell policy only runs lines it can check.`);
|
|
1645
|
+
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.");
|
|
1646
|
+
let cwds = [node_path.default.resolve(policy.cwd)];
|
|
1647
|
+
for (const simple of parsed.commands) cwds = checkSimpleCommand(checker, simple, cwds, dialect);
|
|
1648
|
+
}
|
|
1649
|
+
/** Check one parsed simple command; returns the possible working directories after it. */
|
|
1650
|
+
function checkSimpleCommand(checker, simple, cwds, dialect) {
|
|
1651
|
+
for (const assignment of simple.assignments) {
|
|
1652
|
+
const name = assignment.assignmentName ?? "";
|
|
1653
|
+
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.`);
|
|
1654
|
+
}
|
|
1655
|
+
for (const redirect of simple.redirects) if (redirect.type === "read" || redirect.type === "write") checker.checkRedirectTarget(redirect.target, redirect.type, cwds);
|
|
1656
|
+
const first = simple.words[0];
|
|
1657
|
+
if (first === void 0) return cwds;
|
|
1658
|
+
if (!first.dynamic && first.value.includes("=")) throw new Refusal("unsupported-syntax", `\`${first.raw}\` is not a plain command name or \`NAME=value\` assignment.`);
|
|
1659
|
+
if (/^[-+]/.test(first.value)) throw new Refusal("unsupported-syntax", `\`${first.raw}\` is not a command name.`);
|
|
1660
|
+
if (checker.hasArgumentHooks) {
|
|
1661
|
+
const dynamic = simple.words.find((word) => word.dynamic);
|
|
1662
|
+
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).`);
|
|
1663
|
+
}
|
|
1664
|
+
const executable = first.dynamic ? "" : executableBasename(first.value, dialect).toLowerCase();
|
|
1665
|
+
if (executable === "cd") return checker.checkCd(simple.words, cwds);
|
|
1666
|
+
if (dialect === "posix" && (executable === "pushd" || executable === "popd")) throw new Refusal("unsupported-syntax", `\`${executable}\` is not supported; use \`cd\`.`);
|
|
1667
|
+
checker.checkArgv(simple.words.map((word) => word.dynamic ? word.raw : word.value), simple.words.map((word) => word.dynamic), 0);
|
|
1668
|
+
return cwds;
|
|
1669
|
+
}
|
|
1670
|
+
/**
|
|
1671
|
+
* The shell policy's verdict on a command line, without touching the
|
|
1672
|
+
* filesystem.
|
|
1673
|
+
*
|
|
1674
|
+
* - **argv mode** (`interpreter: "none"`, the default): the line must
|
|
1675
|
+
* tokenize into one argv; otherwise it is refused with a
|
|
1676
|
+
* `"metacharacter"` / `"unbalanced-quotes"` reason and a message that
|
|
1677
|
+
* names the operator and suggests the split.
|
|
1678
|
+
* - **shell mode** (`interpreter: "sh"`): the line is parsed and every
|
|
1679
|
+
* simple command — inside `;`, `&&`, `||`, pipes, subshells, `$( )`,
|
|
1680
|
+
* backticks and here-documents — is checked; redirection targets and `cd`
|
|
1681
|
+
* directories must stay in the jail (checked lexically here; `exec`
|
|
1682
|
+
* re-checks them with symlinks resolved).
|
|
1683
|
+
*
|
|
1684
|
+
* Every argv goes through `deny` (case-insensitive, wins), `allow`
|
|
1685
|
+
* (exhaustive when non-empty), `denyPatterns` and `allowCommand`.
|
|
1686
|
+
* Wrappers (`env`, `time`, `timeout`, `nice`, `nohup`, `command`,
|
|
1687
|
+
* `builtin`, `exec`) and `find -exec` are followed to the command they run.
|
|
1688
|
+
* A runner the policy cannot see into (`bash -c`, `eval`, `xargs`, `sudo`,
|
|
1689
|
+
* …) is refused while the policy has deny rules, unless `allow` names it.
|
|
1690
|
+
*
|
|
1691
|
+
* @example
|
|
1692
|
+
* checkShellCommand({ cwd, shell: { allow: ["git"] } }, "git add -A && git commit -m wip");
|
|
1693
|
+
* // { allowed: false, reason: "metacharacter", message: "`&&` is not supported in argv mode: run `git add -A`, then `git commit -m wip`, …" }
|
|
1694
|
+
*/
|
|
1695
|
+
function checkShellCommand(policy, command) {
|
|
1696
|
+
return checkShellCommandInDialect(policy, command, shellDialect());
|
|
1697
|
+
}
|
|
1698
|
+
/**
|
|
1699
|
+
* {@link checkShellCommand} with the shell-mode dialect chosen explicitly
|
|
1700
|
+
* instead of from the platform (used to test the cmd.exe rules anywhere).
|
|
1701
|
+
* Argv mode ignores `dialect`.
|
|
1702
|
+
*/
|
|
1703
|
+
function checkShellCommandInDialect(policy, command, dialect) {
|
|
1704
|
+
const shell = policy.shell;
|
|
1705
|
+
if (!shell) return {
|
|
1706
|
+
allowed: false,
|
|
1707
|
+
reason: "not-allowed",
|
|
1708
|
+
message: "The workspace has no shell policy, so no command may run.",
|
|
1709
|
+
commands: [],
|
|
1710
|
+
paths: []
|
|
1711
|
+
};
|
|
1712
|
+
const interpreter = shell.interpreter ?? "none";
|
|
1713
|
+
const checker = new LineChecker(policy, shell, command, interpreter === "sh" ? dialect : "posix");
|
|
1714
|
+
try {
|
|
1715
|
+
if (command.trim() === "") throw new Refusal("not-allowed", "The command is empty.");
|
|
1716
|
+
if (interpreter === "sh") checkShellLine(checker, policy, command, dialect);
|
|
1717
|
+
else {
|
|
1718
|
+
const argv = tokenizeCommand(command);
|
|
1719
|
+
if (argv === null) throw argvModeRefusal(command);
|
|
1720
|
+
checker.checkArgv(argv, argv.map(() => false), 0);
|
|
1721
|
+
}
|
|
1722
|
+
return {
|
|
1723
|
+
allowed: true,
|
|
1724
|
+
commands: checker.commands,
|
|
1725
|
+
paths: checker.paths
|
|
1726
|
+
};
|
|
1727
|
+
} catch (error) {
|
|
1728
|
+
if (!(error instanceof Refusal)) throw error;
|
|
1729
|
+
return {
|
|
1730
|
+
allowed: false,
|
|
1731
|
+
reason: error.reason,
|
|
1732
|
+
message: error.message,
|
|
1733
|
+
...error.executable !== void 0 ? { executable: error.executable } : {},
|
|
1734
|
+
...error.refusedPath !== void 0 ? { path: error.refusedPath } : {},
|
|
1735
|
+
commands: checker.commands,
|
|
1736
|
+
paths: checker.paths
|
|
1737
|
+
};
|
|
162
1738
|
}
|
|
163
|
-
if (inToken) argv.push(current);
|
|
164
|
-
return argv.length > 0 ? argv : null;
|
|
165
1739
|
}
|
|
166
1740
|
|
|
167
1741
|
//#endregion
|
|
@@ -188,55 +1762,6 @@ async function canonicalize$1(target) {
|
|
|
188
1762
|
}
|
|
189
1763
|
}
|
|
190
1764
|
/**
|
|
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
1765
|
* Resolve and jail a single input path against a {@link WorkspacePolicy}.
|
|
241
1766
|
*
|
|
242
1767
|
* The input is resolved against `policy.cwd`, canonicalized (symlinks in
|
|
@@ -246,6 +1771,17 @@ function matchesDeny(relativePath, denyPaths) {
|
|
|
246
1771
|
* `denyPaths` glob even while inside `cwd`, is rejected with a
|
|
247
1772
|
* {@link WorkspacePolicyError} of type `"path-escape"`.
|
|
248
1773
|
*
|
|
1774
|
+
* Why this does not use `@warlock.js/fs`'s confined reader (`fs.confined`):
|
|
1775
|
+
* the two answer different questions. The confined reader refuses absolute
|
|
1776
|
+
* paths, refuses every symbolic link (or follows only links inside its one
|
|
1777
|
+
* root), requires the path to exist, and reads the file itself through a
|
|
1778
|
+
* no-follow, identity-checked open. This jail must instead accept absolute
|
|
1779
|
+
* paths inside `cwd`, several `allowPaths` roots, `denyPaths` globs, a
|
|
1780
|
+
* symlinked `cwd`, links whose target stays inside, and paths that do not
|
|
1781
|
+
* exist yet (`writeFile`, `mkdir`), and it returns a path that writes,
|
|
1782
|
+
* `exec`'s working directory and redirections use afterwards. Switching would
|
|
1783
|
+
* change what agents may touch, so the workspace keeps this resolver.
|
|
1784
|
+
*
|
|
249
1785
|
* @param policy - The bounding policy (its `cwd` is the jail root).
|
|
250
1786
|
* @param inputPath - A workspace-relative or absolute path to resolve.
|
|
251
1787
|
* @returns The canonical absolute path plus its `/`-separated relative form.
|
|
@@ -260,8 +1796,9 @@ async function resolveInJail(policy, inputPath) {
|
|
|
260
1796
|
const insideCwd = isInside(absolutePath, jailRoot);
|
|
261
1797
|
const allowRoots = policy.allowPaths ?? [];
|
|
262
1798
|
let insideAllow = false;
|
|
263
|
-
if (!insideCwd) {
|
|
264
|
-
|
|
1799
|
+
if (!insideCwd) for (const root of allowRoots) {
|
|
1800
|
+
const canonicalRoot = await canonicalize$1(root);
|
|
1801
|
+
if (isInside(absolutePath, canonicalRoot)) {
|
|
265
1802
|
insideAllow = true;
|
|
266
1803
|
break;
|
|
267
1804
|
}
|
|
@@ -272,7 +1809,7 @@ async function resolveInJail(policy, inputPath) {
|
|
|
272
1809
|
});
|
|
273
1810
|
const relativeToJail = insideCwd ? node_path.default.relative(jailRoot, absolutePath).split(node_path.default.sep).join("/") : "";
|
|
274
1811
|
if (insideCwd && policy.denyPaths && policy.denyPaths.length > 0) {
|
|
275
|
-
if (
|
|
1812
|
+
if (matchesAnyGlob(relativeToJail, policy.denyPaths)) throw new WorkspacePolicyError(`Path "${inputPath}" is blocked by the workspace deny list.`, {
|
|
276
1813
|
type: "path-escape",
|
|
277
1814
|
path: inputPath
|
|
278
1815
|
});
|
|
@@ -283,32 +1820,22 @@ async function resolveInJail(policy, inputPath) {
|
|
|
283
1820
|
};
|
|
284
1821
|
}
|
|
285
1822
|
/**
|
|
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.
|
|
1823
|
+
* Whether a command line is permitted by the policy's `shell` sub-policy —
|
|
1824
|
+
* `checkShellCommand(policy, command).allowed`.
|
|
296
1825
|
*
|
|
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.
|
|
1826
|
+
* In argv mode (the default) the command must tokenize into a single argv
|
|
1827
|
+
* (no unquoted `;`, `&&`, `|`, backticks, `$`, redirection, …): the backend
|
|
1828
|
+
* spawns it with no shell, and unquoted metacharacters were exactly how an
|
|
1829
|
+
* injected command chain used to ride past the allowlist. In shell mode
|
|
1830
|
+
* (`interpreter: "sh"`) every simple command in the line is checked. Each
|
|
1831
|
+
* argv's executable basename is matched against `shell.deny` (wins, case
|
|
1832
|
+
* insensitive) then `shell.allow` (exhaustive when non-empty), then
|
|
1833
|
+
* `shell.denyPatterns` and `shell.allowCommand`. An absent `shell` block
|
|
1834
|
+
* means no command may run at all.
|
|
308
1835
|
*
|
|
309
|
-
* Returns a plain `boolean
|
|
310
|
-
* the
|
|
311
|
-
*
|
|
1836
|
+
* Returns a plain `boolean`; use {@link checkShellCommand} for the reason
|
|
1837
|
+
* and the model-facing message. Redirection targets are checked lexically
|
|
1838
|
+
* here; `exec` re-checks them with symlinks resolved.
|
|
312
1839
|
*
|
|
313
1840
|
* @example
|
|
314
1841
|
* isCommandAllowed({ cwd, shell: { allow: ["npm"] } }, "npm test"); // true
|
|
@@ -316,15 +1843,7 @@ function executableBasename(firstToken) {
|
|
|
316
1843
|
* isCommandAllowed({ cwd, shell: { allow: ["npm"] } }, "npm test; rm -rf /"); // false
|
|
317
1844
|
*/
|
|
318
1845
|
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;
|
|
1846
|
+
return checkShellCommand(policy, command).allowed;
|
|
328
1847
|
}
|
|
329
1848
|
/**
|
|
330
1849
|
* Build the exact environment a spawned process receives — `process.env`
|
|
@@ -349,6 +1868,280 @@ function buildEnv(policy) {
|
|
|
349
1868
|
if (shell.env) for (const [key, value] of Object.entries(shell.env)) env[key] = value;
|
|
350
1869
|
return env;
|
|
351
1870
|
}
|
|
1871
|
+
/**
|
|
1872
|
+
* Variables removed from the environment of a shell-mode (`interpreter:
|
|
1873
|
+
* "sh"`) command: each changes how the shell itself starts, parses or
|
|
1874
|
+
* resolves the line (`ENV`/`BASH_ENV` run a startup file, `SHELLOPTS` +
|
|
1875
|
+
* `PS4` can run command substitutions, `BASH_FUNC_*` imports functions that
|
|
1876
|
+
* shadow commands, `CDPATH` moves `cd`, `IFS` re-splits words).
|
|
1877
|
+
*/
|
|
1878
|
+
const SHELL_STARTUP_VARIABLES = /* @__PURE__ */ new Set([
|
|
1879
|
+
"ENV",
|
|
1880
|
+
"BASH_ENV",
|
|
1881
|
+
"SHELLOPTS",
|
|
1882
|
+
"BASHOPTS",
|
|
1883
|
+
"CDPATH",
|
|
1884
|
+
"IFS",
|
|
1885
|
+
"PS4",
|
|
1886
|
+
"GLOBIGNORE"
|
|
1887
|
+
]);
|
|
1888
|
+
/**
|
|
1889
|
+
* The environment a command actually receives: {@link buildEnv}, minus the
|
|
1890
|
+
* shell start-up variables when the policy runs lines through a shell.
|
|
1891
|
+
* Compared case-insensitively, because Windows environment names are.
|
|
1892
|
+
*/
|
|
1893
|
+
function buildCommandEnv(policy) {
|
|
1894
|
+
const env = buildEnv(policy);
|
|
1895
|
+
if ((policy.shell?.interpreter ?? "none") !== "sh") return env;
|
|
1896
|
+
for (const key of Object.keys(env)) {
|
|
1897
|
+
const upper = key.toUpperCase();
|
|
1898
|
+
if (SHELL_STARTUP_VARIABLES.has(upper) || upper.startsWith("BASH_FUNC_")) delete env[key];
|
|
1899
|
+
}
|
|
1900
|
+
return env;
|
|
1901
|
+
}
|
|
1902
|
+
|
|
1903
|
+
//#endregion
|
|
1904
|
+
//#region ../../packages/ai-workspace/src/shell-jobs.ts
|
|
1905
|
+
/** Finished commands kept for a last `shell_output` read before the oldest are pruned. */
|
|
1906
|
+
const MAX_FINISHED_JOBS = 16;
|
|
1907
|
+
/** Longest `waitMs` a `shellOutput` call may ask for. */
|
|
1908
|
+
const MAX_OUTPUT_WAIT_MS = 3e4;
|
|
1909
|
+
/** How long `kill()` / `dispose()` wait for a killed tree to report its exit. */
|
|
1910
|
+
const KILL_SETTLE_MS = 5e3;
|
|
1911
|
+
/** Resolve after `ms`, without keeping the event loop alive. */
|
|
1912
|
+
function delay(ms) {
|
|
1913
|
+
return new Promise((resolve) => {
|
|
1914
|
+
setTimeout(resolve, ms).unref?.();
|
|
1915
|
+
});
|
|
1916
|
+
}
|
|
1917
|
+
/**
|
|
1918
|
+
* Keep the last `maxBytes` UTF-8 bytes of `text`, cutting forward to a
|
|
1919
|
+
* character boundary so the kept tail never starts mid-character.
|
|
1920
|
+
*/
|
|
1921
|
+
function tailUtf8(text, maxBytes) {
|
|
1922
|
+
const buffer = Buffer.from(text, "utf8");
|
|
1923
|
+
if (buffer.byteLength <= maxBytes) return {
|
|
1924
|
+
text,
|
|
1925
|
+
bytes: buffer.byteLength
|
|
1926
|
+
};
|
|
1927
|
+
let start = buffer.byteLength - maxBytes;
|
|
1928
|
+
while (start < buffer.byteLength && ((buffer[start] ?? 0) & 192) === 128) start++;
|
|
1929
|
+
const kept = buffer.subarray(start);
|
|
1930
|
+
return {
|
|
1931
|
+
text: kept.toString("utf8"),
|
|
1932
|
+
bytes: kept.byteLength
|
|
1933
|
+
};
|
|
1934
|
+
}
|
|
1935
|
+
/** Bytes of `text` from byte `skip` on, moved forward to a character boundary. */
|
|
1936
|
+
function skipUtf8(text, skip) {
|
|
1937
|
+
const buffer = Buffer.from(text, "utf8");
|
|
1938
|
+
let start = Math.max(0, skip);
|
|
1939
|
+
while (start < buffer.byteLength && ((buffer[start] ?? 0) & 192) === 128) start++;
|
|
1940
|
+
return buffer.subarray(start).toString("utf8");
|
|
1941
|
+
}
|
|
1942
|
+
/** One background command and its retained output. */
|
|
1943
|
+
var ShellJob = class {
|
|
1944
|
+
constructor(id, command, retainBytes) {
|
|
1945
|
+
this.id = id;
|
|
1946
|
+
this.command = command;
|
|
1947
|
+
this.retainBytes = retainBytes;
|
|
1948
|
+
this.status = "running";
|
|
1949
|
+
this.exitCode = null;
|
|
1950
|
+
this.startedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
1951
|
+
this.produced = 0;
|
|
1952
|
+
this.readCursor = 0;
|
|
1953
|
+
this.chunks = [];
|
|
1954
|
+
this.retained = 0;
|
|
1955
|
+
this.listeners = /* @__PURE__ */ new Set();
|
|
1956
|
+
this.killRequested = false;
|
|
1957
|
+
this.timedOut = false;
|
|
1958
|
+
}
|
|
1959
|
+
append(stream, chunk) {
|
|
1960
|
+
if (chunk === "") return;
|
|
1961
|
+
const bytes = Buffer.byteLength(chunk, "utf8");
|
|
1962
|
+
this.chunks.push({
|
|
1963
|
+
stream,
|
|
1964
|
+
text: chunk,
|
|
1965
|
+
offset: this.produced,
|
|
1966
|
+
bytes
|
|
1967
|
+
});
|
|
1968
|
+
this.produced += bytes;
|
|
1969
|
+
this.retained += bytes;
|
|
1970
|
+
this.trim();
|
|
1971
|
+
this.notify();
|
|
1972
|
+
}
|
|
1973
|
+
/** Drop the oldest output until at most `retainBytes` are kept. */
|
|
1974
|
+
trim() {
|
|
1975
|
+
while (this.retained > this.retainBytes && this.chunks.length > 0) {
|
|
1976
|
+
const oldest = this.chunks[0];
|
|
1977
|
+
if (oldest === void 0) return;
|
|
1978
|
+
const excess = this.retained - this.retainBytes;
|
|
1979
|
+
if (oldest.bytes <= excess) {
|
|
1980
|
+
this.chunks.shift();
|
|
1981
|
+
this.retained -= oldest.bytes;
|
|
1982
|
+
continue;
|
|
1983
|
+
}
|
|
1984
|
+
const kept = tailUtf8(oldest.text, oldest.bytes - excess);
|
|
1985
|
+
this.retained -= oldest.bytes - kept.bytes;
|
|
1986
|
+
oldest.offset += oldest.bytes - kept.bytes;
|
|
1987
|
+
oldest.text = kept.text;
|
|
1988
|
+
oldest.bytes = kept.bytes;
|
|
1989
|
+
}
|
|
1990
|
+
}
|
|
1991
|
+
finish(exitCode) {
|
|
1992
|
+
if (this.status !== "running") return;
|
|
1993
|
+
this.exitCode = exitCode;
|
|
1994
|
+
this.status = this.timedOut ? "timed-out" : this.killRequested ? "killed" : "exited";
|
|
1995
|
+
this.endedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
1996
|
+
if (this.timer !== void 0) clearTimeout(this.timer);
|
|
1997
|
+
this.notify();
|
|
1998
|
+
}
|
|
1999
|
+
notify() {
|
|
2000
|
+
for (const listener of [...this.listeners]) listener();
|
|
2001
|
+
}
|
|
2002
|
+
/** Resolve on the next output or state change (or after `ms`, or on abort). */
|
|
2003
|
+
waitForChange(ms, signal) {
|
|
2004
|
+
return new Promise((resolve) => {
|
|
2005
|
+
if (signal?.aborted) {
|
|
2006
|
+
resolve();
|
|
2007
|
+
return;
|
|
2008
|
+
}
|
|
2009
|
+
let timer;
|
|
2010
|
+
const done = () => {
|
|
2011
|
+
if (timer !== void 0) clearTimeout(timer);
|
|
2012
|
+
this.listeners.delete(done);
|
|
2013
|
+
signal?.removeEventListener("abort", done);
|
|
2014
|
+
resolve();
|
|
2015
|
+
};
|
|
2016
|
+
timer = setTimeout(done, ms);
|
|
2017
|
+
timer.unref?.();
|
|
2018
|
+
this.listeners.add(done);
|
|
2019
|
+
signal?.addEventListener("abort", done, { once: true });
|
|
2020
|
+
});
|
|
2021
|
+
}
|
|
2022
|
+
info() {
|
|
2023
|
+
return {
|
|
2024
|
+
id: this.id,
|
|
2025
|
+
command: this.command,
|
|
2026
|
+
status: this.status,
|
|
2027
|
+
exitCode: this.exitCode,
|
|
2028
|
+
...this.process?.pid !== void 0 ? { pid: this.process.pid } : {},
|
|
2029
|
+
startedAt: this.startedAt,
|
|
2030
|
+
...this.endedAt !== void 0 ? { endedAt: this.endedAt } : {}
|
|
2031
|
+
};
|
|
2032
|
+
}
|
|
2033
|
+
/** Output produced after byte `since`, plus how much of it is gone. */
|
|
2034
|
+
read(since) {
|
|
2035
|
+
const from = Math.min(Math.max(0, Math.floor(since)), this.produced);
|
|
2036
|
+
const firstRetained = this.chunks[0]?.offset ?? this.produced;
|
|
2037
|
+
const droppedBytes = Math.max(0, firstRetained - from);
|
|
2038
|
+
let stdout = "";
|
|
2039
|
+
let stderr = "";
|
|
2040
|
+
for (const chunk of this.chunks) {
|
|
2041
|
+
if (chunk.offset + chunk.bytes <= from) continue;
|
|
2042
|
+
const text = chunk.offset >= from ? chunk.text : skipUtf8(chunk.text, from - chunk.offset);
|
|
2043
|
+
if (chunk.stream === "stdout") stdout += text;
|
|
2044
|
+
else stderr += text;
|
|
2045
|
+
}
|
|
2046
|
+
this.readCursor = this.produced;
|
|
2047
|
+
return {
|
|
2048
|
+
...this.info(),
|
|
2049
|
+
stdout,
|
|
2050
|
+
stderr,
|
|
2051
|
+
cursor: this.produced,
|
|
2052
|
+
droppedBytes,
|
|
2053
|
+
truncated: droppedBytes > 0
|
|
2054
|
+
};
|
|
2055
|
+
}
|
|
2056
|
+
};
|
|
2057
|
+
/**
|
|
2058
|
+
* The background commands one workspace owns. Shared by every projection
|
|
2059
|
+
* (`readonly()`, `scope()`) of the workspace, so `dispose()` on any of them
|
|
2060
|
+
* stops them all.
|
|
2061
|
+
*/
|
|
2062
|
+
var ShellJobs = class {
|
|
2063
|
+
constructor() {
|
|
2064
|
+
this.jobs = /* @__PURE__ */ new Map();
|
|
2065
|
+
this.nextId = 1;
|
|
2066
|
+
this.disposed = false;
|
|
2067
|
+
}
|
|
2068
|
+
/** Whether {@link dispose} has run. */
|
|
2069
|
+
get isDisposed() {
|
|
2070
|
+
return this.disposed;
|
|
2071
|
+
}
|
|
2072
|
+
/** Start a command as a background job. */
|
|
2073
|
+
start(spec) {
|
|
2074
|
+
if (this.disposed) throw new WorkspacePolicyError("The workspace has been disposed; it cannot start new commands.", {
|
|
2075
|
+
type: "denied-command",
|
|
2076
|
+
reason: "not-allowed",
|
|
2077
|
+
command: spec.command
|
|
2078
|
+
});
|
|
2079
|
+
const running = [...this.jobs.values()].filter((job) => job.status === "running").length;
|
|
2080
|
+
if (running >= spec.maxJobs) throw new WorkspacePolicyError(`${running} background commands are already running (the limit is ${spec.maxJobs}). Stop one with shell_kill first.`, {
|
|
2081
|
+
type: "denied-command",
|
|
2082
|
+
reason: "not-allowed",
|
|
2083
|
+
command: spec.command
|
|
2084
|
+
});
|
|
2085
|
+
const job = new ShellJob(`job-${this.nextId++}`, spec.command, spec.retainBytes);
|
|
2086
|
+
this.jobs.set(job.id, job);
|
|
2087
|
+
const handle = spec.launch((stream, chunk) => job.append(stream, chunk));
|
|
2088
|
+
job.process = handle;
|
|
2089
|
+
if (spec.timeoutMs !== void 0 && spec.timeoutMs > 0) {
|
|
2090
|
+
job.timer = setTimeout(() => {
|
|
2091
|
+
if (job.status === "running") {
|
|
2092
|
+
job.timedOut = true;
|
|
2093
|
+
handle.kill();
|
|
2094
|
+
}
|
|
2095
|
+
}, spec.timeoutMs);
|
|
2096
|
+
job.timer.unref?.();
|
|
2097
|
+
}
|
|
2098
|
+
handle.done.then(({ exitCode }) => job.finish(exitCode));
|
|
2099
|
+
this.prune();
|
|
2100
|
+
return job.info();
|
|
2101
|
+
}
|
|
2102
|
+
/** Forget the oldest finished jobs beyond {@link MAX_FINISHED_JOBS}. */
|
|
2103
|
+
prune() {
|
|
2104
|
+
const finished = [...this.jobs.values()].filter((job) => job.status !== "running");
|
|
2105
|
+
for (const job of finished.slice(0, Math.max(0, finished.length - MAX_FINISHED_JOBS))) this.jobs.delete(job.id);
|
|
2106
|
+
}
|
|
2107
|
+
get(id) {
|
|
2108
|
+
const job = this.jobs.get(id);
|
|
2109
|
+
if (job === void 0) throw new WorkspaceJobError(`No background command with id "${id}" exists in this workspace.`, {
|
|
2110
|
+
type: "not-found",
|
|
2111
|
+
jobId: id
|
|
2112
|
+
});
|
|
2113
|
+
return job;
|
|
2114
|
+
}
|
|
2115
|
+
/** New output of a job (see {@link ShellOutputOptions}). */
|
|
2116
|
+
async output(id, opts) {
|
|
2117
|
+
const job = this.get(id);
|
|
2118
|
+
const since = opts?.since ?? job.readCursor;
|
|
2119
|
+
const waitMs = Math.min(Math.max(0, opts?.waitMs ?? 0), MAX_OUTPUT_WAIT_MS);
|
|
2120
|
+
if (waitMs > 0 && job.status === "running" && job.produced <= since) await job.waitForChange(waitMs, opts?.signal);
|
|
2121
|
+
return job.read(since);
|
|
2122
|
+
}
|
|
2123
|
+
/** Kill a job's process tree and wait (bounded) for it to stop. */
|
|
2124
|
+
async kill(id) {
|
|
2125
|
+
const job = this.get(id);
|
|
2126
|
+
await this.stop(job);
|
|
2127
|
+
return job.info();
|
|
2128
|
+
}
|
|
2129
|
+
async stop(job) {
|
|
2130
|
+
if (job.status !== "running" || job.process === void 0) return;
|
|
2131
|
+
job.killRequested = true;
|
|
2132
|
+
job.process.kill();
|
|
2133
|
+
await Promise.race([job.process.done, delay(KILL_SETTLE_MS)]);
|
|
2134
|
+
}
|
|
2135
|
+
/** Every job, oldest first. */
|
|
2136
|
+
list() {
|
|
2137
|
+
return [...this.jobs.values()].map((job) => job.info());
|
|
2138
|
+
}
|
|
2139
|
+
/** Kill every running job and refuse new ones. Idempotent. */
|
|
2140
|
+
async dispose() {
|
|
2141
|
+
this.disposed = true;
|
|
2142
|
+
await Promise.all([...this.jobs.values()].map((job) => this.stop(job)));
|
|
2143
|
+
}
|
|
2144
|
+
};
|
|
352
2145
|
|
|
353
2146
|
//#endregion
|
|
354
2147
|
//#region ../../packages/ai-workspace/src/ops.ts
|
|
@@ -358,6 +2151,14 @@ const DEFAULT_READ_LINES = 2e3;
|
|
|
358
2151
|
const DEFAULT_MAX_GREP_MATCHES = 1e3;
|
|
359
2152
|
/** Default per-command output byte cap when the policy sets none. */
|
|
360
2153
|
const DEFAULT_MAX_OUTPUT_BYTES = 1e6;
|
|
2154
|
+
/** Default `list.ignore` when the policy sets none. */
|
|
2155
|
+
const DEFAULT_LIST_IGNORE = ["node_modules", ".git"];
|
|
2156
|
+
/** Default deepest `depth` a listing may ask for. */
|
|
2157
|
+
const DEFAULT_LIST_MAX_DEPTH = 5;
|
|
2158
|
+
/** Default most entries one listing returns. */
|
|
2159
|
+
const DEFAULT_LIST_MAX_ENTRIES = 500;
|
|
2160
|
+
/** Most edits one `multiEdit` call may carry. */
|
|
2161
|
+
const MAX_MULTI_EDITS = 100;
|
|
361
2162
|
/**
|
|
362
2163
|
* Exit code reported for a command refused because its signal was already
|
|
363
2164
|
* aborted (the shell convention for an interrupted command). Callers should
|
|
@@ -448,32 +2249,6 @@ function capOutput(value, maxBytes) {
|
|
|
448
2249
|
return clipUtf8(value, maxBytes);
|
|
449
2250
|
}
|
|
450
2251
|
/**
|
|
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
2252
|
* A quantifier: `+`, `*`, `?`, or a `{m,n}` bound.
|
|
478
2253
|
*/
|
|
479
2254
|
const QUANTIFIER_SOURCE = String.raw`[+*?]|\{\d*,?\d*\}`;
|
|
@@ -495,17 +2270,11 @@ function isSafeGrepPattern(pattern) {
|
|
|
495
2270
|
if (pattern.length > MAX_GREP_PATTERN_LENGTH) return false;
|
|
496
2271
|
return !NESTED_QUANTIFIER_PATTERN.test(pattern);
|
|
497
2272
|
}
|
|
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
2273
|
var Ops = class {
|
|
506
|
-
constructor(backend, policy) {
|
|
2274
|
+
constructor(backend, policy, runtime) {
|
|
507
2275
|
this.backend = backend;
|
|
508
2276
|
this.policy = policy;
|
|
2277
|
+
this.runtime = runtime;
|
|
509
2278
|
this.pathLocks = /* @__PURE__ */ new Map();
|
|
510
2279
|
}
|
|
511
2280
|
/** Run `task` after every earlier mutation of `absolutePath` has settled. */
|
|
@@ -599,30 +2368,76 @@ var Ops = class {
|
|
|
599
2368
|
expectedHash: input.expectHash,
|
|
600
2369
|
actualHash: currentHash
|
|
601
2370
|
});
|
|
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);
|
|
2371
|
+
const { content: updated, replacements } = applyReplacement(current, input, input.path, relativePath || input.path);
|
|
614
2372
|
await this.backend.writeFile(absolutePath, updated);
|
|
615
2373
|
return {
|
|
616
2374
|
path: relativePath || input.path,
|
|
617
|
-
replacements
|
|
2375
|
+
replacements,
|
|
618
2376
|
hash: _warlock_js_fs.fs.hash.string(updated)
|
|
619
2377
|
};
|
|
620
2378
|
}
|
|
621
|
-
async
|
|
622
|
-
|
|
2379
|
+
async multiEdit(input) {
|
|
2380
|
+
const { absolutePath, relativePath } = await resolveInJail(this.policy, input.path);
|
|
2381
|
+
const shownPath = relativePath || input.path;
|
|
2382
|
+
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}).`);
|
|
2383
|
+
return this.withPathLock(absolutePath, async () => {
|
|
2384
|
+
const current = await this.backend.readFile(absolutePath);
|
|
2385
|
+
const currentHash = _warlock_js_fs.fs.hash.string(current);
|
|
2386
|
+
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.`, {
|
|
2387
|
+
type: "stale-hash",
|
|
2388
|
+
path: shownPath,
|
|
2389
|
+
expectedHash: input.expectHash,
|
|
2390
|
+
actualHash: currentHash
|
|
2391
|
+
});
|
|
2392
|
+
let content = current;
|
|
2393
|
+
let replacements = 0;
|
|
2394
|
+
input.edits.forEach((edit, editIndex) => {
|
|
2395
|
+
const applied = applyReplacement(content, edit, input.path, shownPath, editIndex);
|
|
2396
|
+
content = applied.content;
|
|
2397
|
+
replacements += applied.replacements;
|
|
2398
|
+
});
|
|
2399
|
+
await this.backend.writeFile(absolutePath, content);
|
|
2400
|
+
return {
|
|
2401
|
+
path: shownPath,
|
|
2402
|
+
edits: input.edits.length,
|
|
2403
|
+
replacements,
|
|
2404
|
+
hash: _warlock_js_fs.fs.hash.string(content)
|
|
2405
|
+
};
|
|
2406
|
+
});
|
|
2407
|
+
}
|
|
2408
|
+
/**
|
|
2409
|
+
* Check a command line against the shell policy: every command in it
|
|
2410
|
+
* (argv or shell mode), then — shell mode — every redirection target and
|
|
2411
|
+
* `cd` directory against the jail with symlinks resolved.
|
|
2412
|
+
*/
|
|
2413
|
+
async assertCommandAllowed(command) {
|
|
2414
|
+
const verdict = checkShellCommand(this.policy, command);
|
|
2415
|
+
if (!verdict.allowed) throw new WorkspacePolicyError(verdict.message ?? `Command is not permitted by the workspace shell policy: ${command}`, {
|
|
2416
|
+
type: "denied-command",
|
|
2417
|
+
command,
|
|
2418
|
+
...verdict.reason !== void 0 ? { reason: verdict.reason } : {},
|
|
2419
|
+
...verdict.executable !== void 0 ? { executable: verdict.executable } : {},
|
|
2420
|
+
...verdict.path !== void 0 ? { path: verdict.path } : {}
|
|
2421
|
+
});
|
|
2422
|
+
for (const target of verdict.paths) try {
|
|
2423
|
+
await resolveInJail(this.policy, target.path);
|
|
2424
|
+
} catch (error) {
|
|
2425
|
+
if (!(error instanceof WorkspacePolicyError)) throw error;
|
|
2426
|
+
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.`, {
|
|
2427
|
+
type: "denied-command",
|
|
2428
|
+
reason: "path-escape",
|
|
2429
|
+
command,
|
|
2430
|
+
path: target.target
|
|
2431
|
+
});
|
|
2432
|
+
}
|
|
2433
|
+
if (this.runtime.jobs.isDisposed) throw new WorkspacePolicyError("The workspace has been disposed; it cannot run new commands.", {
|
|
623
2434
|
type: "denied-command",
|
|
2435
|
+
reason: "not-allowed",
|
|
624
2436
|
command
|
|
625
2437
|
});
|
|
2438
|
+
}
|
|
2439
|
+
async exec(command, opts) {
|
|
2440
|
+
await this.assertCommandAllowed(command);
|
|
626
2441
|
const signal = opts?.signal;
|
|
627
2442
|
if (signal?.aborted) return {
|
|
628
2443
|
exitCode: CANCELLED_BEFORE_START_EXIT_CODE,
|
|
@@ -635,11 +2450,13 @@ var Ops = class {
|
|
|
635
2450
|
const shell = this.policy.shell;
|
|
636
2451
|
const timeoutMs = opts?.timeoutMs ?? shell?.timeoutMs;
|
|
637
2452
|
const maxOutputBytes = shell?.maxOutputBytes ?? DEFAULT_MAX_OUTPUT_BYTES;
|
|
2453
|
+
const interpreter = shell?.interpreter ?? "none";
|
|
638
2454
|
const result = await this.backend.exec(command, {
|
|
639
2455
|
cwd: this.policy.cwd,
|
|
640
2456
|
timeoutMs,
|
|
641
|
-
env:
|
|
642
|
-
...signal ? { signal } : {}
|
|
2457
|
+
env: buildCommandEnv(this.policy),
|
|
2458
|
+
...signal ? { signal } : {},
|
|
2459
|
+
...interpreter !== "none" ? { interpreter } : {}
|
|
643
2460
|
});
|
|
644
2461
|
const stdout = capOutput(result.stdout, maxOutputBytes);
|
|
645
2462
|
const stderr = capOutput(result.stderr, maxOutputBytes);
|
|
@@ -653,6 +2470,120 @@ var Ops = class {
|
|
|
653
2470
|
cancelled
|
|
654
2471
|
};
|
|
655
2472
|
}
|
|
2473
|
+
async startShell(command, opts) {
|
|
2474
|
+
const background = this.policy.shell?.background;
|
|
2475
|
+
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.", {
|
|
2476
|
+
type: "denied-command",
|
|
2477
|
+
reason: "not-allowed",
|
|
2478
|
+
command
|
|
2479
|
+
});
|
|
2480
|
+
await this.assertCommandAllowed(command);
|
|
2481
|
+
const start = this.backend.start?.bind(this.backend);
|
|
2482
|
+
if (start === void 0) throw new WorkspacePolicyError("This workspace backend cannot run background commands.", {
|
|
2483
|
+
type: "denied-command",
|
|
2484
|
+
reason: "not-allowed",
|
|
2485
|
+
command
|
|
2486
|
+
});
|
|
2487
|
+
const shell = this.policy.shell;
|
|
2488
|
+
const interpreter = shell?.interpreter ?? "none";
|
|
2489
|
+
const timeoutMs = lowerOf(background.timeoutMs, opts?.timeoutMs);
|
|
2490
|
+
const env = buildCommandEnv(this.policy);
|
|
2491
|
+
return this.runtime.jobs.start({
|
|
2492
|
+
command,
|
|
2493
|
+
retainBytes: shell?.maxOutputBytes ?? DEFAULT_MAX_OUTPUT_BYTES,
|
|
2494
|
+
maxJobs: background.maxJobs ?? 4,
|
|
2495
|
+
...timeoutMs !== void 0 ? { timeoutMs } : {},
|
|
2496
|
+
launch: (onOutput) => start(command, {
|
|
2497
|
+
cwd: this.policy.cwd,
|
|
2498
|
+
env,
|
|
2499
|
+
...interpreter !== "none" ? { interpreter } : {},
|
|
2500
|
+
onOutput
|
|
2501
|
+
})
|
|
2502
|
+
});
|
|
2503
|
+
}
|
|
2504
|
+
shellOutput(id, opts) {
|
|
2505
|
+
return this.runtime.jobs.output(id, opts);
|
|
2506
|
+
}
|
|
2507
|
+
shellKill(id) {
|
|
2508
|
+
return this.runtime.jobs.kill(id);
|
|
2509
|
+
}
|
|
2510
|
+
listShells() {
|
|
2511
|
+
return this.runtime.jobs.list();
|
|
2512
|
+
}
|
|
2513
|
+
dispose() {
|
|
2514
|
+
return this.runtime.jobs.dispose();
|
|
2515
|
+
}
|
|
2516
|
+
async listDir(inputPath = ".", opts) {
|
|
2517
|
+
const { absolutePath: root, relativePath: rootRelative } = await resolveInJail(this.policy, inputPath);
|
|
2518
|
+
const { absolutePath: jailRoot } = await resolveInJail(this.policy, ".");
|
|
2519
|
+
const listPolicy = this.policy.list;
|
|
2520
|
+
const maxDepth = Math.max(1, Math.floor(listPolicy?.maxDepth ?? DEFAULT_LIST_MAX_DEPTH));
|
|
2521
|
+
const depth = Math.min(Math.max(1, Math.floor(opts?.depth ?? 1)), maxDepth);
|
|
2522
|
+
const maxEntries = Math.max(1, Math.floor(listPolicy?.maxEntries ?? DEFAULT_LIST_MAX_ENTRIES));
|
|
2523
|
+
const ignore = [...listPolicy?.ignore ?? DEFAULT_LIST_IGNORE, ...opts?.ignore ?? []];
|
|
2524
|
+
const insideCwd = isInside(root, jailRoot);
|
|
2525
|
+
if (!await this.isDirectory(root)) throw new Error(`"${inputPath}" is not a directory in the workspace.`);
|
|
2526
|
+
const entries = [];
|
|
2527
|
+
let level = [root];
|
|
2528
|
+
let truncated = false;
|
|
2529
|
+
for (let currentDepth = 1; currentDepth <= depth && level.length > 0; currentDepth++) {
|
|
2530
|
+
const nextLevel = [];
|
|
2531
|
+
for (const directory of level) {
|
|
2532
|
+
let children;
|
|
2533
|
+
try {
|
|
2534
|
+
children = await this.backend.list(directory);
|
|
2535
|
+
} catch {
|
|
2536
|
+
continue;
|
|
2537
|
+
}
|
|
2538
|
+
children.sort((left, right) => left < right ? -1 : left > right ? 1 : 0);
|
|
2539
|
+
for (const child of children) {
|
|
2540
|
+
const relativeToJail = insideCwd ? node_path.default.relative(jailRoot, child).split(node_path.default.sep).join("/") : child;
|
|
2541
|
+
if (insideCwd && (this.isDenied(relativeToJail) || matchesAnyGlob(relativeToJail, ignore))) continue;
|
|
2542
|
+
const relativeToRoot = node_path.default.relative(root, child).split(node_path.default.sep).join("/");
|
|
2543
|
+
if (!insideCwd && matchesAnyGlob(relativeToRoot, ignore)) continue;
|
|
2544
|
+
if (entries.length >= maxEntries) {
|
|
2545
|
+
truncated = true;
|
|
2546
|
+
break;
|
|
2547
|
+
}
|
|
2548
|
+
const described = await this.describe(child);
|
|
2549
|
+
entries.push({
|
|
2550
|
+
path: relativeToJail,
|
|
2551
|
+
type: described.type,
|
|
2552
|
+
...described.size !== void 0 ? { size: described.size } : {},
|
|
2553
|
+
depth: currentDepth
|
|
2554
|
+
});
|
|
2555
|
+
if (described.type === "directory") nextLevel.push(child);
|
|
2556
|
+
}
|
|
2557
|
+
if (truncated) break;
|
|
2558
|
+
}
|
|
2559
|
+
if (truncated) break;
|
|
2560
|
+
level = nextLevel;
|
|
2561
|
+
}
|
|
2562
|
+
return {
|
|
2563
|
+
path: insideCwd ? rootRelative || "." : root,
|
|
2564
|
+
entries,
|
|
2565
|
+
truncated
|
|
2566
|
+
};
|
|
2567
|
+
}
|
|
2568
|
+
/** Kind and size of a listed entry; symlinks are reported, never followed. */
|
|
2569
|
+
async describe(absPath) {
|
|
2570
|
+
if (this.backend.stat) try {
|
|
2571
|
+
const stats = await this.backend.stat(absPath);
|
|
2572
|
+
return stats.type === "file" ? {
|
|
2573
|
+
type: "file",
|
|
2574
|
+
size: stats.size
|
|
2575
|
+
} : { type: stats.type };
|
|
2576
|
+
} catch {
|
|
2577
|
+
return { type: "other" };
|
|
2578
|
+
}
|
|
2579
|
+
if (await this.isDirectory(absPath)) try {
|
|
2580
|
+
await resolveInJail(this.policy, absPath);
|
|
2581
|
+
return { type: "directory" };
|
|
2582
|
+
} catch {
|
|
2583
|
+
return { type: "other" };
|
|
2584
|
+
}
|
|
2585
|
+
return { type: "file" };
|
|
2586
|
+
}
|
|
656
2587
|
async grep(pattern, opts) {
|
|
657
2588
|
if (!isSafeGrepPattern(pattern)) throw new WorkspacePolicyError(`Grep pattern is too long or too likely to cause catastrophic regex backtracking: ${pattern}`, {
|
|
658
2589
|
type: "unsafe-pattern",
|
|
@@ -676,7 +2607,7 @@ var Ops = class {
|
|
|
676
2607
|
}
|
|
677
2608
|
const lines = content.split("\n");
|
|
678
2609
|
for (let index = 0; index < lines.length; index++) {
|
|
679
|
-
const line = lines[index];
|
|
2610
|
+
const line = lines[index] ?? "";
|
|
680
2611
|
if (line.length > MAX_GREP_LINE_SCAN_LENGTH) continue;
|
|
681
2612
|
if (regex.test(line)) {
|
|
682
2613
|
matches.push({
|
|
@@ -723,17 +2654,40 @@ var Ops = class {
|
|
|
723
2654
|
/** Whether a `/`-separated relative path hits the policy deny list. */
|
|
724
2655
|
isDenied(relativePath) {
|
|
725
2656
|
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
|
-
});
|
|
2657
|
+
return denyPaths !== void 0 && denyPaths.length > 0 && matchesAnyGlob(relativePath, denyPaths);
|
|
735
2658
|
}
|
|
736
2659
|
};
|
|
2660
|
+
/** The lower of two optional limits (either may be absent). */
|
|
2661
|
+
function lowerOf(policyLimit, requested) {
|
|
2662
|
+
if (requested === void 0 || requested <= 0) return policyLimit;
|
|
2663
|
+
return policyLimit === void 0 ? requested : Math.min(policyLimit, requested);
|
|
2664
|
+
}
|
|
2665
|
+
/**
|
|
2666
|
+
* Apply one exact-string replacement to `content` under the edit rules:
|
|
2667
|
+
* `oldString` must occur, and exactly once unless `replaceAll`.
|
|
2668
|
+
*/
|
|
2669
|
+
function applyReplacement(content, edit, inputPath, shownPath, editIndex) {
|
|
2670
|
+
const occurrences = countOccurrences(content, edit.oldString);
|
|
2671
|
+
const which = editIndex === void 0 ? "" : `Edit ${editIndex + 1}: `;
|
|
2672
|
+
const nothingApplied = editIndex === void 0 ? "" : " None of the edits were applied.";
|
|
2673
|
+
const indexOption = editIndex === void 0 ? {} : { editIndex };
|
|
2674
|
+
if (occurrences === 0) throw new WorkspaceEditError(`${which}The text to replace was not found in "${inputPath}".${nothingApplied}`, {
|
|
2675
|
+
type: "not-found",
|
|
2676
|
+
path: shownPath,
|
|
2677
|
+
matches: 0,
|
|
2678
|
+
...indexOption
|
|
2679
|
+
});
|
|
2680
|
+
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}`, {
|
|
2681
|
+
type: "not-unique",
|
|
2682
|
+
path: shownPath,
|
|
2683
|
+
matches: occurrences,
|
|
2684
|
+
...indexOption
|
|
2685
|
+
});
|
|
2686
|
+
return {
|
|
2687
|
+
content: edit.replaceAll ? content.split(edit.oldString).join(edit.newString) : replaceFirst(content, edit.oldString, edit.newString),
|
|
2688
|
+
replacements: edit.replaceAll ? occurrences : 1
|
|
2689
|
+
};
|
|
2690
|
+
}
|
|
737
2691
|
/** Count non-overlapping occurrences of `needle` in `haystack`. */
|
|
738
2692
|
function countOccurrences(haystack, needle) {
|
|
739
2693
|
if (needle === "") return 0;
|
|
@@ -765,13 +2719,15 @@ function replaceFirst(haystack, needle, replacement) {
|
|
|
765
2719
|
*
|
|
766
2720
|
* @param backend - The dumb IO executor (local disk or in-memory mock).
|
|
767
2721
|
* @param policy - The policy that bounds every operation.
|
|
2722
|
+
* @param runtime - State shared with other views of the same workspace
|
|
2723
|
+
* (its background commands); a fresh one when omitted.
|
|
768
2724
|
*
|
|
769
2725
|
* @example
|
|
770
2726
|
* const ops = createOps(localBackend, { cwd: "/srv/api", shell: { allow: ["npm"] } });
|
|
771
2727
|
* const { content, hash } = await ops.readFile("src/index.ts");
|
|
772
2728
|
*/
|
|
773
|
-
function createOps(backend, policy) {
|
|
774
|
-
return new Ops(backend, policy);
|
|
2729
|
+
function createOps(backend, policy, runtime) {
|
|
2730
|
+
return new Ops(backend, policy, runtime ?? { jobs: new ShellJobs() });
|
|
775
2731
|
}
|
|
776
2732
|
|
|
777
2733
|
//#endregion
|
|
@@ -783,7 +2739,7 @@ function createOps(backend, policy) {
|
|
|
783
2739
|
* policy-driven cap (and the `truncated` flag) on top. Generous enough that
|
|
784
2740
|
* ordinary command output is never clipped here.
|
|
785
2741
|
*/
|
|
786
|
-
const MAX_STREAM_BYTES =
|
|
2742
|
+
const MAX_STREAM_BYTES = 10485760;
|
|
787
2743
|
/**
|
|
788
2744
|
* Append a chunk to a capped list of buffers, tracking the running byte
|
|
789
2745
|
* total and stopping once {@link MAX_STREAM_BYTES} is reached. Trailing bytes
|
|
@@ -866,6 +2822,74 @@ function toWin32CmdInvocation(argv) {
|
|
|
866
2822
|
]
|
|
867
2823
|
};
|
|
868
2824
|
}
|
|
2825
|
+
/** The shell a POSIX shell-mode line runs through — an absolute path, never a PATH lookup. */
|
|
2826
|
+
const POSIX_SHELL = "/bin/sh";
|
|
2827
|
+
/**
|
|
2828
|
+
* Work out the spawn for a command line.
|
|
2829
|
+
*
|
|
2830
|
+
* - argv mode: tokenize (no shell semantics) and spawn the argv directly —
|
|
2831
|
+
* on Windows through an individually-quoted `cmd.exe` wrapper.
|
|
2832
|
+
* - shell mode: `/bin/sh -c <line>`, or `cmd.exe /d /v:off /s /c "<line>"`
|
|
2833
|
+
* on Windows (`/d` skips AutoRun, `/v:off` disables delayed `!` expansion,
|
|
2834
|
+
* `/s` keeps the line verbatim inside the outer quotes). The ops layer has
|
|
2835
|
+
* already checked every command in the line.
|
|
2836
|
+
*/
|
|
2837
|
+
function planSpawn(command, interpreter) {
|
|
2838
|
+
if (interpreter === "sh") {
|
|
2839
|
+
if (node_process.platform === "win32") return {
|
|
2840
|
+
file: process.env.ComSpec ?? "cmd.exe",
|
|
2841
|
+
args: [
|
|
2842
|
+
"/d",
|
|
2843
|
+
"/v:off",
|
|
2844
|
+
"/s",
|
|
2845
|
+
"/c",
|
|
2846
|
+
`"${command}"`
|
|
2847
|
+
],
|
|
2848
|
+
windowsVerbatimArguments: true
|
|
2849
|
+
};
|
|
2850
|
+
return {
|
|
2851
|
+
file: POSIX_SHELL,
|
|
2852
|
+
args: ["-c", command],
|
|
2853
|
+
windowsVerbatimArguments: false
|
|
2854
|
+
};
|
|
2855
|
+
}
|
|
2856
|
+
const argv = tokenizeCommand(command);
|
|
2857
|
+
const executable = argv?.[0];
|
|
2858
|
+
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." };
|
|
2859
|
+
if (node_process.platform === "win32") {
|
|
2860
|
+
const invocation = toWin32CmdInvocation(argv);
|
|
2861
|
+
if (invocation === null) return { refused: "Command was not executed: on Windows, arguments containing \", %, or newlines cannot be passed to cmd.exe safely." };
|
|
2862
|
+
return {
|
|
2863
|
+
...invocation,
|
|
2864
|
+
windowsVerbatimArguments: true
|
|
2865
|
+
};
|
|
2866
|
+
}
|
|
2867
|
+
return {
|
|
2868
|
+
file: executable,
|
|
2869
|
+
args: argv.slice(1),
|
|
2870
|
+
windowsVerbatimArguments: false
|
|
2871
|
+
};
|
|
2872
|
+
}
|
|
2873
|
+
/**
|
|
2874
|
+
* Kill functions of every background process still running, so they can
|
|
2875
|
+
* be reaped if the Node process exits without the workspace being disposed
|
|
2876
|
+
* (detached process groups would otherwise outlive it).
|
|
2877
|
+
*/
|
|
2878
|
+
const liveBackgroundKills = /* @__PURE__ */ new Set();
|
|
2879
|
+
let exitHookInstalled = false;
|
|
2880
|
+
/** Track a live background tree; installs (once) the exit hook that kills them. */
|
|
2881
|
+
function trackBackgroundProcess(kill) {
|
|
2882
|
+
liveBackgroundKills.add(kill);
|
|
2883
|
+
if (!exitHookInstalled) {
|
|
2884
|
+
exitHookInstalled = true;
|
|
2885
|
+
process.on("exit", () => {
|
|
2886
|
+
for (const killTreeNow of liveBackgroundKills) killTreeNow();
|
|
2887
|
+
});
|
|
2888
|
+
}
|
|
2889
|
+
return () => {
|
|
2890
|
+
liveBackgroundKills.delete(kill);
|
|
2891
|
+
};
|
|
2892
|
+
}
|
|
869
2893
|
/**
|
|
870
2894
|
* The real-disk executor: every filesystem method delegates to
|
|
871
2895
|
* `@warlock.js/fs` (never `node:fs`), and {@link LocalBackend.exec} spawns a
|
|
@@ -922,6 +2946,17 @@ var LocalBackend = class {
|
|
|
922
2946
|
return _warlock_js_fs.fs.dirs.list(absDir);
|
|
923
2947
|
}
|
|
924
2948
|
/**
|
|
2949
|
+
* Describe a path without following a final symlink (`lstat` — like
|
|
2950
|
+
* `realpath`, a filesystem operation `@warlock.js/fs` does not expose).
|
|
2951
|
+
*/
|
|
2952
|
+
async stat(absPath) {
|
|
2953
|
+
const stats = await (0, node_fs_promises.lstat)(absPath);
|
|
2954
|
+
return {
|
|
2955
|
+
type: stats.isSymbolicLink() ? "symlink" : stats.isDirectory() ? "directory" : stats.isFile() ? "file" : "other",
|
|
2956
|
+
size: stats.size
|
|
2957
|
+
};
|
|
2958
|
+
}
|
|
2959
|
+
/**
|
|
925
2960
|
* Resolve symlinks and `..` segments to a canonical absolute path — the
|
|
926
2961
|
* primitive the ops-layer jail uses to detect escapes. Delegates to
|
|
927
2962
|
* `node:fs/promises` `realpath`, the one filesystem operation `@warlock.js/fs`
|
|
@@ -931,14 +2966,17 @@ var LocalBackend = class {
|
|
|
931
2966
|
return (0, node_fs_promises.realpath)(absPath);
|
|
932
2967
|
}
|
|
933
2968
|
/**
|
|
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`
|
|
2969
|
+
* Run a command and capture its outcome. In argv mode (the default) the
|
|
2970
|
+
* command line is tokenized into an argv (quotes respected, NO shell
|
|
2971
|
+
* semantics — see `tokenizeCommand`) and spawned **without a shell**, so
|
|
2972
|
+
* metacharacters like `;`, `&&`, `|`, backticks, and `$()` can never
|
|
2973
|
+
* chain extra commands past the ops layer's allowlist; a command they
|
|
2974
|
+
* appear unquoted in is refused with exit code 127. On Windows the argv
|
|
2975
|
+
* runs through a `cmd.exe /d /s /c` wrapper (batch shims like `npm.cmd`
|
|
941
2976
|
* cannot be spawned shell-less) with every element individually quoted.
|
|
2977
|
+
* With `interpreter: "sh"` the line runs through `/bin/sh -c` (or
|
|
2978
|
+
* `cmd.exe /d /v:off /s /c` on Windows) — only after the ops layer has
|
|
2979
|
+
* checked every command in it.
|
|
942
2980
|
* `cwd`, `env`, and the timeout are taken verbatim from the ops layer
|
|
943
2981
|
* (the environment is NOT merged with `process.env`). On timeout the
|
|
944
2982
|
* process tree is SIGKILLed and `timedOut` is set. When `signal` aborts
|
|
@@ -970,31 +3008,17 @@ var LocalBackend = class {
|
|
|
970
3008
|
});
|
|
971
3009
|
return;
|
|
972
3010
|
}
|
|
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.");
|
|
3011
|
+
const plan = planSpawn(command, opts.interpreter ?? "none");
|
|
3012
|
+
if ("refused" in plan) {
|
|
3013
|
+
refuse(plan.refused);
|
|
977
3014
|
return;
|
|
978
3015
|
}
|
|
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, {
|
|
3016
|
+
const child = (0, node_child_process.spawn)(plan.file, plan.args, {
|
|
993
3017
|
cwd: opts.cwd,
|
|
994
3018
|
env: opts.env,
|
|
995
3019
|
shell: false,
|
|
996
3020
|
windowsHide: true,
|
|
997
|
-
windowsVerbatimArguments,
|
|
3021
|
+
windowsVerbatimArguments: plan.windowsVerbatimArguments,
|
|
998
3022
|
detached: node_process.platform !== "win32"
|
|
999
3023
|
});
|
|
1000
3024
|
const stdoutChunks = [];
|
|
@@ -1050,6 +3074,75 @@ var LocalBackend = class {
|
|
|
1050
3074
|
});
|
|
1051
3075
|
});
|
|
1052
3076
|
}
|
|
3077
|
+
/**
|
|
3078
|
+
* Start a long-running command and return at once. Spawned like
|
|
3079
|
+
* {@link exec} (same argv/shell planning, own process group on POSIX), with
|
|
3080
|
+
* stdin closed. Output is decoded per stream with a `StringDecoder`, so a
|
|
3081
|
+
* chunk never ends inside a character. `kill()` kills the whole tree; a
|
|
3082
|
+
* still-running tree is also killed when the Node process exits.
|
|
3083
|
+
*/
|
|
3084
|
+
start(command, opts) {
|
|
3085
|
+
const plan = planSpawn(command, opts.interpreter ?? "none");
|
|
3086
|
+
if ("refused" in plan) {
|
|
3087
|
+
opts.onOutput("stderr", plan.refused);
|
|
3088
|
+
return {
|
|
3089
|
+
done: Promise.resolve({ exitCode: 127 }),
|
|
3090
|
+
kill: () => {}
|
|
3091
|
+
};
|
|
3092
|
+
}
|
|
3093
|
+
const child = (0, node_child_process.spawn)(plan.file, plan.args, {
|
|
3094
|
+
cwd: opts.cwd,
|
|
3095
|
+
env: opts.env,
|
|
3096
|
+
shell: false,
|
|
3097
|
+
windowsHide: true,
|
|
3098
|
+
windowsVerbatimArguments: plan.windowsVerbatimArguments,
|
|
3099
|
+
detached: node_process.platform !== "win32",
|
|
3100
|
+
stdio: [
|
|
3101
|
+
"ignore",
|
|
3102
|
+
"pipe",
|
|
3103
|
+
"pipe"
|
|
3104
|
+
]
|
|
3105
|
+
});
|
|
3106
|
+
const stdoutDecoder = new node_string_decoder.StringDecoder("utf8");
|
|
3107
|
+
const stderrDecoder = new node_string_decoder.StringDecoder("utf8");
|
|
3108
|
+
let finished = false;
|
|
3109
|
+
const kill = () => {
|
|
3110
|
+
if (!finished) killTree(child.pid, child);
|
|
3111
|
+
};
|
|
3112
|
+
const untrack = trackBackgroundProcess(kill);
|
|
3113
|
+
child.stdout?.on("data", (chunk) => {
|
|
3114
|
+
const text = stdoutDecoder.write(chunk);
|
|
3115
|
+
if (text !== "") opts.onOutput("stdout", text);
|
|
3116
|
+
});
|
|
3117
|
+
child.stderr?.on("data", (chunk) => {
|
|
3118
|
+
const text = stderrDecoder.write(chunk);
|
|
3119
|
+
if (text !== "") opts.onOutput("stderr", text);
|
|
3120
|
+
});
|
|
3121
|
+
const done = new Promise((resolve) => {
|
|
3122
|
+
const finish = (exitCode) => {
|
|
3123
|
+
if (finished) return;
|
|
3124
|
+
finished = true;
|
|
3125
|
+
untrack();
|
|
3126
|
+
const stdoutTail = stdoutDecoder.end();
|
|
3127
|
+
const stderrTail = stderrDecoder.end();
|
|
3128
|
+
if (stdoutTail !== "") opts.onOutput("stdout", stdoutTail);
|
|
3129
|
+
if (stderrTail !== "") opts.onOutput("stderr", stderrTail);
|
|
3130
|
+
resolve({ exitCode });
|
|
3131
|
+
};
|
|
3132
|
+
child.on("error", (error) => {
|
|
3133
|
+
opts.onOutput("stderr", `Command could not be started: ${error.message}`);
|
|
3134
|
+
finish(127);
|
|
3135
|
+
});
|
|
3136
|
+
child.on("close", (code, signal) => {
|
|
3137
|
+
finish(code === null ? 128 + (signal === "SIGKILL" ? 9 : 1) : code);
|
|
3138
|
+
});
|
|
3139
|
+
});
|
|
3140
|
+
return {
|
|
3141
|
+
...child.pid !== void 0 ? { pid: child.pid } : {},
|
|
3142
|
+
done,
|
|
3143
|
+
kill
|
|
3144
|
+
};
|
|
3145
|
+
}
|
|
1053
3146
|
};
|
|
1054
3147
|
/**
|
|
1055
3148
|
* Create the **local** workspace backend — the default executor that runs the
|
|
@@ -1171,6 +3264,40 @@ var MockBackend = class {
|
|
|
1171
3264
|
async realpath(absPath) {
|
|
1172
3265
|
return canonicalize(absPath);
|
|
1173
3266
|
}
|
|
3267
|
+
async stat(absPath) {
|
|
3268
|
+
const canonical = canonicalize(absPath);
|
|
3269
|
+
const content = this.files.get(canonical);
|
|
3270
|
+
if (content !== void 0) return {
|
|
3271
|
+
type: "file",
|
|
3272
|
+
size: Buffer.byteLength(content, "utf8")
|
|
3273
|
+
};
|
|
3274
|
+
if (this.directories.has(canonical)) return {
|
|
3275
|
+
type: "directory",
|
|
3276
|
+
size: 0
|
|
3277
|
+
};
|
|
3278
|
+
throw new Error(`Mock backend: no such file or directory: ${canonical}`);
|
|
3279
|
+
}
|
|
3280
|
+
/**
|
|
3281
|
+
* Scripted background start: emits the registered `stdout` / `stderr`
|
|
3282
|
+
* synchronously, then exits with `exitCode` — or, when scripted with
|
|
3283
|
+
* `running: true`, stays running until `kill()` (exit code 137).
|
|
3284
|
+
*/
|
|
3285
|
+
start(command, opts) {
|
|
3286
|
+
const scripted = this.commands.get(command);
|
|
3287
|
+
if (scripted?.stdout) opts.onOutput("stdout", scripted.stdout);
|
|
3288
|
+
if (scripted?.stderr) opts.onOutput("stderr", scripted.stderr);
|
|
3289
|
+
if (scripted?.running !== true) return {
|
|
3290
|
+
done: Promise.resolve({ exitCode: scripted?.exitCode ?? 0 }),
|
|
3291
|
+
kill: () => {}
|
|
3292
|
+
};
|
|
3293
|
+
let stop = () => {};
|
|
3294
|
+
return {
|
|
3295
|
+
done: new Promise((resolve) => {
|
|
3296
|
+
stop = resolve;
|
|
3297
|
+
}),
|
|
3298
|
+
kill: () => stop({ exitCode: 137 })
|
|
3299
|
+
};
|
|
3300
|
+
}
|
|
1174
3301
|
async exec(command, opts) {
|
|
1175
3302
|
if (opts?.signal?.aborted) return {
|
|
1176
3303
|
exitCode: 130,
|
|
@@ -1261,6 +3388,10 @@ function isMockBackendSeed(seed) {
|
|
|
1261
3388
|
*/
|
|
1262
3389
|
/** The vendor tag stamped on every issue these builders produce. */
|
|
1263
3390
|
const VENDOR = "ai-workspace";
|
|
3391
|
+
/** Attach a JSON Schema description to a hand-rolled Standard Schema. */
|
|
3392
|
+
function withJsonSchema(schema, jsonSchema) {
|
|
3393
|
+
return Object.assign(schema, { jsonSchema });
|
|
3394
|
+
}
|
|
1264
3395
|
/** Required string field — rejects anything that is not a string. */
|
|
1265
3396
|
function stringField() {
|
|
1266
3397
|
return (value, key) => {
|
|
@@ -1311,24 +3442,25 @@ function optionalBooleanField() {
|
|
|
1311
3442
|
};
|
|
1312
3443
|
}
|
|
1313
3444
|
/**
|
|
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
|
-
* });
|
|
3445
|
+
* Optional array-of-strings field — accepts `undefined` or an array whose
|
|
3446
|
+
* every element is a string (at most `maxItems` of them).
|
|
1330
3447
|
*/
|
|
1331
|
-
function
|
|
3448
|
+
function optionalStringArrayField(maxItems = 50) {
|
|
3449
|
+
return (value, key) => {
|
|
3450
|
+
if (value === void 0) return { value: void 0 };
|
|
3451
|
+
if (Array.isArray(value) && value.length <= maxItems && value.every((item) => typeof item === "string")) return { value };
|
|
3452
|
+
return { issues: [{
|
|
3453
|
+
message: `"${key}" must be an array of at most ${maxItems} strings when provided`,
|
|
3454
|
+
path: [key]
|
|
3455
|
+
}] };
|
|
3456
|
+
};
|
|
3457
|
+
}
|
|
3458
|
+
function objectSchema(shape, jsonSchema) {
|
|
3459
|
+
const schema = buildObjectSchema(shape);
|
|
3460
|
+
return jsonSchema === void 0 ? schema : withJsonSchema(schema, jsonSchema);
|
|
3461
|
+
}
|
|
3462
|
+
/** The validator behind {@link objectSchema}. */
|
|
3463
|
+
function buildObjectSchema(shape) {
|
|
1332
3464
|
return { "~standard": {
|
|
1333
3465
|
version: 1,
|
|
1334
3466
|
vendor: VENDOR,
|
|
@@ -1352,18 +3484,191 @@ function objectSchema(shape) {
|
|
|
1352
3484
|
} };
|
|
1353
3485
|
}
|
|
1354
3486
|
|
|
3487
|
+
//#endregion
|
|
3488
|
+
//#region ../../packages/ai-workspace/src/tools/json-schemas.ts
|
|
3489
|
+
/**
|
|
3490
|
+
* The JSON Schemas the workspace tools publish for their input (read by
|
|
3491
|
+
* `extractJsonSchema` and sent to the provider with each tool), kept short
|
|
3492
|
+
* because they ride along on every model call. They describe what the
|
|
3493
|
+
* hand-rolled validators accept; the validators remain the authority.
|
|
3494
|
+
*/
|
|
3495
|
+
const string = (description) => ({
|
|
3496
|
+
type: "string",
|
|
3497
|
+
description
|
|
3498
|
+
});
|
|
3499
|
+
const integer = (description) => ({
|
|
3500
|
+
type: "integer",
|
|
3501
|
+
description
|
|
3502
|
+
});
|
|
3503
|
+
const boolean = (description) => ({
|
|
3504
|
+
type: "boolean",
|
|
3505
|
+
description
|
|
3506
|
+
});
|
|
3507
|
+
/** `read_file`. */
|
|
3508
|
+
const READ_FILE_JSON_SCHEMA = {
|
|
3509
|
+
type: "object",
|
|
3510
|
+
properties: {
|
|
3511
|
+
path: string("Workspace-relative file path."),
|
|
3512
|
+
startLine: integer("1-based first line to return."),
|
|
3513
|
+
limit: integer("Most lines to return.")
|
|
3514
|
+
},
|
|
3515
|
+
required: ["path"]
|
|
3516
|
+
};
|
|
3517
|
+
/** `edit_file`. */
|
|
3518
|
+
const EDIT_FILE_JSON_SCHEMA = {
|
|
3519
|
+
type: "object",
|
|
3520
|
+
properties: {
|
|
3521
|
+
path: string("Workspace-relative file path."),
|
|
3522
|
+
oldString: string("Exact text to replace; must be unique unless replaceAll."),
|
|
3523
|
+
newString: string("Replacement text."),
|
|
3524
|
+
replaceAll: boolean("Replace every occurrence."),
|
|
3525
|
+
expectHash: string("Hash from read_file; rejects the edit if the file changed.")
|
|
3526
|
+
},
|
|
3527
|
+
required: [
|
|
3528
|
+
"path",
|
|
3529
|
+
"oldString",
|
|
3530
|
+
"newString"
|
|
3531
|
+
]
|
|
3532
|
+
};
|
|
3533
|
+
/** `multi_edit`. */
|
|
3534
|
+
const MULTI_EDIT_JSON_SCHEMA = {
|
|
3535
|
+
type: "object",
|
|
3536
|
+
properties: {
|
|
3537
|
+
path: string("Workspace-relative file path."),
|
|
3538
|
+
edits: {
|
|
3539
|
+
type: "array",
|
|
3540
|
+
description: "Replacements applied in order, each to the result of the previous; all or none.",
|
|
3541
|
+
minItems: 1,
|
|
3542
|
+
maxItems: 100,
|
|
3543
|
+
items: {
|
|
3544
|
+
type: "object",
|
|
3545
|
+
properties: {
|
|
3546
|
+
oldString: string("Exact text to replace; must be unique unless replaceAll."),
|
|
3547
|
+
newString: string("Replacement text."),
|
|
3548
|
+
replaceAll: boolean("Replace every occurrence.")
|
|
3549
|
+
},
|
|
3550
|
+
required: ["oldString", "newString"]
|
|
3551
|
+
}
|
|
3552
|
+
},
|
|
3553
|
+
expectHash: string("Hash from read_file; rejects every edit if the file changed.")
|
|
3554
|
+
},
|
|
3555
|
+
required: ["path", "edits"]
|
|
3556
|
+
};
|
|
3557
|
+
/** `write_file`. */
|
|
3558
|
+
const WRITE_FILE_JSON_SCHEMA = {
|
|
3559
|
+
type: "object",
|
|
3560
|
+
properties: {
|
|
3561
|
+
path: string("Workspace-relative file path."),
|
|
3562
|
+
content: string("Full file content.")
|
|
3563
|
+
},
|
|
3564
|
+
required: ["path", "content"]
|
|
3565
|
+
};
|
|
3566
|
+
/** `list_dir`. */
|
|
3567
|
+
const LIST_DIR_JSON_SCHEMA = {
|
|
3568
|
+
type: "object",
|
|
3569
|
+
properties: {
|
|
3570
|
+
path: string("Workspace-relative directory (default \".\")."),
|
|
3571
|
+
depth: integer("Levels to list; 1 = direct children (default)."),
|
|
3572
|
+
ignore: {
|
|
3573
|
+
type: "array",
|
|
3574
|
+
items: { type: "string" },
|
|
3575
|
+
description: "Extra globs to leave out."
|
|
3576
|
+
}
|
|
3577
|
+
}
|
|
3578
|
+
};
|
|
3579
|
+
/** `run_shell` (the `background` property is only listed when background commands are enabled). */
|
|
3580
|
+
function runShellJsonSchema(background) {
|
|
3581
|
+
return {
|
|
3582
|
+
type: "object",
|
|
3583
|
+
properties: {
|
|
3584
|
+
command: string("The command line to run."),
|
|
3585
|
+
timeoutMs: integer("Wall-clock cap in ms (can only lower the workspace limit)."),
|
|
3586
|
+
...background ? { background: boolean("Start it in the background and return its id at once (dev servers, watchers).") } : {}
|
|
3587
|
+
},
|
|
3588
|
+
required: ["command"]
|
|
3589
|
+
};
|
|
3590
|
+
}
|
|
3591
|
+
/** `shell_output`. */
|
|
3592
|
+
const SHELL_OUTPUT_JSON_SCHEMA = {
|
|
3593
|
+
type: "object",
|
|
3594
|
+
properties: {
|
|
3595
|
+
id: string("Background command id from run_shell."),
|
|
3596
|
+
since: integer("A cursor from an earlier result; omit for output since the last read."),
|
|
3597
|
+
waitMs: integer("Wait up to this long (max 30000) for new output.")
|
|
3598
|
+
},
|
|
3599
|
+
required: ["id"]
|
|
3600
|
+
};
|
|
3601
|
+
/** `shell_kill`. */
|
|
3602
|
+
const SHELL_KILL_JSON_SCHEMA = {
|
|
3603
|
+
type: "object",
|
|
3604
|
+
properties: { id: string("Background command id from run_shell.") },
|
|
3605
|
+
required: ["id"]
|
|
3606
|
+
};
|
|
3607
|
+
/** `run_tests`. */
|
|
3608
|
+
const RUN_TESTS_JSON_SCHEMA = {
|
|
3609
|
+
type: "object",
|
|
3610
|
+
properties: { pattern: string("Optional test path or name filter.") }
|
|
3611
|
+
};
|
|
3612
|
+
/** `grep`. */
|
|
3613
|
+
const GREP_JSON_SCHEMA = {
|
|
3614
|
+
type: "object",
|
|
3615
|
+
properties: {
|
|
3616
|
+
pattern: string("Regular expression to search for."),
|
|
3617
|
+
glob: string("Only search files matching this glob (e.g. \"src/**/*.ts\")."),
|
|
3618
|
+
ignoreCase: boolean("Case-insensitive match.")
|
|
3619
|
+
},
|
|
3620
|
+
required: ["pattern"]
|
|
3621
|
+
};
|
|
3622
|
+
/** `glob`. */
|
|
3623
|
+
const GLOB_JSON_SCHEMA = {
|
|
3624
|
+
type: "object",
|
|
3625
|
+
properties: { pattern: string("Glob such as \"src/**/*.ts\".") },
|
|
3626
|
+
required: ["pattern"]
|
|
3627
|
+
};
|
|
3628
|
+
/** `todo_write`. */
|
|
3629
|
+
const TODO_WRITE_JSON_SCHEMA = {
|
|
3630
|
+
type: "object",
|
|
3631
|
+
properties: { todos: {
|
|
3632
|
+
type: "array",
|
|
3633
|
+
description: "The complete updated list (replaces the old one).",
|
|
3634
|
+
maxItems: 100,
|
|
3635
|
+
items: {
|
|
3636
|
+
type: "object",
|
|
3637
|
+
properties: {
|
|
3638
|
+
id: string("Stable id; omit to number items by position."),
|
|
3639
|
+
content: string("The step."),
|
|
3640
|
+
status: {
|
|
3641
|
+
type: "string",
|
|
3642
|
+
enum: [
|
|
3643
|
+
"pending",
|
|
3644
|
+
"in_progress",
|
|
3645
|
+
"completed"
|
|
3646
|
+
]
|
|
3647
|
+
}
|
|
3648
|
+
},
|
|
3649
|
+
required: ["content", "status"]
|
|
3650
|
+
}
|
|
3651
|
+
} },
|
|
3652
|
+
required: ["todos"]
|
|
3653
|
+
};
|
|
3654
|
+
/** `todo_read`. */
|
|
3655
|
+
const TODO_READ_JSON_SCHEMA = {
|
|
3656
|
+
type: "object",
|
|
3657
|
+
properties: {}
|
|
3658
|
+
};
|
|
3659
|
+
|
|
1355
3660
|
//#endregion
|
|
1356
3661
|
//#region ../../packages/ai-workspace/src/tools/edit-file.ts
|
|
1357
3662
|
/** Default tool name exposed to the LLM. */
|
|
1358
|
-
const DEFAULT_NAME$
|
|
3663
|
+
const DEFAULT_NAME$6 = "edit_file";
|
|
1359
3664
|
/** Input schema for the `edit_file` tool. */
|
|
1360
|
-
const inputSchema$
|
|
3665
|
+
const inputSchema$6 = objectSchema({
|
|
1361
3666
|
path: stringField(),
|
|
1362
3667
|
oldString: stringField(),
|
|
1363
3668
|
newString: stringField(),
|
|
1364
3669
|
replaceAll: optionalBooleanField(),
|
|
1365
3670
|
expectHash: optionalStringField()
|
|
1366
|
-
});
|
|
3671
|
+
}, EDIT_FILE_JSON_SCHEMA);
|
|
1367
3672
|
/**
|
|
1368
3673
|
* Build the agent-facing `edit_file` tool over a workspace's policy-
|
|
1369
3674
|
* enforced {@link WorkspaceOps}.
|
|
@@ -1395,9 +3700,9 @@ const inputSchema$2 = objectSchema({
|
|
|
1395
3700
|
*/
|
|
1396
3701
|
function makeEditFileTool(ops, options) {
|
|
1397
3702
|
return (0, _warlock_js_ai.tool)({
|
|
1398
|
-
name: options?.name ?? DEFAULT_NAME$
|
|
3703
|
+
name: options?.name ?? DEFAULT_NAME$6,
|
|
1399
3704
|
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$
|
|
3705
|
+
input: inputSchema$6,
|
|
1401
3706
|
async execute(input) {
|
|
1402
3707
|
return ops.editFile(input);
|
|
1403
3708
|
}
|
|
@@ -1412,7 +3717,7 @@ function makeEditFileTool(ops, options) {
|
|
|
1412
3717
|
* (no schema library, matching the validator idiom the `@warlock.js/ai`
|
|
1413
3718
|
* tool runtime expects).
|
|
1414
3719
|
*/
|
|
1415
|
-
const globInputSchema = objectSchema({ pattern: stringField() });
|
|
3720
|
+
const globInputSchema = objectSchema({ pattern: stringField() }, GLOB_JSON_SCHEMA);
|
|
1416
3721
|
/**
|
|
1417
3722
|
* Build the agent-facing `glob` tool — resolve a glob pattern to the
|
|
1418
3723
|
* matching workspace-relative paths within the jail. The returned
|
|
@@ -1456,7 +3761,7 @@ const grepInputSchema = objectSchema({
|
|
|
1456
3761
|
pattern: stringField(),
|
|
1457
3762
|
glob: optionalStringField(),
|
|
1458
3763
|
ignoreCase: optionalBooleanField()
|
|
1459
|
-
});
|
|
3764
|
+
}, GREP_JSON_SCHEMA);
|
|
1460
3765
|
/**
|
|
1461
3766
|
* Build the agent-facing `grep` tool — a regex content search across the
|
|
1462
3767
|
* jailed file set. The returned {@link ToolContract} validates the LLM's
|
|
@@ -1490,16 +3795,174 @@ function makeGrepTool(ops, options) {
|
|
|
1490
3795
|
});
|
|
1491
3796
|
}
|
|
1492
3797
|
|
|
3798
|
+
//#endregion
|
|
3799
|
+
//#region ../../packages/ai-workspace/src/tools/list-dir.ts
|
|
3800
|
+
/** Default tool name exposed to the LLM. */
|
|
3801
|
+
const DEFAULT_NAME$5 = "list_dir";
|
|
3802
|
+
/** Input schema for the `list_dir` tool. */
|
|
3803
|
+
const inputSchema$5 = objectSchema({
|
|
3804
|
+
path: optionalStringField(),
|
|
3805
|
+
depth: optionalNumberField(),
|
|
3806
|
+
ignore: optionalStringArrayField()
|
|
3807
|
+
}, LIST_DIR_JSON_SCHEMA);
|
|
3808
|
+
/**
|
|
3809
|
+
* Build the agent-facing `list_dir` tool over a workspace's policy-enforced
|
|
3810
|
+
* {@link WorkspaceOps}: a bounded, jailed listing of one directory.
|
|
3811
|
+
*
|
|
3812
|
+
* Entries come back breadth-first (each level sorted by name) with their
|
|
3813
|
+
* type and — for files — size, down to `depth` levels (capped by
|
|
3814
|
+
* `list.maxDepth`) and at most `list.maxEntries` entries (`truncated` says
|
|
3815
|
+
* when the cap hit). `denyPaths` are always left out, as are the
|
|
3816
|
+
* `list.ignore` globs (default `node_modules`, `.git`) and any `ignore`
|
|
3817
|
+
* globs the model passes. Symlinks are reported as `"symlink"` and never
|
|
3818
|
+
* followed. A path outside the jail surfaces as a `WorkspacePolicyError` in
|
|
3819
|
+
* the result's `error` field.
|
|
3820
|
+
*
|
|
3821
|
+
* @param ops - The shared, policy-enforced operation layer.
|
|
3822
|
+
* @param options - Optional overrides; `name` renames the LLM-visible tool.
|
|
3823
|
+
*
|
|
3824
|
+
* @example
|
|
3825
|
+
* const listDir = makeListDirTool(ops);
|
|
3826
|
+
* const { data } = await listDir.invoke({ path: "src", depth: 2 });
|
|
3827
|
+
* for (const entry of data?.entries ?? []) console.log(entry.type, entry.path, entry.size);
|
|
3828
|
+
*/
|
|
3829
|
+
function makeListDirTool(ops, options) {
|
|
3830
|
+
return (0, _warlock_js_ai.tool)({
|
|
3831
|
+
name: options?.name ?? DEFAULT_NAME$5,
|
|
3832
|
+
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.",
|
|
3833
|
+
action: (input) => `Listing ${typeof input.path === "string" ? input.path : "."}`,
|
|
3834
|
+
input: inputSchema$5,
|
|
3835
|
+
execute: (input) => ops.listDir(input.path ?? ".", {
|
|
3836
|
+
...input.depth !== void 0 ? { depth: input.depth } : {},
|
|
3837
|
+
...input.ignore !== void 0 ? { ignore: input.ignore } : {}
|
|
3838
|
+
})
|
|
3839
|
+
});
|
|
3840
|
+
}
|
|
3841
|
+
|
|
3842
|
+
//#endregion
|
|
3843
|
+
//#region ../../packages/ai-workspace/src/tools/multi-edit.ts
|
|
3844
|
+
/** Default tool name exposed to the LLM. */
|
|
3845
|
+
const DEFAULT_NAME$4 = "multi_edit";
|
|
3846
|
+
/** Validate one element of `edits`. */
|
|
3847
|
+
function validateEdit(value, index) {
|
|
3848
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) return { issues: [{
|
|
3849
|
+
message: `edits[${index}] must be an object`,
|
|
3850
|
+
path: ["edits", index]
|
|
3851
|
+
}] };
|
|
3852
|
+
const candidate = value;
|
|
3853
|
+
const issues = [];
|
|
3854
|
+
if (typeof candidate.oldString !== "string" || candidate.oldString === "") issues.push({
|
|
3855
|
+
message: `edits[${index}].oldString must be a non-empty string`,
|
|
3856
|
+
path: [
|
|
3857
|
+
"edits",
|
|
3858
|
+
index,
|
|
3859
|
+
"oldString"
|
|
3860
|
+
]
|
|
3861
|
+
});
|
|
3862
|
+
if (typeof candidate.newString !== "string") issues.push({
|
|
3863
|
+
message: `edits[${index}].newString must be a string`,
|
|
3864
|
+
path: [
|
|
3865
|
+
"edits",
|
|
3866
|
+
index,
|
|
3867
|
+
"newString"
|
|
3868
|
+
]
|
|
3869
|
+
});
|
|
3870
|
+
if (candidate.replaceAll !== void 0 && typeof candidate.replaceAll !== "boolean") issues.push({
|
|
3871
|
+
message: `edits[${index}].replaceAll must be a boolean when provided`,
|
|
3872
|
+
path: [
|
|
3873
|
+
"edits",
|
|
3874
|
+
index,
|
|
3875
|
+
"replaceAll"
|
|
3876
|
+
]
|
|
3877
|
+
});
|
|
3878
|
+
if (issues.length > 0 || typeof candidate.oldString !== "string" || typeof candidate.newString !== "string") return { issues };
|
|
3879
|
+
return { edit: {
|
|
3880
|
+
oldString: candidate.oldString,
|
|
3881
|
+
newString: candidate.newString,
|
|
3882
|
+
...candidate.replaceAll === true ? { replaceAll: true } : {}
|
|
3883
|
+
} };
|
|
3884
|
+
}
|
|
3885
|
+
/** Input schema for the `multi_edit` tool. */
|
|
3886
|
+
const inputSchema$4 = withJsonSchema({ "~standard": {
|
|
3887
|
+
version: 1,
|
|
3888
|
+
vendor: "ai-workspace",
|
|
3889
|
+
validate(value) {
|
|
3890
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) return { issues: [{ message: "input must be an object" }] };
|
|
3891
|
+
const candidate = value;
|
|
3892
|
+
const issues = [];
|
|
3893
|
+
const edits = [];
|
|
3894
|
+
if (typeof candidate.path !== "string" || candidate.path === "") issues.push({
|
|
3895
|
+
message: "\"path\" must be a non-empty string",
|
|
3896
|
+
path: ["path"]
|
|
3897
|
+
});
|
|
3898
|
+
if (candidate.expectHash !== void 0 && typeof candidate.expectHash !== "string") issues.push({
|
|
3899
|
+
message: "\"expectHash\" must be a string when provided",
|
|
3900
|
+
path: ["expectHash"]
|
|
3901
|
+
});
|
|
3902
|
+
if (!Array.isArray(candidate.edits) || candidate.edits.length === 0 || candidate.edits.length > 100) issues.push({
|
|
3903
|
+
message: `"edits" must be an array of 1 to ${100} edits`,
|
|
3904
|
+
path: ["edits"]
|
|
3905
|
+
});
|
|
3906
|
+
else candidate.edits.forEach((item, index) => {
|
|
3907
|
+
const outcome = validateEdit(item, index);
|
|
3908
|
+
if ("issues" in outcome) issues.push(...outcome.issues);
|
|
3909
|
+
else edits.push(outcome.edit);
|
|
3910
|
+
});
|
|
3911
|
+
if (issues.length > 0 || typeof candidate.path !== "string") return { issues };
|
|
3912
|
+
return { value: {
|
|
3913
|
+
path: candidate.path,
|
|
3914
|
+
edits,
|
|
3915
|
+
...typeof candidate.expectHash === "string" ? { expectHash: candidate.expectHash } : {}
|
|
3916
|
+
} };
|
|
3917
|
+
}
|
|
3918
|
+
} }, MULTI_EDIT_JSON_SCHEMA);
|
|
3919
|
+
/**
|
|
3920
|
+
* Build the agent-facing `multi_edit` tool over a workspace's policy-
|
|
3921
|
+
* enforced {@link WorkspaceOps}: several exact-string replacements in one
|
|
3922
|
+
* file, **atomic** — every edit applies, in order (each against the result
|
|
3923
|
+
* of the previous ones), and the file is written once; or one edit fails
|
|
3924
|
+
* and nothing is written.
|
|
3925
|
+
*
|
|
3926
|
+
* Each edit follows the `edit_file` rules: `oldString` must be found, and
|
|
3927
|
+
* found exactly once unless `replaceAll`. An optional `expectHash` (from
|
|
3928
|
+
* `read_file`) rejects the whole call when the file changed since it was
|
|
3929
|
+
* read. A failure surfaces as a `WorkspaceEditError` in the result's
|
|
3930
|
+
* `error` field, with `editIndex` naming the failing edit.
|
|
3931
|
+
*
|
|
3932
|
+
* @param ops - The shared, policy-enforced operation layer.
|
|
3933
|
+
* @param options - Optional overrides; `name` renames the LLM-visible tool.
|
|
3934
|
+
*
|
|
3935
|
+
* @example
|
|
3936
|
+
* const multiEdit = makeMultiEditTool(ops);
|
|
3937
|
+
* const { data, error } = await multiEdit.invoke({
|
|
3938
|
+
* path: "src/cart.ts",
|
|
3939
|
+
* expectHash,
|
|
3940
|
+
* edits: [
|
|
3941
|
+
* { oldString: "let total = 0;", newString: "let total = 0n;" },
|
|
3942
|
+
* { oldString: "total += price;", newString: "total += BigInt(price);" },
|
|
3943
|
+
* ],
|
|
3944
|
+
* });
|
|
3945
|
+
*/
|
|
3946
|
+
function makeMultiEditTool(ops, options) {
|
|
3947
|
+
return (0, _warlock_js_ai.tool)({
|
|
3948
|
+
name: options?.name ?? DEFAULT_NAME$4,
|
|
3949
|
+
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.",
|
|
3950
|
+
action: (input) => `Editing ${typeof input.path === "string" ? input.path : "a file"}`,
|
|
3951
|
+
input: inputSchema$4,
|
|
3952
|
+
execute: (input) => ops.multiEdit(input)
|
|
3953
|
+
});
|
|
3954
|
+
}
|
|
3955
|
+
|
|
1493
3956
|
//#endregion
|
|
1494
3957
|
//#region ../../packages/ai-workspace/src/tools/read-file.ts
|
|
1495
3958
|
/** Default tool name exposed to the LLM. */
|
|
1496
|
-
const DEFAULT_NAME$
|
|
3959
|
+
const DEFAULT_NAME$3 = "read_file";
|
|
1497
3960
|
/** Input schema for the `read_file` tool. */
|
|
1498
|
-
const inputSchema$
|
|
3961
|
+
const inputSchema$3 = objectSchema({
|
|
1499
3962
|
path: stringField(),
|
|
1500
3963
|
startLine: optionalNumberField(),
|
|
1501
3964
|
limit: optionalNumberField()
|
|
1502
|
-
});
|
|
3965
|
+
}, READ_FILE_JSON_SCHEMA);
|
|
1503
3966
|
/**
|
|
1504
3967
|
* Build the agent-facing `read_file` tool over a workspace's policy-
|
|
1505
3968
|
* enforced {@link WorkspaceOps}.
|
|
@@ -1526,9 +3989,9 @@ const inputSchema$1 = objectSchema({
|
|
|
1526
3989
|
*/
|
|
1527
3990
|
function makeReadFileTool(ops, options) {
|
|
1528
3991
|
return (0, _warlock_js_ai.tool)({
|
|
1529
|
-
name: options?.name ?? DEFAULT_NAME$
|
|
3992
|
+
name: options?.name ?? DEFAULT_NAME$3,
|
|
1530
3993
|
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$
|
|
3994
|
+
input: inputSchema$3,
|
|
1532
3995
|
async execute(input) {
|
|
1533
3996
|
return ops.readFile(input.path, {
|
|
1534
3997
|
offset: input.startLine !== void 0 ? Math.max(1, input.startLine) : 1,
|
|
@@ -1545,64 +4008,67 @@ const DEFAULT_RUN_SHELL_TOOL_NAME = "run_shell";
|
|
|
1545
4008
|
/**
|
|
1546
4009
|
* Hand-rolled Standard Schema for {@link RunShellInput}. We validate the
|
|
1547
4010
|
* 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);
|
|
4011
|
+
* a non-empty string, `timeoutMs` (when present) a positive number and
|
|
4012
|
+
* `background` (when present) a boolean. Invalid args surface as a
|
|
4013
|
+
* `SchemaValidationError` in the tool result's `error` field rather than
|
|
4014
|
+
* reaching `ops.exec`.
|
|
1595
4015
|
*/
|
|
4016
|
+
function runShellInputSchema(background) {
|
|
4017
|
+
return withJsonSchema({ "~standard": {
|
|
4018
|
+
version: 1,
|
|
4019
|
+
vendor: "@warlock.js/ai-workspace",
|
|
4020
|
+
validate: (value) => {
|
|
4021
|
+
if (typeof value !== "object" || value === null) return { issues: [{ message: "expected an object" }] };
|
|
4022
|
+
const candidate = value;
|
|
4023
|
+
if (typeof candidate.command !== "string" || candidate.command.length === 0) return { issues: [{
|
|
4024
|
+
message: "command must be a non-empty string",
|
|
4025
|
+
path: ["command"]
|
|
4026
|
+
}] };
|
|
4027
|
+
if (candidate.timeoutMs !== void 0 && (typeof candidate.timeoutMs !== "number" || candidate.timeoutMs <= 0)) return { issues: [{
|
|
4028
|
+
message: "timeoutMs must be a positive number",
|
|
4029
|
+
path: ["timeoutMs"]
|
|
4030
|
+
}] };
|
|
4031
|
+
if (candidate.background !== void 0 && typeof candidate.background !== "boolean") return { issues: [{
|
|
4032
|
+
message: "background must be a boolean",
|
|
4033
|
+
path: ["background"]
|
|
4034
|
+
}] };
|
|
4035
|
+
const result = { command: candidate.command };
|
|
4036
|
+
if (typeof candidate.timeoutMs === "number") result.timeoutMs = candidate.timeoutMs;
|
|
4037
|
+
if (candidate.background === true) result.background = true;
|
|
4038
|
+
return { value: result };
|
|
4039
|
+
}
|
|
4040
|
+
} }, runShellJsonSchema(background));
|
|
4041
|
+
}
|
|
4042
|
+
/** The lower of the model's timeout and the cap (either may be absent). */
|
|
4043
|
+
function cappedTimeout(requested, cap) {
|
|
4044
|
+
if (requested === void 0) return cap;
|
|
4045
|
+
return cap === void 0 ? requested : Math.min(requested, cap);
|
|
4046
|
+
}
|
|
1596
4047
|
function makeRunShellTool(ops, options) {
|
|
4048
|
+
const background = options?.background === true;
|
|
1597
4049
|
return (0, _warlock_js_ai.tool)({
|
|
1598
4050
|
name: options?.name ?? DEFAULT_RUN_SHELL_TOOL_NAME,
|
|
1599
|
-
description: "Run a
|
|
4051
|
+
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
4052
|
action: (input) => `Running \`${input.command}\``,
|
|
1601
|
-
input: runShellInputSchema,
|
|
1602
|
-
execute: (input, ctx) =>
|
|
1603
|
-
|
|
1604
|
-
|
|
1605
|
-
|
|
4053
|
+
input: runShellInputSchema(background),
|
|
4054
|
+
execute: async (input, ctx) => {
|
|
4055
|
+
if (input.background === true) {
|
|
4056
|
+
if (!background) throw new WorkspacePolicyError("Background commands are not enabled for this workspace; run the command without background.", {
|
|
4057
|
+
type: "denied-command",
|
|
4058
|
+
reason: "not-allowed",
|
|
4059
|
+
command: input.command
|
|
4060
|
+
});
|
|
4061
|
+
return {
|
|
4062
|
+
...await ops.startShell(input.command, input.timeoutMs !== void 0 ? { timeoutMs: input.timeoutMs } : void 0),
|
|
4063
|
+
background: true
|
|
4064
|
+
};
|
|
4065
|
+
}
|
|
4066
|
+
const timeoutMs = cappedTimeout(input.timeoutMs, options?.maxTimeoutMs);
|
|
4067
|
+
return ops.exec(input.command, {
|
|
4068
|
+
...timeoutMs !== void 0 ? { timeoutMs } : {},
|
|
4069
|
+
...ctx?.signal ? { signal: ctx.signal } : {}
|
|
4070
|
+
});
|
|
4071
|
+
}
|
|
1606
4072
|
});
|
|
1607
4073
|
}
|
|
1608
4074
|
|
|
@@ -1621,7 +4087,7 @@ const DEFAULT_TEST_COMMAND = "npm test";
|
|
|
1621
4087
|
* happens without a runtime schema dependency, mirroring the wider tool
|
|
1622
4088
|
* layer.
|
|
1623
4089
|
*/
|
|
1624
|
-
const runTestsInputSchema = { "~standard": {
|
|
4090
|
+
const runTestsInputSchema = withJsonSchema({ "~standard": {
|
|
1625
4091
|
version: 1,
|
|
1626
4092
|
vendor: "@warlock.js/ai-workspace",
|
|
1627
4093
|
validate: (value) => {
|
|
@@ -1640,7 +4106,12 @@ const runTestsInputSchema = { "~standard": {
|
|
|
1640
4106
|
if (candidate.pattern !== void 0) result.pattern = candidate.pattern;
|
|
1641
4107
|
return { value: result };
|
|
1642
4108
|
}
|
|
1643
|
-
} };
|
|
4109
|
+
} }, RUN_TESTS_JSON_SCHEMA);
|
|
4110
|
+
/** Quote the test pattern as one literal argument for the line's interpreter. */
|
|
4111
|
+
function quotePattern(pattern, interpreter) {
|
|
4112
|
+
if (interpreter === "sh" && process.platform !== "win32") return `'${pattern.replace(/'/g, "'\\''")}'`;
|
|
4113
|
+
return `"${pattern}"`;
|
|
4114
|
+
}
|
|
1644
4115
|
/**
|
|
1645
4116
|
* Build the `run_tests` tool — a {@link ToolContract} convenience over
|
|
1646
4117
|
* `run_shell` that runs the workspace's configured test command through
|
|
@@ -1673,12 +4144,275 @@ function makeRunTestsTool(ops, options) {
|
|
|
1673
4144
|
action: (input) => input.pattern ? `Running tests matching "${input.pattern}"` : "Running tests",
|
|
1674
4145
|
input: runTestsInputSchema,
|
|
1675
4146
|
execute: (input, ctx) => {
|
|
1676
|
-
const command = input.pattern ? `${baseCommand}
|
|
4147
|
+
const command = input.pattern ? `${baseCommand} ${quotePattern(input.pattern, options?.interpreter ?? "none")}` : baseCommand;
|
|
1677
4148
|
return ops.exec(command, ctx?.signal ? { signal: ctx.signal } : void 0);
|
|
1678
4149
|
}
|
|
1679
4150
|
});
|
|
1680
4151
|
}
|
|
1681
4152
|
|
|
4153
|
+
//#endregion
|
|
4154
|
+
//#region ../../packages/ai-workspace/src/tools/shell-kill.ts
|
|
4155
|
+
/** Default tool name exposed to the LLM. */
|
|
4156
|
+
const DEFAULT_NAME$2 = "shell_kill";
|
|
4157
|
+
/** Input schema for the `shell_kill` tool. */
|
|
4158
|
+
const inputSchema$2 = objectSchema({ id: stringField() }, SHELL_KILL_JSON_SCHEMA);
|
|
4159
|
+
/**
|
|
4160
|
+
* Build the agent-facing `shell_kill` tool: stop a background command
|
|
4161
|
+
* started with `run_shell({ background: true })`, killing its whole process
|
|
4162
|
+
* tree, and return its final state. Killing a command that already stopped
|
|
4163
|
+
* just reports its state. An unknown id surfaces as a `WorkspaceJobError`
|
|
4164
|
+
* in the result's `error` field.
|
|
4165
|
+
*
|
|
4166
|
+
* @param ops - The shared, policy-enforced operation layer.
|
|
4167
|
+
* @param options - Optional overrides; `name` renames the LLM-visible tool.
|
|
4168
|
+
*
|
|
4169
|
+
* @example
|
|
4170
|
+
* const { data } = await makeShellKillTool(ops).invoke({ id: "job-1" });
|
|
4171
|
+
* console.log(data?.status); // "killed"
|
|
4172
|
+
*/
|
|
4173
|
+
function makeShellKillTool(ops, options) {
|
|
4174
|
+
return (0, _warlock_js_ai.tool)({
|
|
4175
|
+
name: options?.name ?? DEFAULT_NAME$2,
|
|
4176
|
+
description: "Stop a background command started with run_shell (background: true), killing it and everything it started. Returns its final state.",
|
|
4177
|
+
action: (input) => `Stopping ${typeof input.id === "string" ? input.id : "a background command"}`,
|
|
4178
|
+
input: inputSchema$2,
|
|
4179
|
+
execute: (input) => ops.shellKill(input.id)
|
|
4180
|
+
});
|
|
4181
|
+
}
|
|
4182
|
+
|
|
4183
|
+
//#endregion
|
|
4184
|
+
//#region ../../packages/ai-workspace/src/tools/shell-output.ts
|
|
4185
|
+
/** Default tool name exposed to the LLM. */
|
|
4186
|
+
const DEFAULT_NAME$1 = "shell_output";
|
|
4187
|
+
/** Input schema for the `shell_output` tool. */
|
|
4188
|
+
const inputSchema$1 = objectSchema({
|
|
4189
|
+
id: stringField(),
|
|
4190
|
+
since: optionalNumberField(),
|
|
4191
|
+
waitMs: optionalNumberField()
|
|
4192
|
+
}, SHELL_OUTPUT_JSON_SCHEMA);
|
|
4193
|
+
/**
|
|
4194
|
+
* Build the agent-facing `shell_output` tool: the new output of a
|
|
4195
|
+
* background command started with `run_shell({ background: true })`, plus
|
|
4196
|
+
* its state (`running` / `exited` / `killed` / `timed-out`, `exitCode`).
|
|
4197
|
+
*
|
|
4198
|
+
* Without `since` it returns everything produced since the previous read;
|
|
4199
|
+
* `cursor` in the result is the position to pass as `since` next time.
|
|
4200
|
+
* `waitMs` (at most 30 s) waits for new output or for the command to stop
|
|
4201
|
+
* when there is nothing new yet — the agent run's signal ends the wait
|
|
4202
|
+
* early. Only the newest `shell.maxOutputBytes` of a command's output are
|
|
4203
|
+
* kept; `droppedBytes` says how much the reader missed.
|
|
4204
|
+
*
|
|
4205
|
+
* @param ops - The shared, policy-enforced operation layer.
|
|
4206
|
+
* @param options - Optional overrides; `name` renames the LLM-visible tool.
|
|
4207
|
+
*
|
|
4208
|
+
* @example
|
|
4209
|
+
* const { data } = await makeShellOutputTool(ops).invoke({ id: "job-1", waitMs: 2_000 });
|
|
4210
|
+
* if (data?.stdout.includes("ready")) console.log("dev server is up");
|
|
4211
|
+
*/
|
|
4212
|
+
function makeShellOutputTool(ops, options) {
|
|
4213
|
+
return (0, _warlock_js_ai.tool)({
|
|
4214
|
+
name: options?.name ?? DEFAULT_NAME$1,
|
|
4215
|
+
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.",
|
|
4216
|
+
action: (input) => `Reading output of ${typeof input.id === "string" ? input.id : "a background command"}`,
|
|
4217
|
+
input: inputSchema$1,
|
|
4218
|
+
execute: (input, ctx) => ops.shellOutput(input.id, {
|
|
4219
|
+
...input.since !== void 0 ? { since: input.since } : {},
|
|
4220
|
+
...input.waitMs !== void 0 ? { waitMs: input.waitMs } : {},
|
|
4221
|
+
...ctx?.signal ? { signal: ctx.signal } : {}
|
|
4222
|
+
})
|
|
4223
|
+
});
|
|
4224
|
+
}
|
|
4225
|
+
|
|
4226
|
+
//#endregion
|
|
4227
|
+
//#region ../../packages/ai-workspace/src/todos.ts
|
|
4228
|
+
/** Most items a todo list may hold. */
|
|
4229
|
+
const MAX_TODOS = 100;
|
|
4230
|
+
/** Longest `content` one todo item may have. */
|
|
4231
|
+
const MAX_TODO_CONTENT_LENGTH = 1e3;
|
|
4232
|
+
/** The statuses a todo item may have, in display order. */
|
|
4233
|
+
const TODO_STATUSES = [
|
|
4234
|
+
"pending",
|
|
4235
|
+
"in_progress",
|
|
4236
|
+
"completed"
|
|
4237
|
+
];
|
|
4238
|
+
/**
|
|
4239
|
+
* Validate a whole replacement list. Returns the problems found (empty when
|
|
4240
|
+
* the list is valid) so the tool schema and the direct method share one
|
|
4241
|
+
* set of rules.
|
|
4242
|
+
*/
|
|
4243
|
+
function todoListProblems(todos) {
|
|
4244
|
+
const problems = [];
|
|
4245
|
+
const ids = /* @__PURE__ */ new Set();
|
|
4246
|
+
if (todos.length > 100) problems.push(`a todo list holds at most ${100} items (got ${todos.length})`);
|
|
4247
|
+
todos.forEach((todo, index) => {
|
|
4248
|
+
if (todo.content.trim() === "") problems.push(`todos[${index}].content must not be empty`);
|
|
4249
|
+
if (todo.content.length > 1e3) problems.push(`todos[${index}].content is longer than ${MAX_TODO_CONTENT_LENGTH} characters`);
|
|
4250
|
+
if (!TODO_STATUSES.includes(todo.status)) problems.push(`todos[${index}].status must be one of ${TODO_STATUSES.join(", ")}`);
|
|
4251
|
+
const id = todo.id ?? String(index + 1);
|
|
4252
|
+
if (ids.has(id)) problems.push(`todos[${index}].id "${id}" is used more than once`);
|
|
4253
|
+
ids.add(id);
|
|
4254
|
+
});
|
|
4255
|
+
return problems;
|
|
4256
|
+
}
|
|
4257
|
+
/**
|
|
4258
|
+
* The in-memory todo list of one workspace (shared by its projections).
|
|
4259
|
+
* `write` replaces the whole list, the way an agent's plan tool usually
|
|
4260
|
+
* works: the model sends the full updated list every time.
|
|
4261
|
+
*/
|
|
4262
|
+
var TodoStore = class {
|
|
4263
|
+
constructor() {
|
|
4264
|
+
this.items = [];
|
|
4265
|
+
}
|
|
4266
|
+
/** The current list and a count per status. */
|
|
4267
|
+
read() {
|
|
4268
|
+
const counts = {
|
|
4269
|
+
pending: 0,
|
|
4270
|
+
in_progress: 0,
|
|
4271
|
+
completed: 0
|
|
4272
|
+
};
|
|
4273
|
+
for (const item of this.items) counts[item.status]++;
|
|
4274
|
+
return {
|
|
4275
|
+
todos: this.items.map((item) => ({ ...item })),
|
|
4276
|
+
counts
|
|
4277
|
+
};
|
|
4278
|
+
}
|
|
4279
|
+
/**
|
|
4280
|
+
* Replace the list. Items without an `id` get their 1-based position as
|
|
4281
|
+
* one. Throws a `RangeError` naming every problem when the list is invalid
|
|
4282
|
+
* (too long, empty content, unknown status, duplicate id); the old list is
|
|
4283
|
+
* kept in that case.
|
|
4284
|
+
*/
|
|
4285
|
+
write(todos) {
|
|
4286
|
+
const problems = todoListProblems(todos);
|
|
4287
|
+
if (problems.length > 0) throw new RangeError(`Invalid todo list: ${problems.join("; ")}.`);
|
|
4288
|
+
this.items = todos.map((todo, index) => ({
|
|
4289
|
+
id: todo.id ?? String(index + 1),
|
|
4290
|
+
content: todo.content.trim(),
|
|
4291
|
+
status: todo.status
|
|
4292
|
+
}));
|
|
4293
|
+
return this.read();
|
|
4294
|
+
}
|
|
4295
|
+
};
|
|
4296
|
+
|
|
4297
|
+
//#endregion
|
|
4298
|
+
//#region ../../packages/ai-workspace/src/tools/todo.ts
|
|
4299
|
+
/** Whether `value` is a todo status. */
|
|
4300
|
+
function isTodoStatus(value) {
|
|
4301
|
+
return typeof value === "string" && TODO_STATUSES.includes(value);
|
|
4302
|
+
}
|
|
4303
|
+
/** Input schema for the `todo_write` tool. */
|
|
4304
|
+
const todoWriteSchema = withJsonSchema({ "~standard": {
|
|
4305
|
+
version: 1,
|
|
4306
|
+
vendor: "ai-workspace",
|
|
4307
|
+
validate(value) {
|
|
4308
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) return { issues: [{ message: "input must be an object" }] };
|
|
4309
|
+
const raw = value.todos;
|
|
4310
|
+
if (!Array.isArray(raw) || raw.length > 100) return { issues: [{
|
|
4311
|
+
message: `"todos" must be an array of at most ${100} items`,
|
|
4312
|
+
path: ["todos"]
|
|
4313
|
+
}] };
|
|
4314
|
+
const issues = [];
|
|
4315
|
+
const todos = [];
|
|
4316
|
+
raw.forEach((item, index) => {
|
|
4317
|
+
if (typeof item !== "object" || item === null || Array.isArray(item)) {
|
|
4318
|
+
issues.push({
|
|
4319
|
+
message: `todos[${index}] must be an object`,
|
|
4320
|
+
path: ["todos", index]
|
|
4321
|
+
});
|
|
4322
|
+
return;
|
|
4323
|
+
}
|
|
4324
|
+
const candidate = item;
|
|
4325
|
+
if (typeof candidate.content !== "string") issues.push({
|
|
4326
|
+
message: `todos[${index}].content must be a string`,
|
|
4327
|
+
path: [
|
|
4328
|
+
"todos",
|
|
4329
|
+
index,
|
|
4330
|
+
"content"
|
|
4331
|
+
]
|
|
4332
|
+
});
|
|
4333
|
+
if (!isTodoStatus(candidate.status)) issues.push({
|
|
4334
|
+
message: `todos[${index}].status must be one of ${TODO_STATUSES.join(", ")}`,
|
|
4335
|
+
path: [
|
|
4336
|
+
"todos",
|
|
4337
|
+
index,
|
|
4338
|
+
"status"
|
|
4339
|
+
]
|
|
4340
|
+
});
|
|
4341
|
+
if (candidate.id !== void 0 && typeof candidate.id !== "string") issues.push({
|
|
4342
|
+
message: `todos[${index}].id must be a string when provided`,
|
|
4343
|
+
path: [
|
|
4344
|
+
"todos",
|
|
4345
|
+
index,
|
|
4346
|
+
"id"
|
|
4347
|
+
]
|
|
4348
|
+
});
|
|
4349
|
+
if (typeof candidate.content === "string" && isTodoStatus(candidate.status)) todos.push({
|
|
4350
|
+
content: candidate.content,
|
|
4351
|
+
status: candidate.status,
|
|
4352
|
+
...typeof candidate.id === "string" ? { id: candidate.id } : {}
|
|
4353
|
+
});
|
|
4354
|
+
});
|
|
4355
|
+
if (issues.length === 0) for (const problem of todoListProblems(todos)) issues.push({
|
|
4356
|
+
message: problem,
|
|
4357
|
+
path: ["todos"]
|
|
4358
|
+
});
|
|
4359
|
+
return issues.length > 0 ? { issues } : { value: { todos } };
|
|
4360
|
+
}
|
|
4361
|
+
} }, TODO_WRITE_JSON_SCHEMA);
|
|
4362
|
+
/** Input schema for the `todo_read` tool (no arguments). */
|
|
4363
|
+
const todoReadSchema = withJsonSchema({ "~standard": {
|
|
4364
|
+
version: 1,
|
|
4365
|
+
vendor: "ai-workspace",
|
|
4366
|
+
validate: () => ({ value: {} })
|
|
4367
|
+
} }, TODO_READ_JSON_SCHEMA);
|
|
4368
|
+
/**
|
|
4369
|
+
* Build the agent-facing `todo_write` tool over a workspace's todo list:
|
|
4370
|
+
* the model sends the **complete** updated list (each item `content` +
|
|
4371
|
+
* `status` of `pending` / `in_progress` / `completed`, optional `id`) and it
|
|
4372
|
+
* replaces the old one. The list lives in memory, per workspace (shared by
|
|
4373
|
+
* its `readonly()` / `scope()` projections), for the length of the run.
|
|
4374
|
+
*
|
|
4375
|
+
* @param store - The workspace's todo list.
|
|
4376
|
+
* @param options - Optional overrides; `name` renames the LLM-visible tool.
|
|
4377
|
+
*
|
|
4378
|
+
* @example
|
|
4379
|
+
* await makeTodoWriteTool(store).invoke({
|
|
4380
|
+
* todos: [
|
|
4381
|
+
* { content: "Reproduce the failing test", status: "completed" },
|
|
4382
|
+
* { content: "Fix the rounding bug", status: "in_progress" },
|
|
4383
|
+
* ],
|
|
4384
|
+
* });
|
|
4385
|
+
*/
|
|
4386
|
+
function makeTodoWriteTool(store, options) {
|
|
4387
|
+
return (0, _warlock_js_ai.tool)({
|
|
4388
|
+
name: options?.name ?? "todo_write",
|
|
4389
|
+
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.",
|
|
4390
|
+
action: "Updating the todo list",
|
|
4391
|
+
input: todoWriteSchema,
|
|
4392
|
+
execute: async (input) => store.write(input.todos)
|
|
4393
|
+
});
|
|
4394
|
+
}
|
|
4395
|
+
/**
|
|
4396
|
+
* Build the agent-facing `todo_read` tool: the workspace's current todo
|
|
4397
|
+
* list and a count per status.
|
|
4398
|
+
*
|
|
4399
|
+
* @param store - The workspace's todo list.
|
|
4400
|
+
* @param options - Optional overrides; `name` renames the LLM-visible tool.
|
|
4401
|
+
*
|
|
4402
|
+
* @example
|
|
4403
|
+
* const { data } = await makeTodoReadTool(store).invoke({});
|
|
4404
|
+
* console.log(data?.counts.pending);
|
|
4405
|
+
*/
|
|
4406
|
+
function makeTodoReadTool(store, options) {
|
|
4407
|
+
return (0, _warlock_js_ai.tool)({
|
|
4408
|
+
name: options?.name ?? "todo_read",
|
|
4409
|
+
description: "Read your current todo list for this task.",
|
|
4410
|
+
action: "Reading the todo list",
|
|
4411
|
+
input: todoReadSchema,
|
|
4412
|
+
execute: async () => store.read()
|
|
4413
|
+
});
|
|
4414
|
+
}
|
|
4415
|
+
|
|
1682
4416
|
//#endregion
|
|
1683
4417
|
//#region ../../packages/ai-workspace/src/tools/write-file.ts
|
|
1684
4418
|
/** Default tool name exposed to the LLM. */
|
|
@@ -1687,7 +4421,7 @@ const DEFAULT_NAME = "write_file";
|
|
|
1687
4421
|
const inputSchema = objectSchema({
|
|
1688
4422
|
path: stringField(),
|
|
1689
4423
|
content: stringField()
|
|
1690
|
-
});
|
|
4424
|
+
}, WRITE_FILE_JSON_SCHEMA);
|
|
1691
4425
|
/**
|
|
1692
4426
|
* Build the agent-facing `write_file` tool over a workspace's policy-
|
|
1693
4427
|
* enforced {@link WorkspaceOps}.
|
|
@@ -1735,23 +4469,35 @@ function makeWriteFileTool(ops, options) {
|
|
|
1735
4469
|
const ALL_TOOL_NAMES = [
|
|
1736
4470
|
"readFile",
|
|
1737
4471
|
"editFile",
|
|
4472
|
+
"multiEdit",
|
|
1738
4473
|
"writeFile",
|
|
4474
|
+
"listDir",
|
|
1739
4475
|
"runShell",
|
|
4476
|
+
"shellOutput",
|
|
4477
|
+
"shellKill",
|
|
1740
4478
|
"runTests",
|
|
1741
4479
|
"grep",
|
|
1742
|
-
"glob"
|
|
4480
|
+
"glob",
|
|
4481
|
+
"todoWrite",
|
|
4482
|
+
"todoRead"
|
|
1743
4483
|
];
|
|
1744
4484
|
/**
|
|
1745
4485
|
* The subset a {@link Workspace.readonly} projection exposes — the
|
|
1746
|
-
*
|
|
1747
|
-
* `runTests` are deliberately omitted so a
|
|
1748
|
-
* change
|
|
4486
|
+
* tools that cannot change the tree. `editFile` / `multiEdit` /
|
|
4487
|
+
* `writeFile` / the shell tools / `runTests` are deliberately omitted so a
|
|
4488
|
+
* reviewer agent has no path to change it. The todo tools stay: they only
|
|
4489
|
+
* touch the in-memory plan.
|
|
1749
4490
|
*/
|
|
1750
4491
|
const READONLY_TOOL_NAMES = [
|
|
1751
4492
|
"readFile",
|
|
4493
|
+
"listDir",
|
|
1752
4494
|
"grep",
|
|
1753
|
-
"glob"
|
|
4495
|
+
"glob",
|
|
4496
|
+
"todoWrite",
|
|
4497
|
+
"todoRead"
|
|
1754
4498
|
];
|
|
4499
|
+
/** Tools vended only when the policy enables background commands. */
|
|
4500
|
+
const BACKGROUND_TOOL_NAMES = /* @__PURE__ */ new Set(["shellOutput", "shellKill"]);
|
|
1755
4501
|
/**
|
|
1756
4502
|
* Choose the dumb IO executor for a policy. `"mock"` selects the
|
|
1757
4503
|
* in-memory backend (hermetic tests); anything else — including the
|
|
@@ -1776,11 +4522,15 @@ function selectBackend(policy) {
|
|
|
1776
4522
|
* Constructed via {@link workspace}; the class itself is internal.
|
|
1777
4523
|
*/
|
|
1778
4524
|
var WorkspaceImpl = class WorkspaceImpl {
|
|
1779
|
-
constructor(policy, allowedTools = ALL_TOOL_NAMES) {
|
|
4525
|
+
constructor(policy, allowedTools = ALL_TOOL_NAMES, state) {
|
|
1780
4526
|
this.policy = policy;
|
|
1781
4527
|
this.allowedTools = new Set(allowedTools);
|
|
4528
|
+
this.state = state ?? {
|
|
4529
|
+
jobs: new ShellJobs(),
|
|
4530
|
+
todos: new TodoStore()
|
|
4531
|
+
};
|
|
1782
4532
|
const backend = selectBackend(policy);
|
|
1783
|
-
this.ops = createOps(backend, policy);
|
|
4533
|
+
this.ops = createOps(backend, policy, { jobs: this.state.jobs });
|
|
1784
4534
|
this.tools = this.buildTools();
|
|
1785
4535
|
}
|
|
1786
4536
|
/**
|
|
@@ -1792,26 +4542,49 @@ var WorkspaceImpl = class WorkspaceImpl {
|
|
|
1792
4542
|
*/
|
|
1793
4543
|
buildTools() {
|
|
1794
4544
|
const erase = (contract) => contract;
|
|
4545
|
+
const shell = this.policy.shell;
|
|
4546
|
+
const background = shell?.background !== void 0;
|
|
4547
|
+
const interpreter = shell?.interpreter ?? "none";
|
|
1795
4548
|
const factories = {
|
|
1796
4549
|
readFile: (opts) => erase(makeReadFileTool(this.ops, opts)),
|
|
1797
4550
|
editFile: (opts) => erase(makeEditFileTool(this.ops, opts)),
|
|
4551
|
+
multiEdit: (opts) => erase(makeMultiEditTool(this.ops, opts)),
|
|
1798
4552
|
writeFile: (opts) => erase(makeWriteFileTool(this.ops, opts)),
|
|
1799
|
-
|
|
1800
|
-
|
|
4553
|
+
listDir: (opts) => erase(makeListDirTool(this.ops, opts)),
|
|
4554
|
+
runShell: (opts) => erase(makeRunShellTool(this.ops, {
|
|
4555
|
+
...opts?.name !== void 0 ? { name: opts.name } : {},
|
|
4556
|
+
...shell?.timeoutMs !== void 0 ? { maxTimeoutMs: shell.timeoutMs } : {},
|
|
4557
|
+
background
|
|
4558
|
+
})),
|
|
4559
|
+
shellOutput: (opts) => erase(makeShellOutputTool(this.ops, opts)),
|
|
4560
|
+
shellKill: (opts) => erase(makeShellKillTool(this.ops, opts)),
|
|
4561
|
+
runTests: (opts) => erase(makeRunTestsTool(this.ops, {
|
|
4562
|
+
...opts,
|
|
4563
|
+
interpreter
|
|
4564
|
+
})),
|
|
1801
4565
|
grep: (opts) => erase(makeGrepTool(this.ops, opts)),
|
|
1802
|
-
glob: (opts) => erase(makeGlobTool(this.ops, opts))
|
|
4566
|
+
glob: (opts) => erase(makeGlobTool(this.ops, opts)),
|
|
4567
|
+
todoWrite: (opts) => erase(makeTodoWriteTool(this.state.todos, opts)),
|
|
4568
|
+
todoRead: (opts) => erase(makeTodoReadTool(this.state.todos, opts))
|
|
1803
4569
|
};
|
|
1804
4570
|
const build = (name, opts) => factories[name](opts);
|
|
4571
|
+
const vendedByAll = (name) => this.allowedTools.has(name) && (background || !BACKGROUND_TOOL_NAMES.has(name));
|
|
1805
4572
|
return {
|
|
1806
|
-
all: () => ALL_TOOL_NAMES.filter(
|
|
4573
|
+
all: () => ALL_TOOL_NAMES.filter(vendedByAll).map((name) => build(name)),
|
|
1807
4574
|
pick: (...names) => names.filter((name) => this.allowedTools.has(name)).map((name) => build(name)),
|
|
1808
4575
|
readFile: (opts) => build("readFile", opts),
|
|
1809
4576
|
editFile: (opts) => build("editFile", opts),
|
|
4577
|
+
multiEdit: (opts) => build("multiEdit", opts),
|
|
1810
4578
|
writeFile: (opts) => build("writeFile", opts),
|
|
4579
|
+
listDir: (opts) => build("listDir", opts),
|
|
1811
4580
|
runShell: (opts) => build("runShell", opts),
|
|
4581
|
+
shellOutput: (opts) => build("shellOutput", opts),
|
|
4582
|
+
shellKill: (opts) => build("shellKill", opts),
|
|
1812
4583
|
runTests: (opts) => build("runTests", opts),
|
|
1813
4584
|
grep: (opts) => build("grep", opts),
|
|
1814
|
-
glob: (opts) => build("glob", opts)
|
|
4585
|
+
glob: (opts) => build("glob", opts),
|
|
4586
|
+
todoWrite: (opts) => build("todoWrite", opts),
|
|
4587
|
+
todoRead: (opts) => build("todoRead", opts)
|
|
1815
4588
|
};
|
|
1816
4589
|
}
|
|
1817
4590
|
/**
|
|
@@ -1837,10 +4610,40 @@ var WorkspaceImpl = class WorkspaceImpl {
|
|
|
1837
4610
|
this.assertWritable("editFile");
|
|
1838
4611
|
return this.ops.editFile(input);
|
|
1839
4612
|
}
|
|
4613
|
+
async multiEdit(input) {
|
|
4614
|
+
this.assertWritable("multiEdit");
|
|
4615
|
+
return this.ops.multiEdit(input);
|
|
4616
|
+
}
|
|
4617
|
+
listDir(dirPath, opts) {
|
|
4618
|
+
return this.ops.listDir(dirPath, opts);
|
|
4619
|
+
}
|
|
1840
4620
|
async exec(command, opts) {
|
|
1841
4621
|
this.assertWritable("exec");
|
|
1842
4622
|
return this.ops.exec(command, opts);
|
|
1843
4623
|
}
|
|
4624
|
+
async startShell(command, opts) {
|
|
4625
|
+
this.assertWritable("startShell");
|
|
4626
|
+
return this.ops.startShell(command, opts);
|
|
4627
|
+
}
|
|
4628
|
+
shellOutput(id, opts) {
|
|
4629
|
+
return this.ops.shellOutput(id, opts);
|
|
4630
|
+
}
|
|
4631
|
+
async shellKill(id) {
|
|
4632
|
+
this.assertWritable("shellKill");
|
|
4633
|
+
return this.ops.shellKill(id);
|
|
4634
|
+
}
|
|
4635
|
+
listShells() {
|
|
4636
|
+
return this.ops.listShells();
|
|
4637
|
+
}
|
|
4638
|
+
readTodos() {
|
|
4639
|
+
return this.state.todos.read();
|
|
4640
|
+
}
|
|
4641
|
+
writeTodos(todos) {
|
|
4642
|
+
return this.state.todos.write(todos);
|
|
4643
|
+
}
|
|
4644
|
+
dispose() {
|
|
4645
|
+
return this.ops.dispose();
|
|
4646
|
+
}
|
|
1844
4647
|
grep(pattern, opts) {
|
|
1845
4648
|
return this.ops.grep(pattern, opts);
|
|
1846
4649
|
}
|
|
@@ -1859,44 +4662,49 @@ var WorkspaceImpl = class WorkspaceImpl {
|
|
|
1859
4662
|
return this.ops.remove(filePath);
|
|
1860
4663
|
}
|
|
1861
4664
|
/**
|
|
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
|
|
4665
|
+
* A read-only projection over the SAME policy — only the read / list /
|
|
4666
|
+
* grep / glob / todo tools are vended and every mutating direct method
|
|
4667
|
+
* rejects with a {@link WorkspacePolicyError}. A fresh ops/backend is
|
|
4668
|
+
* built from the identical policy, so the projection sees the same jailed
|
|
4669
|
+
* tree; it shares this workspace's background commands and todo list.
|
|
1866
4670
|
*/
|
|
1867
4671
|
readonly() {
|
|
1868
|
-
return new WorkspaceImpl(this.policy, READONLY_TOOL_NAMES);
|
|
4672
|
+
return new WorkspaceImpl(this.policy, READONLY_TOOL_NAMES.filter((name) => this.allowedTools.has(name)), this.state);
|
|
1869
4673
|
}
|
|
1870
4674
|
/**
|
|
1871
4675
|
* A sub-jailed view rooted at `subdir` (relative to this workspace's
|
|
1872
4676
|
* `cwd`). Returns a brand-new workspace whose policy is this policy
|
|
1873
4677
|
* with `cwd` narrowed to `join(cwd, subdir)` — same backend selection,
|
|
1874
|
-
* same allow/deny/shell/read sub-policies, but a tighter jail root.
|
|
4678
|
+
* same allow/deny/shell/read sub-policies, but a tighter jail root. It
|
|
4679
|
+
* shares this workspace's background commands and todo list.
|
|
1875
4680
|
*/
|
|
1876
4681
|
scope(subdir) {
|
|
1877
4682
|
return new WorkspaceImpl({
|
|
1878
4683
|
...this.policy,
|
|
1879
4684
|
cwd: node_path.default.join(this.policy.cwd, subdir)
|
|
1880
|
-
}, [...this.allowedTools]);
|
|
4685
|
+
}, [...this.allowedTools], this.state);
|
|
1881
4686
|
}
|
|
1882
4687
|
};
|
|
1883
4688
|
/**
|
|
1884
4689
|
* Build a {@link Workspace} — the integrator that wires a
|
|
1885
4690
|
* {@link WorkspacePolicy} to a backend, the shared policy-enforced ops
|
|
1886
|
-
* layer, and the
|
|
4691
|
+
* layer, and the agent-facing tool factories.
|
|
1887
4692
|
*
|
|
1888
4693
|
* The backend is chosen from `policy.backend`: `"mock"` runs in memory
|
|
1889
4694
|
* (hermetic tests); the `"local"` default (and any absent value) runs
|
|
1890
4695
|
* over the real disk via `@warlock.js/fs` + `node:child_process`. The
|
|
1891
4696
|
* returned workspace exposes:
|
|
1892
4697
|
*
|
|
1893
|
-
* - **`tools.*`** — `readFile` / `editFile` / `
|
|
1894
|
-
* `
|
|
1895
|
-
* `
|
|
1896
|
-
*
|
|
1897
|
-
*
|
|
1898
|
-
*
|
|
1899
|
-
*
|
|
4698
|
+
* - **`tools.*`** — `readFile` / `editFile` / `multiEdit` / `writeFile` /
|
|
4699
|
+
* `listDir` / `runShell` / `shellOutput` / `shellKill` / `runTests` /
|
|
4700
|
+
* `grep` / `glob` / `todoWrite` / `todoRead`, plus `all()` (every tool)
|
|
4701
|
+
* and `pick(...)` (a least-privilege subset).
|
|
4702
|
+
* - **direct methods** — `readFile` / `writeFile` / `editFile` /
|
|
4703
|
+
* `multiEdit` / `listDir` / `exec` / `startShell` / `shellOutput` /
|
|
4704
|
+
* `shellKill` / `listShells` / `grep` / `glob` / `exists` / `mkdir` /
|
|
4705
|
+
* `remove` / `readTodos` / `writeTodos`, each delegating to the shared
|
|
4706
|
+
* ops layer, and `dispose()`.
|
|
4707
|
+
* - **`readonly()`** — a projection that vends only the read-side tools and
|
|
1900
4708
|
* rejects every mutating direct method.
|
|
1901
4709
|
* - **`scope(subdir)`** — a sub-jailed workspace rooted at `subdir`.
|
|
1902
4710
|
*
|
|
@@ -1921,9 +4729,13 @@ function workspace(policy) {
|
|
|
1921
4729
|
_warlock_js_ai.ai.workspace = workspace;
|
|
1922
4730
|
|
|
1923
4731
|
//#endregion
|
|
4732
|
+
exports.TodoStore = TodoStore;
|
|
1924
4733
|
exports.WorkspaceEditError = WorkspaceEditError;
|
|
4734
|
+
exports.WorkspaceJobError = WorkspaceJobError;
|
|
1925
4735
|
exports.WorkspacePolicyError = WorkspacePolicyError;
|
|
4736
|
+
exports.analyzeArgvCommand = analyzeArgvCommand;
|
|
1926
4737
|
exports.buildEnv = buildEnv;
|
|
4738
|
+
exports.checkShellCommand = checkShellCommand;
|
|
1927
4739
|
exports.createLocalBackend = createLocalBackend;
|
|
1928
4740
|
exports.createMockBackend = createMockBackend;
|
|
1929
4741
|
exports.createOps = createOps;
|
|
@@ -1931,10 +4743,17 @@ exports.isCommandAllowed = isCommandAllowed;
|
|
|
1931
4743
|
exports.makeEditFileTool = makeEditFileTool;
|
|
1932
4744
|
exports.makeGlobTool = makeGlobTool;
|
|
1933
4745
|
exports.makeGrepTool = makeGrepTool;
|
|
4746
|
+
exports.makeListDirTool = makeListDirTool;
|
|
4747
|
+
exports.makeMultiEditTool = makeMultiEditTool;
|
|
1934
4748
|
exports.makeReadFileTool = makeReadFileTool;
|
|
1935
4749
|
exports.makeRunShellTool = makeRunShellTool;
|
|
1936
4750
|
exports.makeRunTestsTool = makeRunTestsTool;
|
|
4751
|
+
exports.makeShellKillTool = makeShellKillTool;
|
|
4752
|
+
exports.makeShellOutputTool = makeShellOutputTool;
|
|
4753
|
+
exports.makeTodoReadTool = makeTodoReadTool;
|
|
4754
|
+
exports.makeTodoWriteTool = makeTodoWriteTool;
|
|
1937
4755
|
exports.makeWriteFileTool = makeWriteFileTool;
|
|
1938
4756
|
exports.resolveInJail = resolveInJail;
|
|
4757
|
+
exports.tokenizeCommand = tokenizeCommand;
|
|
1939
4758
|
exports.workspace = workspace;
|
|
1940
4759
|
//# sourceMappingURL=index.cjs.map
|