@warlock.js/ai-workspace 5.42.0 → 5.44.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 +18 -0
- package/README.md +14 -1
- package/cjs/index.cjs +1781 -141
- package/cjs/index.cjs.map +1 -1
- package/esm/backends/local.d.mts.map +1 -1
- package/esm/backends/local.mjs +36 -22
- package/esm/backends/local.mjs.map +1 -1
- package/esm/contracts/index.d.mts +5 -1
- package/esm/contracts/shell-approval.type.d.mts +78 -0
- package/esm/contracts/shell-approval.type.d.mts.map +1 -0
- package/esm/contracts/tool-io.type.d.mts +35 -1
- package/esm/contracts/tool-io.type.d.mts.map +1 -1
- package/esm/contracts/workspace-backend.contract.d.mts +11 -1
- package/esm/contracts/workspace-backend.contract.d.mts.map +1 -1
- package/esm/contracts/workspace-edit.type.d.mts +66 -0
- package/esm/contracts/workspace-edit.type.d.mts.map +1 -0
- package/esm/contracts/workspace-ops.contract.d.mts +7 -1
- package/esm/contracts/workspace-ops.contract.d.mts.map +1 -1
- package/esm/contracts/workspace-output.type.d.mts +57 -0
- package/esm/contracts/workspace-output.type.d.mts.map +1 -0
- package/esm/contracts/workspace-policy.type.d.mts +38 -1
- package/esm/contracts/workspace-policy.type.d.mts.map +1 -1
- package/esm/contracts/workspace.contract.d.mts +11 -0
- package/esm/contracts/workspace.contract.d.mts.map +1 -1
- package/esm/edit/apply-replacement.mjs +58 -0
- package/esm/edit/apply-replacement.mjs.map +1 -0
- package/esm/edit/match.d.mts +16 -0
- package/esm/edit/match.d.mts.map +1 -0
- package/esm/edit/match.mjs +214 -0
- package/esm/edit/match.mjs.map +1 -0
- package/esm/errors.d.mts +12 -1
- package/esm/errors.d.mts.map +1 -1
- package/esm/errors.mjs +1 -0
- package/esm/errors.mjs.map +1 -1
- package/esm/hooks/after-edit.mjs +36 -0
- package/esm/hooks/after-edit.mjs.map +1 -0
- package/esm/index.d.mts +7 -2
- package/esm/index.mjs +3 -2
- package/esm/ops.d.mts +3 -1
- package/esm/ops.d.mts.map +1 -1
- package/esm/ops.mjs +196 -75
- package/esm/ops.mjs.map +1 -1
- package/esm/output/spill-dir.mjs +15 -0
- package/esm/output/spill-dir.mjs.map +1 -0
- package/esm/output/spill.mjs +220 -0
- package/esm/output/spill.mjs.map +1 -0
- package/esm/policy/policy.d.mts +3 -1
- package/esm/policy/policy.d.mts.map +1 -1
- package/esm/policy/policy.mjs +12 -4
- package/esm/policy/policy.mjs.map +1 -1
- package/esm/policy/powershell-aliases.mjs +275 -0
- package/esm/policy/powershell-aliases.mjs.map +1 -0
- package/esm/policy/powershell-parser.mjs +418 -0
- package/esm/policy/powershell-parser.mjs.map +1 -0
- package/esm/policy/shell-check.d.mts +32 -2
- package/esm/policy/shell-check.d.mts.map +1 -1
- package/esm/policy/shell-check.mjs +183 -31
- package/esm/policy/shell-check.mjs.map +1 -1
- package/esm/policy/shell-interpreter.d.mts +45 -0
- package/esm/policy/shell-interpreter.d.mts.map +1 -0
- package/esm/policy/shell-interpreter.mjs +109 -0
- package/esm/policy/shell-interpreter.mjs.map +1 -0
- package/esm/policy/shell-parser.d.mts +31 -0
- package/esm/policy/shell-parser.d.mts.map +1 -0
- package/esm/policy/shell-parser.mjs +3 -0
- package/esm/policy/shell-parser.mjs.map +1 -1
- package/esm/tools/glob.mjs +1 -0
- package/esm/tools/glob.mjs.map +1 -1
- package/esm/tools/run-shell.d.mts +10 -0
- package/esm/tools/run-shell.d.mts.map +1 -1
- package/esm/tools/run-shell.mjs +18 -1
- package/esm/tools/run-shell.mjs.map +1 -1
- package/esm/tools/run-tests.d.mts +7 -0
- package/esm/tools/run-tests.d.mts.map +1 -1
- package/esm/tools/run-tests.mjs +5 -4
- package/esm/tools/run-tests.mjs.map +1 -1
- package/esm/tools/write-file.mjs +3 -2
- package/esm/tools/write-file.mjs.map +1 -1
- package/esm/workspace.d.mts.map +1 -1
- package/esm/workspace.mjs +16 -5
- package/esm/workspace.mjs.map +1 -1
- package/llms-full.txt +64 -17
- package/package.json +3 -3
- package/skills/use-a-workspace/SKILL.md +64 -17
package/cjs/index.cjs
CHANGED
|
@@ -30,6 +30,7 @@ let _warlock_js_ai = require("@warlock.js/ai");
|
|
|
30
30
|
let node_path = require("node:path");
|
|
31
31
|
node_path = __toESM(node_path, 1);
|
|
32
32
|
let node_fs_promises = require("node:fs/promises");
|
|
33
|
+
let node_fs = require("node:fs");
|
|
33
34
|
let _warlock_js_fs = require("@warlock.js/fs");
|
|
34
35
|
let node_child_process = require("node:child_process");
|
|
35
36
|
let node_process = require("node:process");
|
|
@@ -89,6 +90,7 @@ var WorkspaceEditError = class extends _warlock_js_ai.AIError {
|
|
|
89
90
|
this.expectedHash = options.expectedHash;
|
|
90
91
|
this.actualHash = options.actualHash;
|
|
91
92
|
this.editIndex = options.editIndex;
|
|
93
|
+
this.hint = options.hint;
|
|
92
94
|
this.hunkIndex = options.hunkIndex;
|
|
93
95
|
}
|
|
94
96
|
};
|
|
@@ -180,6 +182,810 @@ function isInsideJailLexically(policy, target) {
|
|
|
180
182
|
return (policy.allowPaths ?? []).some((allowRoot) => isInside(target, node_path.default.resolve(allowRoot)));
|
|
181
183
|
}
|
|
182
184
|
|
|
185
|
+
//#endregion
|
|
186
|
+
//#region ../../packages/ai-workspace/src/output/spill-dir.ts
|
|
187
|
+
/**
|
|
188
|
+
* The absolute directory spill files go to, or `undefined` when the policy
|
|
189
|
+
* has no `output.spillDir` (spilling is off).
|
|
190
|
+
*/
|
|
191
|
+
function resolveSpillDir(policy) {
|
|
192
|
+
const dir = policy.output?.spillDir;
|
|
193
|
+
return dir === void 0 ? void 0 : node_path.default.resolve(policy.cwd, dir);
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
//#endregion
|
|
197
|
+
//#region ../../packages/ai-workspace/src/policy/powershell-aliases.ts
|
|
198
|
+
const FAMILIES = [
|
|
199
|
+
{
|
|
200
|
+
canonical: "rm",
|
|
201
|
+
names: [
|
|
202
|
+
"remove-item",
|
|
203
|
+
"ri",
|
|
204
|
+
"rm",
|
|
205
|
+
"del",
|
|
206
|
+
"erase",
|
|
207
|
+
"rd",
|
|
208
|
+
"rmdir"
|
|
209
|
+
],
|
|
210
|
+
recurseFlag: "r",
|
|
211
|
+
forceFlag: "f",
|
|
212
|
+
params: [
|
|
213
|
+
["recurse", "recurse"],
|
|
214
|
+
["force", "force"],
|
|
215
|
+
["path", "operand"],
|
|
216
|
+
["literalpath", "operand"],
|
|
217
|
+
["whatif", "switch"],
|
|
218
|
+
["confirm", "switch"],
|
|
219
|
+
["filter", "value"],
|
|
220
|
+
["include", "value"],
|
|
221
|
+
["exclude", "value"],
|
|
222
|
+
["credential", "value"],
|
|
223
|
+
["stream", "value"]
|
|
224
|
+
]
|
|
225
|
+
},
|
|
226
|
+
{
|
|
227
|
+
canonical: "cp",
|
|
228
|
+
names: [
|
|
229
|
+
"copy-item",
|
|
230
|
+
"cpi",
|
|
231
|
+
"cp",
|
|
232
|
+
"copy"
|
|
233
|
+
],
|
|
234
|
+
recurseFlag: "r",
|
|
235
|
+
forceFlag: "f",
|
|
236
|
+
params: [
|
|
237
|
+
["recurse", "recurse"],
|
|
238
|
+
["force", "force"],
|
|
239
|
+
["path", "operand"],
|
|
240
|
+
["literalpath", "operand"],
|
|
241
|
+
["destination", "operand"],
|
|
242
|
+
["whatif", "switch"],
|
|
243
|
+
["confirm", "switch"],
|
|
244
|
+
["filter", "value"],
|
|
245
|
+
["include", "value"],
|
|
246
|
+
["exclude", "value"],
|
|
247
|
+
["credential", "value"]
|
|
248
|
+
]
|
|
249
|
+
},
|
|
250
|
+
{
|
|
251
|
+
canonical: "mv",
|
|
252
|
+
names: [
|
|
253
|
+
"move-item",
|
|
254
|
+
"mi",
|
|
255
|
+
"mv",
|
|
256
|
+
"move"
|
|
257
|
+
],
|
|
258
|
+
recurseFlag: "",
|
|
259
|
+
forceFlag: "f",
|
|
260
|
+
params: [
|
|
261
|
+
["force", "force"],
|
|
262
|
+
["path", "operand"],
|
|
263
|
+
["literalpath", "operand"],
|
|
264
|
+
["destination", "operand"],
|
|
265
|
+
["whatif", "switch"],
|
|
266
|
+
["confirm", "switch"],
|
|
267
|
+
["filter", "value"],
|
|
268
|
+
["include", "value"],
|
|
269
|
+
["exclude", "value"],
|
|
270
|
+
["credential", "value"]
|
|
271
|
+
]
|
|
272
|
+
},
|
|
273
|
+
{
|
|
274
|
+
canonical: "ls",
|
|
275
|
+
names: [
|
|
276
|
+
"get-childitem",
|
|
277
|
+
"gci",
|
|
278
|
+
"ls",
|
|
279
|
+
"dir"
|
|
280
|
+
],
|
|
281
|
+
recurseFlag: "R",
|
|
282
|
+
forceFlag: "a",
|
|
283
|
+
params: [
|
|
284
|
+
["recurse", "recurse"],
|
|
285
|
+
["force", "force"],
|
|
286
|
+
["path", "operand"],
|
|
287
|
+
["literalpath", "operand"],
|
|
288
|
+
["filter", "value"],
|
|
289
|
+
["include", "value"],
|
|
290
|
+
["exclude", "value"],
|
|
291
|
+
["depth", "value"],
|
|
292
|
+
["name", "switch"]
|
|
293
|
+
]
|
|
294
|
+
},
|
|
295
|
+
{
|
|
296
|
+
canonical: "cat",
|
|
297
|
+
names: [
|
|
298
|
+
"get-content",
|
|
299
|
+
"gc",
|
|
300
|
+
"cat",
|
|
301
|
+
"type"
|
|
302
|
+
],
|
|
303
|
+
recurseFlag: "",
|
|
304
|
+
forceFlag: "",
|
|
305
|
+
params: [
|
|
306
|
+
["path", "operand"],
|
|
307
|
+
["literalpath", "operand"],
|
|
308
|
+
["totalcount", "value"],
|
|
309
|
+
["tail", "value"],
|
|
310
|
+
["head", "value"],
|
|
311
|
+
["encoding", "value"],
|
|
312
|
+
["filter", "value"],
|
|
313
|
+
["include", "value"],
|
|
314
|
+
["exclude", "value"],
|
|
315
|
+
["force", "switch"],
|
|
316
|
+
["raw", "switch"],
|
|
317
|
+
["wait", "switch"]
|
|
318
|
+
]
|
|
319
|
+
},
|
|
320
|
+
{
|
|
321
|
+
canonical: "curl",
|
|
322
|
+
names: [
|
|
323
|
+
"invoke-webrequest",
|
|
324
|
+
"iwr",
|
|
325
|
+
"invoke-restmethod",
|
|
326
|
+
"irm",
|
|
327
|
+
"curl",
|
|
328
|
+
"wget"
|
|
329
|
+
],
|
|
330
|
+
recurseFlag: "",
|
|
331
|
+
forceFlag: "",
|
|
332
|
+
params: [
|
|
333
|
+
["uri", "operand"],
|
|
334
|
+
["outfile", "outfile"],
|
|
335
|
+
["method", "method"],
|
|
336
|
+
["headers", "value"],
|
|
337
|
+
["body", "value"],
|
|
338
|
+
["contenttype", "value"],
|
|
339
|
+
["useragent", "value"],
|
|
340
|
+
["credential", "value"],
|
|
341
|
+
["timeoutsec", "value"],
|
|
342
|
+
["proxy", "value"]
|
|
343
|
+
]
|
|
344
|
+
},
|
|
345
|
+
{
|
|
346
|
+
canonical: "kill",
|
|
347
|
+
names: [
|
|
348
|
+
"stop-process",
|
|
349
|
+
"spps",
|
|
350
|
+
"kill"
|
|
351
|
+
],
|
|
352
|
+
recurseFlag: "",
|
|
353
|
+
forceFlag: "9",
|
|
354
|
+
params: [
|
|
355
|
+
["id", "operand"],
|
|
356
|
+
["name", "operand"],
|
|
357
|
+
["force", "force"],
|
|
358
|
+
["inputobject", "value"],
|
|
359
|
+
["whatif", "switch"],
|
|
360
|
+
["confirm", "switch"]
|
|
361
|
+
]
|
|
362
|
+
}
|
|
363
|
+
];
|
|
364
|
+
const BY_NAME = /* @__PURE__ */ new Map();
|
|
365
|
+
for (const family of FAMILIES) for (const name of family.names) BY_NAME.set(name, family);
|
|
366
|
+
/** PowerShell reads an en dash, em dash or horizontal bar as the parameter dash too. */
|
|
367
|
+
const FLAG_ORDER = "rRfa9";
|
|
368
|
+
const DASH = /^[-–—―]/;
|
|
369
|
+
/** Lowercase executable name used to look an alias up. */
|
|
370
|
+
function aliasKey(first) {
|
|
371
|
+
return (first.split(/[\\/]/).pop() ?? first).replace(/\.(exe|cmd|bat|com)$/i, "").toLowerCase();
|
|
372
|
+
}
|
|
373
|
+
/** Split `-Name` / `-Name:value` into the lowercase name and the inline value. */
|
|
374
|
+
function parameter(arg) {
|
|
375
|
+
if (!DASH.test(arg) || arg.length < 2) return null;
|
|
376
|
+
const body = arg.slice(1);
|
|
377
|
+
const colon = body.indexOf(":");
|
|
378
|
+
return colon === -1 ? { name: body.toLowerCase() } : {
|
|
379
|
+
name: body.slice(0, colon).toLowerCase(),
|
|
380
|
+
inline: body.slice(colon + 1)
|
|
381
|
+
};
|
|
382
|
+
}
|
|
383
|
+
/**
|
|
384
|
+
* The canonical argv of a PowerShell command that uses an alias or cmdlet
|
|
385
|
+
* name from the table (`Remove-Item -Recurse -Force x` → `["rm", "-rf", "x"]`),
|
|
386
|
+
* or `undefined` when the command has no canonical form.
|
|
387
|
+
*/
|
|
388
|
+
function canonicalPowerShellArgv(argv) {
|
|
389
|
+
const family = BY_NAME.get(aliasKey(argv[0] ?? ""));
|
|
390
|
+
if (family === void 0) return;
|
|
391
|
+
let flags = "";
|
|
392
|
+
const operands = [];
|
|
393
|
+
const extras = [];
|
|
394
|
+
for (let index = 1; index < argv.length; index++) {
|
|
395
|
+
const arg = argv[index] ?? "";
|
|
396
|
+
const parsed = parameter(arg);
|
|
397
|
+
if (parsed === null) {
|
|
398
|
+
operands.push(arg);
|
|
399
|
+
continue;
|
|
400
|
+
}
|
|
401
|
+
const match = family.params.find(([name]) => parsed.name !== "" && name.startsWith(parsed.name));
|
|
402
|
+
if (match === void 0) continue;
|
|
403
|
+
const role = match[1];
|
|
404
|
+
if (role === "recurse") flags += family.recurseFlag;
|
|
405
|
+
else if (role === "force") flags += family.forceFlag;
|
|
406
|
+
else if (role === "switch") continue;
|
|
407
|
+
else {
|
|
408
|
+
const value = parsed.inline ?? argv[++index] ?? "";
|
|
409
|
+
if (role === "operand") operands.push(value);
|
|
410
|
+
else if (role === "outfile") extras.push("-o", value);
|
|
411
|
+
else if (role === "method") extras.push("-X", value);
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
const unique = [...FLAG_ORDER].filter((flag) => flags.includes(flag)).join("");
|
|
415
|
+
return [
|
|
416
|
+
family.canonical,
|
|
417
|
+
...unique !== "" ? [`-${unique}`] : [],
|
|
418
|
+
...extras,
|
|
419
|
+
...operands
|
|
420
|
+
];
|
|
421
|
+
}
|
|
422
|
+
/** The names PowerShell's `cd` goes by. */
|
|
423
|
+
const POWERSHELL_CD_NAMES = /* @__PURE__ */ new Set([
|
|
424
|
+
"cd",
|
|
425
|
+
"sl",
|
|
426
|
+
"set-location",
|
|
427
|
+
"chdir"
|
|
428
|
+
]);
|
|
429
|
+
/** Directory stack commands, refused because the policy does not track them. */
|
|
430
|
+
const POWERSHELL_DIRECTORY_STACK = /* @__PURE__ */ new Set([
|
|
431
|
+
"push-location",
|
|
432
|
+
"pushd",
|
|
433
|
+
"pop-location",
|
|
434
|
+
"popd"
|
|
435
|
+
]);
|
|
436
|
+
/**
|
|
437
|
+
* Normalise a PowerShell `cd` to `[cd, directory]`: `-Path x`, `-LiteralPath x`
|
|
438
|
+
* (and their prefixes, `-Path:x`) become the directory operand. Returns `null`
|
|
439
|
+
* for any other parameter (`-PassThru`, `-StackName`, …).
|
|
440
|
+
*/
|
|
441
|
+
function normalizePowerShellCd(words) {
|
|
442
|
+
const first = words[0];
|
|
443
|
+
if (first === void 0) return null;
|
|
444
|
+
const operands = [];
|
|
445
|
+
for (let index = 1; index < words.length; index++) {
|
|
446
|
+
const word = words[index];
|
|
447
|
+
const parsed = word.quoted ? null : parameter(word.value);
|
|
448
|
+
if (parsed === null) {
|
|
449
|
+
operands.push(word);
|
|
450
|
+
continue;
|
|
451
|
+
}
|
|
452
|
+
const isPath = parsed.name.length >= 2 && "path".startsWith(parsed.name);
|
|
453
|
+
const isLiteral = parsed.name.length >= 1 && "literalpath".startsWith(parsed.name);
|
|
454
|
+
if (!isPath && !isLiteral) return null;
|
|
455
|
+
if (parsed.inline !== void 0) operands.push({
|
|
456
|
+
...word,
|
|
457
|
+
raw: parsed.inline,
|
|
458
|
+
value: parsed.inline
|
|
459
|
+
});
|
|
460
|
+
else {
|
|
461
|
+
const next = words[++index];
|
|
462
|
+
if (next === void 0) return null;
|
|
463
|
+
operands.push(next);
|
|
464
|
+
}
|
|
465
|
+
}
|
|
466
|
+
return [first, ...operands];
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
//#endregion
|
|
470
|
+
//#region ../../packages/ai-workspace/src/policy/shell-interpreter.ts
|
|
471
|
+
/** The real host: the running platform, `process.env.PATH`, and the filesystem. */
|
|
472
|
+
function currentShellHost() {
|
|
473
|
+
return {
|
|
474
|
+
platform: process.platform,
|
|
475
|
+
pathEnv: process.env.PATH ?? process.env.Path,
|
|
476
|
+
isExecutable: (file) => {
|
|
477
|
+
try {
|
|
478
|
+
(0, node_fs.accessSync)(file, process.platform === "win32" ? node_fs.constants.F_OK : node_fs.constants.X_OK);
|
|
479
|
+
return true;
|
|
480
|
+
} catch {
|
|
481
|
+
return false;
|
|
482
|
+
}
|
|
483
|
+
}
|
|
484
|
+
};
|
|
485
|
+
}
|
|
486
|
+
/** The grammar a concrete interpreter reads on `platform`. */
|
|
487
|
+
function dialectOf(interpreter, platform = process.platform) {
|
|
488
|
+
if (interpreter === "powershell") return "powershell";
|
|
489
|
+
if (interpreter === "cmd") return "cmd";
|
|
490
|
+
return interpreter === "sh" && platform === "win32" ? "cmd" : "posix";
|
|
491
|
+
}
|
|
492
|
+
/** The first of `names` found in the host's `PATH` (Windows: with `.exe`). */
|
|
493
|
+
function findOnPath(names, host) {
|
|
494
|
+
const windows = host.platform === "win32";
|
|
495
|
+
const separator = windows ? ";" : ":";
|
|
496
|
+
const join = windows ? node_path.default.win32.join : node_path.default.posix.join;
|
|
497
|
+
const directories = (host.pathEnv ?? "").split(separator).filter((entry) => entry !== "");
|
|
498
|
+
for (const name of names) for (const directory of directories) {
|
|
499
|
+
const candidate = join(directory, windows ? `${name}.exe` : name);
|
|
500
|
+
if (host.isExecutable(candidate)) return candidate;
|
|
501
|
+
}
|
|
502
|
+
}
|
|
503
|
+
/** Git for Windows' `bash.exe`, derived from the `git` found on `PATH`. */
|
|
504
|
+
function findGitBash(host) {
|
|
505
|
+
const git = findOnPath(["git"], host);
|
|
506
|
+
if (git === void 0) return;
|
|
507
|
+
const win = node_path.default.win32;
|
|
508
|
+
const roots = [win.dirname(win.dirname(git)), win.dirname(win.dirname(win.dirname(git)))];
|
|
509
|
+
for (const root of roots) for (const candidate of [win.join(root, "bin", "bash.exe"), win.join(root, "usr", "bin", "bash.exe")]) if (host.isExecutable(candidate)) return candidate;
|
|
510
|
+
}
|
|
511
|
+
/** The PowerShell executable names, newest first. */
|
|
512
|
+
function powershellNames(host) {
|
|
513
|
+
return host.platform === "win32" ? ["pwsh", "powershell"] : ["pwsh"];
|
|
514
|
+
}
|
|
515
|
+
function powershellLabel(file) {
|
|
516
|
+
return file !== void 0 && /pwsh(\.exe)?$/i.test(file) ? "PowerShell 7 (pwsh)" : "PowerShell";
|
|
517
|
+
}
|
|
518
|
+
/**
|
|
519
|
+
* Resolve a shell policy's `interpreter` (and `interpreterPath`) to the
|
|
520
|
+
* concrete shell that will run lines, and the grammar they are checked with.
|
|
521
|
+
*
|
|
522
|
+
* `"auto"` picks `pwsh`, then Windows PowerShell, then Git Bash (found next to
|
|
523
|
+
* `git`), then `cmd.exe` on Windows and `bash`, then `sh` elsewhere. An explicit interpreter whose executable
|
|
524
|
+
* is not on `PATH` keeps its name and has no `file`, so the spawn reports the
|
|
525
|
+
* missing shell instead of quietly running another one.
|
|
526
|
+
*
|
|
527
|
+
* @example
|
|
528
|
+
* resolveShell({ interpreter: "auto" }, { platform: "win32", pathEnv: "C:\\bin", isExecutable })
|
|
529
|
+
*/
|
|
530
|
+
function resolveShell(shell, host = currentShellHost()) {
|
|
531
|
+
const requested = shell?.interpreter ?? "none";
|
|
532
|
+
const explicit = shell?.interpreterPath;
|
|
533
|
+
if (requested === "none") return {
|
|
534
|
+
interpreter: "none",
|
|
535
|
+
dialect: "posix",
|
|
536
|
+
label: "none"
|
|
537
|
+
};
|
|
538
|
+
let interpreter;
|
|
539
|
+
let file = explicit;
|
|
540
|
+
if (requested === "auto") {
|
|
541
|
+
if (explicit !== void 0) interpreter = /(^|[\\/])(pwsh|powershell)(\.exe)?$/i.test(explicit) ? "powershell" : /(^|[\\/])cmd(\.exe)?$/i.test(explicit) ? "cmd" : /(^|[\\/])bash(\.exe)?$/i.test(explicit) ? "bash" : "sh";
|
|
542
|
+
else if (host.platform === "win32") {
|
|
543
|
+
const powershell = findOnPath(powershellNames(host), host);
|
|
544
|
+
const gitBash = powershell === void 0 ? findGitBash(host) : void 0;
|
|
545
|
+
interpreter = powershell !== void 0 ? "powershell" : gitBash !== void 0 ? "bash" : "cmd";
|
|
546
|
+
file = powershell ?? gitBash;
|
|
547
|
+
} else {
|
|
548
|
+
const bash = findOnPath(["bash"], host);
|
|
549
|
+
interpreter = bash !== void 0 ? "bash" : "sh";
|
|
550
|
+
file = bash;
|
|
551
|
+
}
|
|
552
|
+
} else interpreter = requested;
|
|
553
|
+
if (file === void 0) {
|
|
554
|
+
if (interpreter === "powershell") file = findOnPath(powershellNames(host), host);
|
|
555
|
+
else if (interpreter === "bash") file = findOnPath(["bash"], host) ?? (host.platform === "win32" ? findGitBash(host) : void 0);
|
|
556
|
+
else if (interpreter === "cmd" && host.platform === "win32") file = process.env.ComSpec ?? "cmd.exe";
|
|
557
|
+
}
|
|
558
|
+
const labels = {
|
|
559
|
+
none: "none",
|
|
560
|
+
sh: host.platform === "win32" ? "cmd.exe" : "sh",
|
|
561
|
+
bash: "bash",
|
|
562
|
+
cmd: "cmd.exe",
|
|
563
|
+
powershell: powershellLabel(file)
|
|
564
|
+
};
|
|
565
|
+
return {
|
|
566
|
+
interpreter,
|
|
567
|
+
dialect: dialectOf(interpreter, host.platform),
|
|
568
|
+
...file !== void 0 ? { file } : {},
|
|
569
|
+
label: labels[interpreter]
|
|
570
|
+
};
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
//#endregion
|
|
574
|
+
//#region ../../packages/ai-workspace/src/policy/powershell-parser.ts
|
|
575
|
+
/**
|
|
576
|
+
* Parser for the PowerShell dialect (`powershell -Command` / `pwsh -Command`).
|
|
577
|
+
*
|
|
578
|
+
* Like the other dialects it is deliberately conservative: it finds every
|
|
579
|
+
* simple command a line would run and every redirection target, and refuses
|
|
580
|
+
* what it cannot read with confidence. Supported: `;` and line breaks, `&&`,
|
|
581
|
+
* `||`, `|`, `$( )` subexpressions (their commands are checked too), the call
|
|
582
|
+
* operator `&` in front of a command, single and double
|
|
583
|
+
* quotes (including the typographic quotes PowerShell treats as quotes),
|
|
584
|
+
* backtick escapes, `$name` / `${name}` / `$env:NAME` variables (dynamic
|
|
585
|
+
* words), and the redirections `>`, `>>`, `2>`, `2>>`, `*>`, `2>&1`.
|
|
586
|
+
*
|
|
587
|
+
* Refused: script blocks and braces, bare parentheses,
|
|
588
|
+
* `@( )` / `@{ }` / splatting / here-strings, the stop-parsing token `--%`,
|
|
589
|
+
* `<` redirection, comments, the comma operator, compound-statement
|
|
590
|
+
* keywords (`if`, `foreach`, `function`, `try`, …), dot-sourcing, a `&` that
|
|
591
|
+
* is not a call operator (background jobs), and type literals.
|
|
592
|
+
*/
|
|
593
|
+
/** Statement keywords that start a compound command; refused in command position. */
|
|
594
|
+
const POWERSHELL_KEYWORDS = /* @__PURE__ */ new Set([
|
|
595
|
+
"if",
|
|
596
|
+
"elseif",
|
|
597
|
+
"else",
|
|
598
|
+
"switch",
|
|
599
|
+
"foreach",
|
|
600
|
+
"for",
|
|
601
|
+
"while",
|
|
602
|
+
"do",
|
|
603
|
+
"until",
|
|
604
|
+
"function",
|
|
605
|
+
"filter",
|
|
606
|
+
"workflow",
|
|
607
|
+
"param",
|
|
608
|
+
"begin",
|
|
609
|
+
"process",
|
|
610
|
+
"end",
|
|
611
|
+
"try",
|
|
612
|
+
"catch",
|
|
613
|
+
"finally",
|
|
614
|
+
"trap",
|
|
615
|
+
"data",
|
|
616
|
+
"class",
|
|
617
|
+
"enum",
|
|
618
|
+
"using",
|
|
619
|
+
"return",
|
|
620
|
+
"break",
|
|
621
|
+
"continue",
|
|
622
|
+
"throw",
|
|
623
|
+
"parallel",
|
|
624
|
+
"sequence",
|
|
625
|
+
"inlinescript"
|
|
626
|
+
]);
|
|
627
|
+
/** Characters PowerShell reads as a single quote. */
|
|
628
|
+
const SINGLE_QUOTES = "'‘’‚‛";
|
|
629
|
+
/** Characters PowerShell reads as a double quote. */
|
|
630
|
+
const DOUBLE_QUOTES = "\"“”„";
|
|
631
|
+
/** Variable-name characters after `$`. */
|
|
632
|
+
const VARIABLE_CHAR = /[A-Za-z0-9_:?^$]/;
|
|
633
|
+
/** Whether `char` (never the empty end-of-input string) is one of `set`. */
|
|
634
|
+
function inSet(set, char) {
|
|
635
|
+
return char !== "" && set.includes(char);
|
|
636
|
+
}
|
|
637
|
+
var PowerShellRefusal = class extends Error {
|
|
638
|
+
constructor(reason, message) {
|
|
639
|
+
super(message);
|
|
640
|
+
this.reason = reason;
|
|
641
|
+
}
|
|
642
|
+
};
|
|
643
|
+
function unsupported$1(message) {
|
|
644
|
+
return new PowerShellRefusal("unsupported-syntax", message);
|
|
645
|
+
}
|
|
646
|
+
var PowerShellParser = class PowerShellParser {
|
|
647
|
+
constructor(src, commands = [], depth = 0) {
|
|
648
|
+
this.src = src;
|
|
649
|
+
this.commands = commands;
|
|
650
|
+
this.depth = depth;
|
|
651
|
+
this.pos = 0;
|
|
652
|
+
}
|
|
653
|
+
atEnd() {
|
|
654
|
+
return this.pos >= this.src.length;
|
|
655
|
+
}
|
|
656
|
+
peek(offset = 0) {
|
|
657
|
+
return this.src.charAt(this.pos + offset);
|
|
658
|
+
}
|
|
659
|
+
skipBlanks() {
|
|
660
|
+
while (this.peek() === " " || this.peek() === " ") this.pos++;
|
|
661
|
+
}
|
|
662
|
+
atLineBreak() {
|
|
663
|
+
return this.peek() === "\n" || this.peek() === "\r";
|
|
664
|
+
}
|
|
665
|
+
parseProgram(segments) {
|
|
666
|
+
while (true) {
|
|
667
|
+
this.skipBlanks();
|
|
668
|
+
if (this.atEnd()) {
|
|
669
|
+
if (segments.length === 0) throw unsupported$1("The command is empty.");
|
|
670
|
+
if (segments[segments.length - 1]?.operator === "&&" || segments[segments.length - 1]?.operator === "||") throw unsupported$1("The line ends with an operator but no command follows it.");
|
|
671
|
+
return;
|
|
672
|
+
}
|
|
673
|
+
const start = this.pos;
|
|
674
|
+
this.parsePipeline();
|
|
675
|
+
const end = this.pos;
|
|
676
|
+
this.skipBlanks();
|
|
677
|
+
const operator = this.readListOperator();
|
|
678
|
+
segments.push({
|
|
679
|
+
source: this.src.slice(start, end).trim(),
|
|
680
|
+
...operator !== null ? { operator } : {}
|
|
681
|
+
});
|
|
682
|
+
if (operator === null) {
|
|
683
|
+
if (!this.atEnd()) throw unsupported$1(`Unexpected \`${this.peek()}\`.`);
|
|
684
|
+
return;
|
|
685
|
+
}
|
|
686
|
+
if (operator === ";" || operator === "\n") while (this.peek() === " " || this.peek() === " " || this.atLineBreak()) this.pos++;
|
|
687
|
+
else this.skipBlanks();
|
|
688
|
+
}
|
|
689
|
+
}
|
|
690
|
+
readListOperator() {
|
|
691
|
+
const char = this.peek();
|
|
692
|
+
if (char === "&" && this.peek(1) === "&") {
|
|
693
|
+
this.pos += 2;
|
|
694
|
+
return "&&";
|
|
695
|
+
}
|
|
696
|
+
if (char === "|" && this.peek(1) === "|") {
|
|
697
|
+
this.pos += 2;
|
|
698
|
+
return "||";
|
|
699
|
+
}
|
|
700
|
+
if (char === ";") {
|
|
701
|
+
this.pos++;
|
|
702
|
+
return ";";
|
|
703
|
+
}
|
|
704
|
+
if (this.atLineBreak()) {
|
|
705
|
+
while (this.atLineBreak()) this.pos++;
|
|
706
|
+
return "\n";
|
|
707
|
+
}
|
|
708
|
+
return null;
|
|
709
|
+
}
|
|
710
|
+
parsePipeline() {
|
|
711
|
+
this.parseCommand();
|
|
712
|
+
while (this.peek() === "|" && this.peek(1) !== "|") {
|
|
713
|
+
this.pos++;
|
|
714
|
+
this.skipBlanks();
|
|
715
|
+
if (this.atEnd() || this.atLineBreak() || this.peek() === ";") throw unsupported$1("The line ends with `|` but no command follows it.");
|
|
716
|
+
this.parseCommand();
|
|
717
|
+
}
|
|
718
|
+
}
|
|
719
|
+
atRedirectStart() {
|
|
720
|
+
const char = this.peek();
|
|
721
|
+
if (char === ">") return true;
|
|
722
|
+
return /[1-6*]/.test(char) && this.peek(1) === ">";
|
|
723
|
+
}
|
|
724
|
+
parseCommand() {
|
|
725
|
+
const start = this.pos;
|
|
726
|
+
const command = {
|
|
727
|
+
assignments: [],
|
|
728
|
+
words: [],
|
|
729
|
+
redirects: [],
|
|
730
|
+
source: ""
|
|
731
|
+
};
|
|
732
|
+
let callOperator = false;
|
|
733
|
+
if (this.peek() === "&" && this.peek(1) !== "&") {
|
|
734
|
+
callOperator = true;
|
|
735
|
+
this.pos++;
|
|
736
|
+
this.skipBlanks();
|
|
737
|
+
}
|
|
738
|
+
while (true) {
|
|
739
|
+
this.skipBlanks();
|
|
740
|
+
if (this.atEnd() || this.atLineBreak() || this.peek() === ";" || this.peek() === "|") break;
|
|
741
|
+
if (this.peek() === "&") {
|
|
742
|
+
if (this.peek(1) === "&") break;
|
|
743
|
+
throw unsupported$1("`&` is only supported as the call operator in front of a command (not as a background job).");
|
|
744
|
+
}
|
|
745
|
+
if (this.peek() === "<") throw unsupported$1("`<` is not supported in PowerShell command lines.");
|
|
746
|
+
if (this.atRedirectStart()) {
|
|
747
|
+
command.redirects.push(this.parseRedirect());
|
|
748
|
+
continue;
|
|
749
|
+
}
|
|
750
|
+
const word = this.parseWord();
|
|
751
|
+
command.words.push(word);
|
|
752
|
+
if (command.words.length === 1) this.checkCommandWord(word);
|
|
753
|
+
}
|
|
754
|
+
if (command.words.length === 0 && (callOperator || command.redirects.length === 0)) throw unsupported$1("The line has an empty command (two operators in a row?).");
|
|
755
|
+
command.source = this.src.slice(start, this.pos).trim();
|
|
756
|
+
this.commands.push(command);
|
|
757
|
+
}
|
|
758
|
+
checkCommandWord(word) {
|
|
759
|
+
if (word.raw.startsWith("[")) throw unsupported$1(`\`${word.raw}\` (a type literal) is not supported.`);
|
|
760
|
+
if (!word.quoted && word.value === ".") throw unsupported$1("Dot-sourcing (`. file`) runs a script in the current scope and is not supported.");
|
|
761
|
+
if (!word.quoted && POWERSHELL_KEYWORDS.has(word.value.toLowerCase())) throw unsupported$1(`\`${word.value}\` starts a PowerShell statement, which is not supported.`);
|
|
762
|
+
}
|
|
763
|
+
parseRedirect() {
|
|
764
|
+
let fd;
|
|
765
|
+
if (this.peek() === "*") this.pos++;
|
|
766
|
+
else if (/[1-6]/.test(this.peek())) {
|
|
767
|
+
fd = Number(this.peek());
|
|
768
|
+
this.pos++;
|
|
769
|
+
}
|
|
770
|
+
let op = ">";
|
|
771
|
+
if (this.peek(1) === ">") op = ">>";
|
|
772
|
+
this.pos += op.length;
|
|
773
|
+
if (op === ">" && this.peek() === "&") {
|
|
774
|
+
if (/[1-6]/.test(this.peek(1))) {
|
|
775
|
+
const target = {
|
|
776
|
+
raw: this.peek(1),
|
|
777
|
+
value: this.peek(1),
|
|
778
|
+
dynamic: false,
|
|
779
|
+
quoted: false
|
|
780
|
+
};
|
|
781
|
+
this.pos += 2;
|
|
782
|
+
return {
|
|
783
|
+
op: ">&",
|
|
784
|
+
...fd !== void 0 ? { fd } : {},
|
|
785
|
+
target,
|
|
786
|
+
type: "dup"
|
|
787
|
+
};
|
|
788
|
+
}
|
|
789
|
+
throw unsupported$1("Only `>&1`-style stream merges (`2>&1`) are supported.");
|
|
790
|
+
}
|
|
791
|
+
this.skipBlanks();
|
|
792
|
+
if (this.atEnd() || this.atLineBreak() || /[;&|<>]/.test(this.peek())) throw unsupported$1(`The redirection \`${op}\` has no target.`);
|
|
793
|
+
const target = this.parseWord();
|
|
794
|
+
return {
|
|
795
|
+
op,
|
|
796
|
+
...fd !== void 0 ? { fd } : {},
|
|
797
|
+
target,
|
|
798
|
+
type: "write"
|
|
799
|
+
};
|
|
800
|
+
}
|
|
801
|
+
parseWord() {
|
|
802
|
+
const start = this.pos;
|
|
803
|
+
let value = "";
|
|
804
|
+
let dynamic = false;
|
|
805
|
+
let quoted = false;
|
|
806
|
+
if (this.src.startsWith("--%", this.pos) && /[\s;|&]|^$/.test(this.src.charAt(this.pos + 3))) throw unsupported$1("The stop-parsing token `--%` is not supported.");
|
|
807
|
+
while (!this.atEnd()) {
|
|
808
|
+
const char = this.peek();
|
|
809
|
+
if (char === " " || char === " " || char === "\n" || char === "\r" || char === ";" || char === "|" || char === "&" || char === ">" || char === "<") break;
|
|
810
|
+
if (inSet(SINGLE_QUOTES, char)) {
|
|
811
|
+
this.pos++;
|
|
812
|
+
quoted = true;
|
|
813
|
+
while (true) {
|
|
814
|
+
if (this.atEnd()) throw new PowerShellRefusal("unbalanced-quotes", "A single quote is never closed.");
|
|
815
|
+
const inner = this.peek();
|
|
816
|
+
if (inSet(SINGLE_QUOTES, inner)) {
|
|
817
|
+
if (inSet(SINGLE_QUOTES, this.peek(1))) {
|
|
818
|
+
value += this.peek(1);
|
|
819
|
+
this.pos += 2;
|
|
820
|
+
continue;
|
|
821
|
+
}
|
|
822
|
+
this.pos++;
|
|
823
|
+
break;
|
|
824
|
+
}
|
|
825
|
+
value += inner;
|
|
826
|
+
this.pos++;
|
|
827
|
+
}
|
|
828
|
+
continue;
|
|
829
|
+
}
|
|
830
|
+
if (inSet(DOUBLE_QUOTES, char)) {
|
|
831
|
+
this.pos++;
|
|
832
|
+
quoted = true;
|
|
833
|
+
while (true) {
|
|
834
|
+
if (this.atEnd()) throw new PowerShellRefusal("unbalanced-quotes", "A double quote is never closed.");
|
|
835
|
+
const inner = this.peek();
|
|
836
|
+
if (inSet(DOUBLE_QUOTES, inner)) {
|
|
837
|
+
if (inSet(DOUBLE_QUOTES, this.peek(1))) {
|
|
838
|
+
value += this.peek(1);
|
|
839
|
+
this.pos += 2;
|
|
840
|
+
continue;
|
|
841
|
+
}
|
|
842
|
+
this.pos++;
|
|
843
|
+
break;
|
|
844
|
+
}
|
|
845
|
+
if (inner === "`") {
|
|
846
|
+
const next = this.peek(1);
|
|
847
|
+
if (next !== "" && (inSet(DOUBLE_QUOTES, next) || next === "`" || next === "$")) {
|
|
848
|
+
value += next;
|
|
849
|
+
this.pos += 2;
|
|
850
|
+
continue;
|
|
851
|
+
}
|
|
852
|
+
throw unsupported$1("Only `\"`, `` ` `` and `$` can follow a backtick inside double quotes.");
|
|
853
|
+
}
|
|
854
|
+
if (inner === "$") {
|
|
855
|
+
const read = this.readVariable();
|
|
856
|
+
value += read;
|
|
857
|
+
dynamic = true;
|
|
858
|
+
continue;
|
|
859
|
+
}
|
|
860
|
+
value += inner;
|
|
861
|
+
this.pos++;
|
|
862
|
+
}
|
|
863
|
+
continue;
|
|
864
|
+
}
|
|
865
|
+
if (char === "`") {
|
|
866
|
+
const next = this.peek(1);
|
|
867
|
+
if (next === "" || next === "\n" || next === "\r") throw unsupported$1("A backtick at the end of a line (line continuation) is not supported.");
|
|
868
|
+
value += next;
|
|
869
|
+
quoted = true;
|
|
870
|
+
this.pos += 2;
|
|
871
|
+
continue;
|
|
872
|
+
}
|
|
873
|
+
if (char === "$") {
|
|
874
|
+
const wasLiteral = this.src.charAt(this.pos + 1);
|
|
875
|
+
if (wasLiteral === "" || !(wasLiteral === "{" || wasLiteral === "(" || VARIABLE_CHAR.test(wasLiteral))) {
|
|
876
|
+
value += "$";
|
|
877
|
+
this.pos++;
|
|
878
|
+
continue;
|
|
879
|
+
}
|
|
880
|
+
value += this.readVariable();
|
|
881
|
+
dynamic = true;
|
|
882
|
+
continue;
|
|
883
|
+
}
|
|
884
|
+
if (char === "@" && this.pos === start) {
|
|
885
|
+
const next = this.peek(1);
|
|
886
|
+
if (next === "(" || next === "{" || next === "'" || next === "\"" || /[A-Za-z_$]/.test(next)) throw unsupported$1("Arrays (`@( )`), hashtables, splatting (`@name`) and here-strings are not supported.");
|
|
887
|
+
}
|
|
888
|
+
if (char === "{" || char === "}") throw unsupported$1("Script blocks and braces are not supported.");
|
|
889
|
+
if (char === "(" || char === ")") throw unsupported$1("Parentheses (grouping and `$( )` subexpressions) are not supported; quote the text.");
|
|
890
|
+
if (char === ",") throw unsupported$1("The comma operator builds an array and is not supported; quote the text.");
|
|
891
|
+
if (char === "#" && this.pos === start) throw unsupported$1("Comments are not supported in a command line.");
|
|
892
|
+
if (char === "*" || char === "?" || char === "[" || char === "~" && this.pos === start) dynamic = true;
|
|
893
|
+
value += char;
|
|
894
|
+
this.pos++;
|
|
895
|
+
}
|
|
896
|
+
return {
|
|
897
|
+
raw: this.src.slice(start, this.pos),
|
|
898
|
+
value,
|
|
899
|
+
dynamic,
|
|
900
|
+
quoted
|
|
901
|
+
};
|
|
902
|
+
}
|
|
903
|
+
/**
|
|
904
|
+
* Parse a `$( … )` subexpression: its commands run, so they are collected
|
|
905
|
+
* (before the command that contains it) and checked like any other.
|
|
906
|
+
*/
|
|
907
|
+
parseSubexpression() {
|
|
908
|
+
if (this.depth >= 8) throw unsupported$1("`$( )` subexpressions are nested too deeply.");
|
|
909
|
+
const open = this.pos;
|
|
910
|
+
let level = 0;
|
|
911
|
+
let index = open;
|
|
912
|
+
for (; index < this.src.length; index++) {
|
|
913
|
+
const char = this.src.charAt(index);
|
|
914
|
+
if (inSet(SINGLE_QUOTES, char) || inSet(DOUBLE_QUOTES, char)) {
|
|
915
|
+
const set = inSet(SINGLE_QUOTES, char) ? SINGLE_QUOTES : DOUBLE_QUOTES;
|
|
916
|
+
index++;
|
|
917
|
+
while (index < this.src.length) {
|
|
918
|
+
const inner = this.src.charAt(index);
|
|
919
|
+
if (inner === "`" && set === DOUBLE_QUOTES) {
|
|
920
|
+
index += 2;
|
|
921
|
+
continue;
|
|
922
|
+
}
|
|
923
|
+
if (inSet(set, inner)) {
|
|
924
|
+
if (inSet(set, this.src.charAt(index + 1))) {
|
|
925
|
+
index += 2;
|
|
926
|
+
continue;
|
|
927
|
+
}
|
|
928
|
+
break;
|
|
929
|
+
}
|
|
930
|
+
index++;
|
|
931
|
+
}
|
|
932
|
+
continue;
|
|
933
|
+
}
|
|
934
|
+
if (char === "`") {
|
|
935
|
+
index++;
|
|
936
|
+
continue;
|
|
937
|
+
}
|
|
938
|
+
if (char === "(") level++;
|
|
939
|
+
else if (char === ")") {
|
|
940
|
+
level--;
|
|
941
|
+
if (level === 0) break;
|
|
942
|
+
}
|
|
943
|
+
}
|
|
944
|
+
if (level !== 0) throw new PowerShellRefusal("unbalanced-quotes", "A `$(` subexpression is never closed.");
|
|
945
|
+
const inner = this.src.slice(open + 1, index);
|
|
946
|
+
new PowerShellParser(inner, this.commands, this.depth + 1).parseProgram([]);
|
|
947
|
+
this.pos = index + 1;
|
|
948
|
+
}
|
|
949
|
+
/** Read `$name`, `${name}` or `$env:NAME` at the cursor; a `$(` is refused. */
|
|
950
|
+
readVariable() {
|
|
951
|
+
const begin = this.pos;
|
|
952
|
+
this.pos++;
|
|
953
|
+
if (this.peek() === "(") {
|
|
954
|
+
this.parseSubexpression();
|
|
955
|
+
return this.src.slice(begin, this.pos);
|
|
956
|
+
}
|
|
957
|
+
if (this.peek() === "{") {
|
|
958
|
+
const close = this.src.indexOf("}", this.pos);
|
|
959
|
+
if (close === -1) throw new PowerShellRefusal("unbalanced-quotes", "A `${` variable name is never closed.");
|
|
960
|
+
this.pos = close + 1;
|
|
961
|
+
return this.src.slice(begin, this.pos);
|
|
962
|
+
}
|
|
963
|
+
while (!this.atEnd() && VARIABLE_CHAR.test(this.peek())) this.pos++;
|
|
964
|
+
return this.src.slice(begin, this.pos);
|
|
965
|
+
}
|
|
966
|
+
};
|
|
967
|
+
/** Parse a PowerShell command line for the shell policy. Never throws. */
|
|
968
|
+
function parsePowerShellLine(line) {
|
|
969
|
+
const segments = [];
|
|
970
|
+
const parser = new PowerShellParser(line);
|
|
971
|
+
try {
|
|
972
|
+
parser.parseProgram(segments);
|
|
973
|
+
return {
|
|
974
|
+
ok: true,
|
|
975
|
+
commands: parser.commands,
|
|
976
|
+
segments,
|
|
977
|
+
background: false
|
|
978
|
+
};
|
|
979
|
+
} catch (error) {
|
|
980
|
+
if (error instanceof PowerShellRefusal) return {
|
|
981
|
+
ok: false,
|
|
982
|
+
reason: error.reason,
|
|
983
|
+
message: error.message
|
|
984
|
+
};
|
|
985
|
+
throw error;
|
|
986
|
+
}
|
|
987
|
+
}
|
|
988
|
+
|
|
183
989
|
//#endregion
|
|
184
990
|
//#region ../../packages/ai-workspace/src/policy/shell-parser.ts
|
|
185
991
|
/** Deepest nesting of subshells and substitutions the parser accepts. */
|
|
@@ -947,6 +1753,7 @@ var CmdParser = class {
|
|
|
947
1753
|
*/
|
|
948
1754
|
function parseShellLine(line, dialect) {
|
|
949
1755
|
const segments = [];
|
|
1756
|
+
if (dialect === "powershell") return parsePowerShellLine(line);
|
|
950
1757
|
try {
|
|
951
1758
|
if (dialect === "cmd") {
|
|
952
1759
|
const commands = [];
|
|
@@ -1159,6 +1966,26 @@ const OPAQUE_RUNNERS = /* @__PURE__ */ new Set([
|
|
|
1159
1966
|
"stdbuf",
|
|
1160
1967
|
"flock"
|
|
1161
1968
|
]);
|
|
1969
|
+
/**
|
|
1970
|
+
* PowerShell cmdlets and aliases that run code or commands the policy cannot
|
|
1971
|
+
* see, or change what later names mean (lowercase).
|
|
1972
|
+
*/
|
|
1973
|
+
const POWERSHELL_OPAQUE = /* @__PURE__ */ new Set([
|
|
1974
|
+
"iex",
|
|
1975
|
+
"invoke-expression",
|
|
1976
|
+
"invoke-command",
|
|
1977
|
+
"icm",
|
|
1978
|
+
"start-process",
|
|
1979
|
+
"saps",
|
|
1980
|
+
"start",
|
|
1981
|
+
"add-type",
|
|
1982
|
+
"set-alias",
|
|
1983
|
+
"sal",
|
|
1984
|
+
"new-alias",
|
|
1985
|
+
"nal",
|
|
1986
|
+
"invoke-item",
|
|
1987
|
+
"ii"
|
|
1988
|
+
]);
|
|
1162
1989
|
/** POSIX shell builtins that evaluate code or change how later names resolve. */
|
|
1163
1990
|
const SHELL_OPAQUE_BUILTINS = /* @__PURE__ */ new Set([
|
|
1164
1991
|
"eval",
|
|
@@ -1251,7 +2078,7 @@ const MAX_WRAPPER_DEPTH = 8;
|
|
|
1251
2078
|
const MAX_CD_CANDIDATES = 32;
|
|
1252
2079
|
/** Longest command shown verbatim in a refusal before it is abbreviated. */
|
|
1253
2080
|
const MAX_SHOWN_COMMAND = 48;
|
|
1254
|
-
/** The dialect
|
|
2081
|
+
/** The dialect `interpreter: "sh"` parses on this platform. */
|
|
1255
2082
|
function shellDialect() {
|
|
1256
2083
|
return process.platform === "win32" ? "cmd" : "posix";
|
|
1257
2084
|
}
|
|
@@ -1262,7 +2089,7 @@ function shellDialect() {
|
|
|
1262
2089
|
* stripped). The cmd dialect splits Windows paths too.
|
|
1263
2090
|
*/
|
|
1264
2091
|
function executableBasename(firstToken, dialect = "posix") {
|
|
1265
|
-
return (dialect === "cmd" ? node_path.default.win32.basename(firstToken) : node_path.default.basename(firstToken)).replace(/\.(exe|cmd|bat|com)$/i, "");
|
|
2092
|
+
return (dialect === "cmd" || dialect === "powershell" ? node_path.default.win32.basename(firstToken) : node_path.default.basename(firstToken)).replace(dialect === "powershell" ? /\.(exe|cmd|bat|com|ps1)$/i : /\.(exe|cmd|bat|com)$/i, "");
|
|
1266
2093
|
}
|
|
1267
2094
|
/** Show a command in a refusal, abbreviated at a word boundary when long. */
|
|
1268
2095
|
function show(source) {
|
|
@@ -1306,25 +2133,75 @@ var Refusal = class extends Error {
|
|
|
1306
2133
|
};
|
|
1307
2134
|
/** Per-line checker state. */
|
|
1308
2135
|
var LineChecker = class {
|
|
1309
|
-
constructor(policy, shell, line, dialect) {
|
|
2136
|
+
constructor(policy, shell, line, dialect, interpreter, remembered = []) {
|
|
1310
2137
|
this.policy = policy;
|
|
1311
2138
|
this.shell = shell;
|
|
1312
2139
|
this.line = line;
|
|
1313
2140
|
this.dialect = dialect;
|
|
2141
|
+
this.interpreter = interpreter;
|
|
1314
2142
|
this.commands = [];
|
|
1315
2143
|
this.paths = [];
|
|
2144
|
+
this.asks = [];
|
|
1316
2145
|
this.deny = new Set((shell.deny ?? []).map((name) => name.toLowerCase()));
|
|
1317
2146
|
this.allow = shell.allow ?? [];
|
|
1318
2147
|
this.patterns = (shell.denyPatterns ?? []).map(compilePattern);
|
|
1319
|
-
this.
|
|
1320
|
-
|
|
2148
|
+
this.rules = (shell.rules ?? []).map((rule) => ({
|
|
2149
|
+
rule,
|
|
2150
|
+
pattern: compilePattern(rule.match)
|
|
2151
|
+
}));
|
|
2152
|
+
this.remembered = remembered.map(compilePattern);
|
|
2153
|
+
this.askMode = shell.onAsk !== void 0;
|
|
2154
|
+
this.hasCommandRules = this.deny.size > 0 || this.patterns.length > 0 || this.rules.length > 0 || shell.allowCommand !== void 0;
|
|
1321
2155
|
}
|
|
1322
2156
|
/** Whether argument hooks (`denyPatterns` / `allowCommand`) need literal argv. */
|
|
1323
2157
|
get hasArgumentHooks() {
|
|
1324
2158
|
return this.patterns.length > 0 || this.shell.allowCommand !== void 0;
|
|
1325
2159
|
}
|
|
1326
2160
|
get shellMode() {
|
|
1327
|
-
return this.interpreter
|
|
2161
|
+
return this.interpreter !== "none";
|
|
2162
|
+
}
|
|
2163
|
+
/** The last rule that matches `argv` (later rules win). */
|
|
2164
|
+
matchRule(argv) {
|
|
2165
|
+
for (let index = this.rules.length - 1; index >= 0; index--) {
|
|
2166
|
+
const compiled = this.rules[index];
|
|
2167
|
+
if (compiled.pattern.test(argv, this.dialect)) return compiled.rule;
|
|
2168
|
+
}
|
|
2169
|
+
}
|
|
2170
|
+
isRemembered(argv) {
|
|
2171
|
+
return this.remembered.some((pattern) => pattern.test(argv, this.dialect));
|
|
2172
|
+
}
|
|
2173
|
+
/** Record a command that needs a decision (once per argv and reason). */
|
|
2174
|
+
addAsk(argv, reason, rule) {
|
|
2175
|
+
const key = `${reason}\u0000${argv.join("\0")}`;
|
|
2176
|
+
if (this.asks.some((ask) => `${ask.reason}\u0000${ask.argv.join("\0")}` === key)) return;
|
|
2177
|
+
this.asks.push({
|
|
2178
|
+
argv: [...argv],
|
|
2179
|
+
reason,
|
|
2180
|
+
...rule !== void 0 ? { rule } : {}
|
|
2181
|
+
});
|
|
2182
|
+
}
|
|
2183
|
+
/** Refuse when the matching rule denies. */
|
|
2184
|
+
assertNotDenied(argv, rule) {
|
|
2185
|
+
if (rule?.effect !== "deny") return;
|
|
2186
|
+
const explanation = rule.reason !== void 0 && rule.reason.trim() !== "" ? ` ${rule.reason.trim()}` : "";
|
|
2187
|
+
throw new Refusal("denied-pattern", `${show(argv.join(" "))} is denied by a workspace shell rule.${explanation}`, executableBasename(argv[0] ?? "", this.dialect));
|
|
2188
|
+
}
|
|
2189
|
+
/**
|
|
2190
|
+
* Check the canonical form of a PowerShell alias (`Remove-Item -Recurse
|
|
2191
|
+
* -Force x` as `rm -rf x`): deny, `denyPatterns`, `allowCommand` and the
|
|
2192
|
+
* rules, but not the `allow` list (which names the spelling that was written).
|
|
2193
|
+
*/
|
|
2194
|
+
checkCanonical(argv) {
|
|
2195
|
+
const key = executableBasename(argv[0] ?? "", this.dialect).toLowerCase();
|
|
2196
|
+
if (this.deny.has(key)) throw new Refusal("denied-executable", `\`${key}\` is denied by the workspace shell policy (in ${show(argv.join(" "))}).`, key);
|
|
2197
|
+
this.checkHooks(argv, true);
|
|
2198
|
+
const rule = this.matchRule(argv);
|
|
2199
|
+
this.assertNotDenied(argv, rule);
|
|
2200
|
+
if (rule?.effect === "ask" && !this.isRemembered(argv)) this.addAsk(argv, "rule", rule);
|
|
2201
|
+
}
|
|
2202
|
+
/** Refuse a denied executable (used for `cd` spellings). */
|
|
2203
|
+
assertExecutableNotDenied(key, argv) {
|
|
2204
|
+
if (this.deny.has(key)) throw new Refusal("denied-executable", `\`${key}\` is denied by the workspace shell policy (in ${show(argv.join(" "))}).`, key);
|
|
1328
2205
|
}
|
|
1329
2206
|
/** Run `denyPatterns` and `allowCommand` on one argv. */
|
|
1330
2207
|
checkHooks(argv, nested) {
|
|
@@ -1372,15 +2249,34 @@ var LineChecker = class {
|
|
|
1372
2249
|
throw new Refusal("not-allowed", `\`${executable}\` is not in the workspace shell allowlist. Allowed commands: ${listed}${this.allow.length > 20 ? ", …" : ""}.`, executable);
|
|
1373
2250
|
}
|
|
1374
2251
|
this.checkHooks(argv, depth > 0);
|
|
2252
|
+
const rule = this.matchRule(argv);
|
|
2253
|
+
this.assertNotDenied(argv, rule);
|
|
1375
2254
|
this.commands.push([...argv]);
|
|
1376
|
-
|
|
2255
|
+
const remembered = this.isRemembered(argv);
|
|
2256
|
+
const approved = rule?.effect === "allow" || remembered || this.explicitlyAllowed(executable);
|
|
2257
|
+
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) || this.shellMode && this.dialect === "powershell" && POWERSHELL_OPAQUE.has(key)) {
|
|
2258
|
+
if (approved) return;
|
|
2259
|
+
if (this.askMode || rule?.effect === "ask") {
|
|
2260
|
+
this.addAsk(argv, "nested", rule);
|
|
2261
|
+
return;
|
|
2262
|
+
}
|
|
1377
2263
|
this.checkOpaque(executable, argv);
|
|
1378
2264
|
return;
|
|
1379
2265
|
}
|
|
1380
2266
|
if (this.shellMode && this.dialect === "posix" && ARITHMETIC_SENSITIVE_BUILTINS.has(key)) this.checkArithmeticSensitive(executable, argv, dynamic);
|
|
1381
2267
|
const ranges = this.unwrap(key, argv);
|
|
2268
|
+
if (!approved && ranges === void 0) {
|
|
2269
|
+
if (rule?.effect === "ask") this.addAsk(argv, "rule", rule);
|
|
2270
|
+
else if (rule === void 0 && this.askMode && this.allow.length === 0) this.addAsk(argv, "rule");
|
|
2271
|
+
} else if (!approved && rule?.effect === "ask") this.addAsk(argv, "rule", rule);
|
|
1382
2272
|
if (ranges === void 0) return;
|
|
1383
|
-
if (ranges === null || depth >= MAX_WRAPPER_DEPTH)
|
|
2273
|
+
if (ranges === null || depth >= MAX_WRAPPER_DEPTH) {
|
|
2274
|
+
if (this.askMode) {
|
|
2275
|
+
this.addAsk(argv, "nested", rule);
|
|
2276
|
+
return;
|
|
2277
|
+
}
|
|
2278
|
+
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);
|
|
2279
|
+
}
|
|
1384
2280
|
for (const [start, end] of ranges) this.checkArgv(argv.slice(start, end), dynamic.slice(start, end), depth + 1);
|
|
1385
2281
|
}
|
|
1386
2282
|
/**
|
|
@@ -1446,6 +2342,7 @@ var LineChecker = class {
|
|
|
1446
2342
|
}
|
|
1447
2343
|
/** Check a redirection target from every possible cwd. */
|
|
1448
2344
|
checkRedirectTarget(word, access, cwds) {
|
|
2345
|
+
if (this.dialect === "powershell" && !word.quoted && word.value.toLowerCase() === "$null") return;
|
|
1449
2346
|
if (word.dynamic) throw new Refusal("unsupported-syntax", `The redirection target \`${word.raw}\` must be a literal path (no variables, globs or \`~\`).`);
|
|
1450
2347
|
const value = word.value;
|
|
1451
2348
|
if (this.dialect === "posix" && DEVICE_TARGETS.has(value) || this.dialect === "cmd" && CMD_DEVICE_TARGETS.has(value.toLowerCase())) return;
|
|
@@ -1638,11 +2535,17 @@ function argvModeRefusal(command) {
|
|
|
1638
2535
|
if (token === "(" || token === ")") return new Refusal("metacharacter", `\`${token}\` is not supported in argv mode: put it inside quotes to pass it as text.${suffix}`);
|
|
1639
2536
|
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}`);
|
|
1640
2537
|
}
|
|
1641
|
-
/** Check a shell-mode
|
|
2538
|
+
/** Check a shell-mode line. */
|
|
1642
2539
|
function checkShellLine(checker, policy, command, dialect) {
|
|
1643
2540
|
if (/^[-+]/.test(command)) throw new Refusal("unsupported-syntax", "A command line may not start with `-` or `+`.");
|
|
1644
2541
|
const parsed = parseShellLine(command, dialect);
|
|
1645
|
-
if (!parsed.ok)
|
|
2542
|
+
if (!parsed.ok) {
|
|
2543
|
+
if (checker.askMode) {
|
|
2544
|
+
checker.addAsk([command], "unparsed");
|
|
2545
|
+
return;
|
|
2546
|
+
}
|
|
2547
|
+
throw new Refusal(parsed.reason, `${parsed.message} The workspace shell policy only runs lines it can check.`);
|
|
2548
|
+
}
|
|
1646
2549
|
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.");
|
|
1647
2550
|
let cwds = [node_path.default.resolve(policy.cwd)];
|
|
1648
2551
|
for (const simple of parsed.commands) cwds = checkSimpleCommand(checker, simple, cwds, dialect);
|
|
@@ -1656,16 +2559,38 @@ function checkSimpleCommand(checker, simple, cwds, dialect) {
|
|
|
1656
2559
|
for (const redirect of simple.redirects) if (redirect.type === "read" || redirect.type === "write") checker.checkRedirectTarget(redirect.target, redirect.type, cwds);
|
|
1657
2560
|
const first = simple.words[0];
|
|
1658
2561
|
if (first === void 0) return cwds;
|
|
1659
|
-
if (!first.dynamic && first.value.includes("=")) throw new Refusal("unsupported-syntax", `\`${first.raw}\` is not a plain command name or \`NAME=value\` assignment.`);
|
|
1660
|
-
if (/^[-+]/.test(first.value)) throw new Refusal("unsupported-syntax", `\`${first.raw}\` is not a command name.`);
|
|
1661
|
-
if (checker.hasArgumentHooks) {
|
|
1662
|
-
const dynamic = simple.words.find((word) => word.dynamic);
|
|
1663
|
-
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).`);
|
|
1664
|
-
}
|
|
1665
2562
|
const executable = first.dynamic ? "" : executableBasename(first.value, dialect).toLowerCase();
|
|
1666
|
-
|
|
1667
|
-
if (
|
|
1668
|
-
|
|
2563
|
+
const powershell = dialect === "powershell";
|
|
2564
|
+
if (executable === "cd" || powershell && POWERSHELL_CD_NAMES.has(executable)) {
|
|
2565
|
+
checker.assertExecutableNotDenied(executable, simple.words.map((word) => word.value));
|
|
2566
|
+
if (powershell) {
|
|
2567
|
+
const words = normalizePowerShellCd(simple.words);
|
|
2568
|
+
if (words === null) throw new Refusal("unsupported-syntax", "`Set-Location` is supported with one directory (`-Path` / `-LiteralPath` or positional) only.", "cd");
|
|
2569
|
+
return checker.checkCd(words, cwds);
|
|
2570
|
+
}
|
|
2571
|
+
return checker.checkCd(simple.words, cwds);
|
|
2572
|
+
}
|
|
2573
|
+
try {
|
|
2574
|
+
if (!first.dynamic && first.value.includes("=")) throw new Refusal("unsupported-syntax", `\`${first.raw}\` is not a plain command name or \`NAME=value\` assignment.`);
|
|
2575
|
+
if (/^[-+]/.test(first.value)) throw new Refusal("unsupported-syntax", `\`${first.raw}\` is not a command name.`);
|
|
2576
|
+
if (checker.hasArgumentHooks) {
|
|
2577
|
+
const dynamic = simple.words.find((word) => word.dynamic);
|
|
2578
|
+
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).`);
|
|
2579
|
+
}
|
|
2580
|
+
if (dialect === "posix" && (executable === "pushd" || executable === "popd") || powershell && POWERSHELL_DIRECTORY_STACK.has(executable)) throw new Refusal("unsupported-syntax", `\`${executable}\` is not supported; use \`${powershell ? "Set-Location" : "cd"}\`.`);
|
|
2581
|
+
const argv = simple.words.map((word) => word.dynamic ? word.raw : word.value);
|
|
2582
|
+
checker.checkArgv(argv, simple.words.map((word) => word.dynamic), 0);
|
|
2583
|
+
if (powershell) {
|
|
2584
|
+
const canonical = canonicalPowerShellArgv(argv);
|
|
2585
|
+
if (canonical !== void 0 && canonical.join("\0") !== argv.join("\0")) checker.checkCanonical(canonical);
|
|
2586
|
+
}
|
|
2587
|
+
} catch (error) {
|
|
2588
|
+
if (error instanceof Refusal && error.reason === "unsupported-syntax" && checker.askMode) {
|
|
2589
|
+
checker.addAsk(simple.words.map((word) => word.dynamic ? word.raw : word.value), "unparsed");
|
|
2590
|
+
return cwds;
|
|
2591
|
+
}
|
|
2592
|
+
throw error;
|
|
2593
|
+
}
|
|
1669
2594
|
return cwds;
|
|
1670
2595
|
}
|
|
1671
2596
|
/**
|
|
@@ -1693,35 +2618,44 @@ function checkSimpleCommand(checker, simple, cwds, dialect) {
|
|
|
1693
2618
|
* checkShellCommand({ cwd, shell: { allow: ["git"] } }, "git add -A && git commit -m wip");
|
|
1694
2619
|
* // { allowed: false, reason: "metacharacter", message: "`&&` is not supported in argv mode: run `git add -A`, then `git commit -m wip`, …" }
|
|
1695
2620
|
*/
|
|
1696
|
-
function checkShellCommand(policy, command) {
|
|
1697
|
-
|
|
2621
|
+
function checkShellCommand(policy, command, options) {
|
|
2622
|
+
const resolved = options?.resolved ?? resolveShell(policy.shell, options?.host ?? currentShellHost());
|
|
2623
|
+
return runCheck(policy, command, resolved.interpreter, resolved.dialect, options?.remembered);
|
|
1698
2624
|
}
|
|
1699
|
-
|
|
1700
|
-
* {@link checkShellCommand} with the shell-mode dialect chosen explicitly
|
|
1701
|
-
* instead of from the platform (used to test the cmd.exe rules anywhere).
|
|
1702
|
-
* Argv mode ignores `dialect`.
|
|
1703
|
-
*/
|
|
1704
|
-
function checkShellCommandInDialect(policy, command, dialect) {
|
|
2625
|
+
function runCheck(policy, command, interpreter, dialect, remembered) {
|
|
1705
2626
|
const shell = policy.shell;
|
|
1706
2627
|
if (!shell) return {
|
|
1707
2628
|
allowed: false,
|
|
1708
2629
|
reason: "not-allowed",
|
|
1709
2630
|
message: "The workspace has no shell policy, so no command may run.",
|
|
2631
|
+
asks: [],
|
|
1710
2632
|
commands: [],
|
|
1711
2633
|
paths: []
|
|
1712
2634
|
};
|
|
1713
|
-
const
|
|
1714
|
-
const checker = new LineChecker(policy, shell, command,
|
|
2635
|
+
const shellMode = interpreter !== "none";
|
|
2636
|
+
const checker = new LineChecker(policy, shell, command, shellMode ? dialect : "posix", interpreter, remembered);
|
|
1715
2637
|
try {
|
|
1716
2638
|
if (command.trim() === "") throw new Refusal("not-allowed", "The command is empty.");
|
|
1717
|
-
if (
|
|
2639
|
+
if (shellMode) checkShellLine(checker, policy, command, dialect);
|
|
1718
2640
|
else {
|
|
1719
2641
|
const argv = tokenizeCommand(command);
|
|
1720
2642
|
if (argv === null) throw argvModeRefusal(command);
|
|
1721
2643
|
checker.checkArgv(argv, argv.map(() => false), 0);
|
|
1722
2644
|
}
|
|
2645
|
+
if (checker.asks.length > 0) {
|
|
2646
|
+
const first = checker.asks[0];
|
|
2647
|
+
return {
|
|
2648
|
+
allowed: false,
|
|
2649
|
+
reason: "approval-required",
|
|
2650
|
+
message: `${show(first.argv.join(" "))} needs approval, and the workspace shell policy has no approval handler (shell.onAsk).${first.rule?.reason !== void 0 ? ` ${first.rule.reason}` : ""}`,
|
|
2651
|
+
asks: checker.asks,
|
|
2652
|
+
commands: checker.commands,
|
|
2653
|
+
paths: checker.paths
|
|
2654
|
+
};
|
|
2655
|
+
}
|
|
1723
2656
|
return {
|
|
1724
2657
|
allowed: true,
|
|
2658
|
+
asks: [],
|
|
1725
2659
|
commands: checker.commands,
|
|
1726
2660
|
paths: checker.paths
|
|
1727
2661
|
};
|
|
@@ -1733,11 +2667,34 @@ function checkShellCommandInDialect(policy, command, dialect) {
|
|
|
1733
2667
|
message: error.message,
|
|
1734
2668
|
...error.executable !== void 0 ? { executable: error.executable } : {},
|
|
1735
2669
|
...error.refusedPath !== void 0 ? { path: error.refusedPath } : {},
|
|
2670
|
+
asks: [],
|
|
1736
2671
|
commands: checker.commands,
|
|
1737
2672
|
paths: checker.paths
|
|
1738
2673
|
};
|
|
1739
2674
|
}
|
|
1740
2675
|
}
|
|
2676
|
+
/**
|
|
2677
|
+
* The simple commands a line would run (pipeline members, `&&` / `||` / `;`
|
|
2678
|
+
* parts, subshells and substitutions included), without checking anything.
|
|
2679
|
+
* A line the parser cannot read with confidence comes back with `opaque`
|
|
2680
|
+
* set to the reason and no commands.
|
|
2681
|
+
*
|
|
2682
|
+
* The line is read with the policy's shell dialect when its `interpreter`
|
|
2683
|
+
* is a shell, and with the platform's default shell otherwise.
|
|
2684
|
+
*
|
|
2685
|
+
* @example
|
|
2686
|
+
* classifyShellCommand("a | b"); // { commands: [["a"], ["b"]] }
|
|
2687
|
+
*/
|
|
2688
|
+
function classifyShellCommand(command, policy, host) {
|
|
2689
|
+
const resolved = resolveShell(policy?.shell, host ?? currentShellHost());
|
|
2690
|
+
const dialect = resolved.interpreter === "none" ? shellDialect() : resolved.dialect;
|
|
2691
|
+
const parsed = parseShellLine(command, dialect);
|
|
2692
|
+
if (!parsed.ok) return {
|
|
2693
|
+
commands: [],
|
|
2694
|
+
opaque: parsed.message
|
|
2695
|
+
};
|
|
2696
|
+
return { commands: parsed.commands.filter((simple) => simple.words.length > 0).map((simple) => simple.words.map((word) => word.dynamic ? word.raw : word.value)) };
|
|
2697
|
+
}
|
|
1741
2698
|
|
|
1742
2699
|
//#endregion
|
|
1743
2700
|
//#region ../../packages/ai-workspace/src/policy/policy.ts
|
|
@@ -1785,13 +2742,15 @@ async function canonicalize$1(target) {
|
|
|
1785
2742
|
*
|
|
1786
2743
|
* @param policy - The bounding policy (its `cwd` is the jail root).
|
|
1787
2744
|
* @param inputPath - A workspace-relative or absolute path to resolve.
|
|
2745
|
+
* @param access - `"read"` also accepts the `output.spillDir` directory (only
|
|
2746
|
+
* `readFile` asks for it); `"write"` (default) never does.
|
|
1788
2747
|
* @returns The canonical absolute path plus its `/`-separated relative form.
|
|
1789
2748
|
* @throws {WorkspacePolicyError} When the path escapes the jail or hits a deny glob.
|
|
1790
2749
|
*
|
|
1791
2750
|
* @example
|
|
1792
2751
|
* const { absolutePath } = await resolveInJail(policy, "src/index.ts");
|
|
1793
2752
|
*/
|
|
1794
|
-
async function resolveInJail(policy, inputPath) {
|
|
2753
|
+
async function resolveInJail(policy, inputPath, access = "write") {
|
|
1795
2754
|
const jailRoot = await canonicalize$1(policy.cwd);
|
|
1796
2755
|
const absolutePath = await canonicalize$1(node_path.default.isAbsolute(inputPath) ? inputPath : node_path.default.join(policy.cwd, inputPath));
|
|
1797
2756
|
const insideCwd = isInside(absolutePath, jailRoot);
|
|
@@ -1804,6 +2763,11 @@ async function resolveInJail(policy, inputPath) {
|
|
|
1804
2763
|
break;
|
|
1805
2764
|
}
|
|
1806
2765
|
}
|
|
2766
|
+
const spillDir = access === "read" && !insideCwd ? resolveSpillDir(policy) : void 0;
|
|
2767
|
+
if (spillDir !== void 0 && isInside(absolutePath, await canonicalize$1(spillDir))) return {
|
|
2768
|
+
absolutePath,
|
|
2769
|
+
relativePath: ""
|
|
2770
|
+
};
|
|
1807
2771
|
if (!insideCwd && !insideAllow) throw new WorkspacePolicyError(`Path "${inputPath}" resolves outside the workspace jail.`, {
|
|
1808
2772
|
type: "path-escape",
|
|
1809
2773
|
path: inputPath
|
|
@@ -1870,8 +2834,8 @@ function buildEnv(policy) {
|
|
|
1870
2834
|
return env;
|
|
1871
2835
|
}
|
|
1872
2836
|
/**
|
|
1873
|
-
* Variables removed from the environment of a shell-mode (`interpreter
|
|
1874
|
-
* "
|
|
2837
|
+
* Variables removed from the environment of a shell-mode (`interpreter`
|
|
2838
|
+
* other than `"none"`) command: each changes how the shell itself starts, parses or
|
|
1875
2839
|
* resolves the line (`ENV`/`BASH_ENV` run a startup file, `SHELLOPTS` +
|
|
1876
2840
|
* `PS4` can run command substitutions, `BASH_FUNC_*` imports functions that
|
|
1877
2841
|
* shadow commands, `CDPATH` moves `cd`, `IFS` re-splits words).
|
|
@@ -1893,7 +2857,7 @@ const SHELL_STARTUP_VARIABLES = /* @__PURE__ */ new Set([
|
|
|
1893
2857
|
*/
|
|
1894
2858
|
function buildCommandEnv(policy) {
|
|
1895
2859
|
const env = buildEnv(policy);
|
|
1896
|
-
if ((policy.shell?.interpreter ?? "none")
|
|
2860
|
+
if ((policy.shell?.interpreter ?? "none") === "none") return env;
|
|
1897
2861
|
for (const key of Object.keys(env)) {
|
|
1898
2862
|
const upper = key.toUpperCase();
|
|
1899
2863
|
if (SHELL_STARTUP_VARIABLES.has(upper) || upper.startsWith("BASH_FUNC_")) delete env[key];
|
|
@@ -2388,6 +3352,520 @@ function applyPatchWithOps(ops, input) {
|
|
|
2388
3352
|
});
|
|
2389
3353
|
}
|
|
2390
3354
|
|
|
3355
|
+
//#endregion
|
|
3356
|
+
//#region ../../packages/ai-workspace/src/edit/match.ts
|
|
3357
|
+
/** Most lines of file text a miss hint shows. */
|
|
3358
|
+
const MAX_HINT_LINES = 12;
|
|
3359
|
+
/** Most characters of one line a miss hint shows. */
|
|
3360
|
+
const MAX_HINT_LINE_CHARS = 200;
|
|
3361
|
+
const FOLD_MAP = {
|
|
3362
|
+
"‘": "'",
|
|
3363
|
+
"’": "'",
|
|
3364
|
+
"‚": "'",
|
|
3365
|
+
"‛": "'",
|
|
3366
|
+
"“": "\"",
|
|
3367
|
+
"”": "\"",
|
|
3368
|
+
"„": "\"",
|
|
3369
|
+
"‟": "\"",
|
|
3370
|
+
"‐": "-",
|
|
3371
|
+
"‑": "-",
|
|
3372
|
+
"‒": "-",
|
|
3373
|
+
"–": "-",
|
|
3374
|
+
"—": "-",
|
|
3375
|
+
"―": "-",
|
|
3376
|
+
"−": "-",
|
|
3377
|
+
"\xA0": " ",
|
|
3378
|
+
" ": " ",
|
|
3379
|
+
" ": " ",
|
|
3380
|
+
" ": " ",
|
|
3381
|
+
" ": " ",
|
|
3382
|
+
" ": " ",
|
|
3383
|
+
" ": " "
|
|
3384
|
+
};
|
|
3385
|
+
/** Fold look-alike characters one for one, so string indexes stay aligned. */
|
|
3386
|
+
function foldUnicode(value) {
|
|
3387
|
+
let out = "";
|
|
3388
|
+
for (const char of value) out += FOLD_MAP[char] ?? char;
|
|
3389
|
+
return out;
|
|
3390
|
+
}
|
|
3391
|
+
/** Every non-overlapping occurrence of `needle` in `haystack`. */
|
|
3392
|
+
function findAll(haystack, needle) {
|
|
3393
|
+
const ranges = [];
|
|
3394
|
+
if (needle === "") return ranges;
|
|
3395
|
+
let from = 0;
|
|
3396
|
+
for (;;) {
|
|
3397
|
+
const index = haystack.indexOf(needle, from);
|
|
3398
|
+
if (index === -1) return ranges;
|
|
3399
|
+
ranges.push({
|
|
3400
|
+
start: index,
|
|
3401
|
+
end: index + needle.length
|
|
3402
|
+
});
|
|
3403
|
+
from = index + needle.length;
|
|
3404
|
+
}
|
|
3405
|
+
}
|
|
3406
|
+
/** The file's dominant line ending. */
|
|
3407
|
+
function detectEol(content) {
|
|
3408
|
+
const crlf = content.split("\r\n").length - 1;
|
|
3409
|
+
return crlf > content.split("\n").length - 1 - crlf ? "\r\n" : "\n";
|
|
3410
|
+
}
|
|
3411
|
+
/** Convert any line endings in `text` to `eol`. */
|
|
3412
|
+
function withEol(text, eol) {
|
|
3413
|
+
const normalised = text.replace(/\r\n/g, "\n");
|
|
3414
|
+
return eol === "\n" ? normalised : normalised.replace(/\n/g, "\r\n");
|
|
3415
|
+
}
|
|
3416
|
+
/** An LF view of `content` plus the original index of each view character. */
|
|
3417
|
+
function lfView(content) {
|
|
3418
|
+
if (!content.includes("\r\n")) return {
|
|
3419
|
+
view: content,
|
|
3420
|
+
map: null
|
|
3421
|
+
};
|
|
3422
|
+
let view = "";
|
|
3423
|
+
const map = [];
|
|
3424
|
+
for (let index = 0; index < content.length; index++) {
|
|
3425
|
+
if (content[index] === "\r" && content[index + 1] === "\n") continue;
|
|
3426
|
+
map.push(content[index] === "\n" && content[index - 1] === "\r" ? index - 1 : index);
|
|
3427
|
+
view += content[index];
|
|
3428
|
+
}
|
|
3429
|
+
map.push(content.length);
|
|
3430
|
+
return {
|
|
3431
|
+
view,
|
|
3432
|
+
map
|
|
3433
|
+
};
|
|
3434
|
+
}
|
|
3435
|
+
/** Line-based search ignoring trailing whitespace, on already folded text. */
|
|
3436
|
+
function findByLines(view, needle) {
|
|
3437
|
+
const needleLines = needle.split("\n");
|
|
3438
|
+
const endsWithBreak = needleLines.length > 1 && needleLines[needleLines.length - 1] === "";
|
|
3439
|
+
if (endsWithBreak) needleLines.pop();
|
|
3440
|
+
const wanted = needleLines.map((line) => line.trimEnd());
|
|
3441
|
+
if (wanted.every((line) => line === "")) return [];
|
|
3442
|
+
const lines = view.split("\n");
|
|
3443
|
+
const starts = [];
|
|
3444
|
+
let offset = 0;
|
|
3445
|
+
for (const line of lines) {
|
|
3446
|
+
starts.push(offset);
|
|
3447
|
+
offset += line.length + 1;
|
|
3448
|
+
}
|
|
3449
|
+
const ranges = [];
|
|
3450
|
+
for (let index = 0; index + wanted.length <= lines.length;) {
|
|
3451
|
+
let same = true;
|
|
3452
|
+
for (let k = 0; k < wanted.length; k++) if ((lines[index + k] ?? "").trimEnd() !== wanted[k]) {
|
|
3453
|
+
same = false;
|
|
3454
|
+
break;
|
|
3455
|
+
}
|
|
3456
|
+
if (!same) {
|
|
3457
|
+
index++;
|
|
3458
|
+
continue;
|
|
3459
|
+
}
|
|
3460
|
+
const last = index + wanted.length - 1;
|
|
3461
|
+
let end = (starts[last] ?? 0) + (lines[last] ?? "").length;
|
|
3462
|
+
if (endsWithBreak && view[end] === "\n") end++;
|
|
3463
|
+
ranges.push({
|
|
3464
|
+
start: starts[index] ?? 0,
|
|
3465
|
+
end
|
|
3466
|
+
});
|
|
3467
|
+
index += wanted.length;
|
|
3468
|
+
}
|
|
3469
|
+
return ranges;
|
|
3470
|
+
}
|
|
3471
|
+
/**
|
|
3472
|
+
* Find `oldString` in `content`. `"exact"` only looks for the text as given;
|
|
3473
|
+
* `"tiered"` walks the tiers described on {@link WorkspaceEditMatching} and
|
|
3474
|
+
* stops at the first one with a match. The ranges are always in the original
|
|
3475
|
+
* content, whatever view found them.
|
|
3476
|
+
*/
|
|
3477
|
+
function locateEdit(content, oldString, matching) {
|
|
3478
|
+
const exact = findAll(content, oldString);
|
|
3479
|
+
if (exact.length > 0 || matching === "exact") return {
|
|
3480
|
+
tier: exact.length > 0 ? "exact" : null,
|
|
3481
|
+
ranges: exact,
|
|
3482
|
+
viaView: false
|
|
3483
|
+
};
|
|
3484
|
+
const { view, map } = lfView(content);
|
|
3485
|
+
const needle = oldString.replace(/\r\n/g, "\n");
|
|
3486
|
+
const back = (ranges) => map === null ? ranges : ranges.map((range) => ({
|
|
3487
|
+
start: map[range.start] ?? content.length,
|
|
3488
|
+
end: map[range.end] ?? content.length
|
|
3489
|
+
}));
|
|
3490
|
+
if (map !== null || needle !== oldString) {
|
|
3491
|
+
const plain = findAll(view, needle);
|
|
3492
|
+
if (plain.length > 0) return {
|
|
3493
|
+
tier: "exact",
|
|
3494
|
+
ranges: back(plain),
|
|
3495
|
+
viaView: true
|
|
3496
|
+
};
|
|
3497
|
+
}
|
|
3498
|
+
const foldedView = foldUnicode(view);
|
|
3499
|
+
const foldedNeedle = foldUnicode(needle);
|
|
3500
|
+
const folded = findAll(foldedView, foldedNeedle);
|
|
3501
|
+
if (folded.length > 0) return {
|
|
3502
|
+
tier: "unicode",
|
|
3503
|
+
ranges: back(folded),
|
|
3504
|
+
viaView: true
|
|
3505
|
+
};
|
|
3506
|
+
const lined = findByLines(foldedView, foldedNeedle);
|
|
3507
|
+
if (lined.length > 0) return {
|
|
3508
|
+
tier: "whitespace",
|
|
3509
|
+
ranges: back(lined),
|
|
3510
|
+
viaView: true
|
|
3511
|
+
};
|
|
3512
|
+
return {
|
|
3513
|
+
tier: null,
|
|
3514
|
+
ranges: [],
|
|
3515
|
+
viaView: false
|
|
3516
|
+
};
|
|
3517
|
+
}
|
|
3518
|
+
function clipLine(line) {
|
|
3519
|
+
const flat = line.replace(/\r$/, "");
|
|
3520
|
+
return flat.length > 200 ? `${flat.slice(0, 200)}…` : flat;
|
|
3521
|
+
}
|
|
3522
|
+
function tokens(line) {
|
|
3523
|
+
return new Set(line.match(/[A-Za-z0-9_$]+/g) ?? []);
|
|
3524
|
+
}
|
|
3525
|
+
function similarity(left, right) {
|
|
3526
|
+
if (left.size === 0 || right.size === 0) return 0;
|
|
3527
|
+
let shared = 0;
|
|
3528
|
+
for (const token of left) if (right.has(token)) shared++;
|
|
3529
|
+
return shared / (left.size + right.size - shared);
|
|
3530
|
+
}
|
|
3531
|
+
/**
|
|
3532
|
+
* Explain a miss: either `newString` is already in the file (the edit was
|
|
3533
|
+
* probably applied), or the file text closest to `oldString`, at most
|
|
3534
|
+
* {@link MAX_HINT_LINES} lines of {@link MAX_HINT_LINE_CHARS} characters.
|
|
3535
|
+
* `undefined` when nothing is close.
|
|
3536
|
+
*/
|
|
3537
|
+
function describeMiss(content, oldString, newString, maxLines = 12) {
|
|
3538
|
+
if (maxLines <= 0) return;
|
|
3539
|
+
const lines = content.split("\n");
|
|
3540
|
+
const present = newString.trim() === "" ? -1 : content.indexOf(newString);
|
|
3541
|
+
if (present !== -1 && newString !== oldString) return `newString already present in the file (line ${content.slice(0, present).split("\n").length}); the edit may already have been applied.`;
|
|
3542
|
+
const fileTokens = lines.map((line) => tokens(line));
|
|
3543
|
+
const wanted = oldString.split("\n");
|
|
3544
|
+
let best = {
|
|
3545
|
+
score: 0,
|
|
3546
|
+
fileLine: -1,
|
|
3547
|
+
offset: 0
|
|
3548
|
+
};
|
|
3549
|
+
wanted.forEach((text, offset) => {
|
|
3550
|
+
const own = tokens(text);
|
|
3551
|
+
fileTokens.forEach((candidate, fileLine) => {
|
|
3552
|
+
const score = similarity(own, candidate);
|
|
3553
|
+
if (score > best.score) best = {
|
|
3554
|
+
score,
|
|
3555
|
+
fileLine,
|
|
3556
|
+
offset
|
|
3557
|
+
};
|
|
3558
|
+
});
|
|
3559
|
+
});
|
|
3560
|
+
if (best.fileLine === -1 || best.score < .5) return;
|
|
3561
|
+
const first = Math.max(0, best.fileLine - best.offset);
|
|
3562
|
+
const count = Math.min(maxLines, 12, Math.max(1, wanted.length));
|
|
3563
|
+
const shown = lines.slice(first, first + count).map((line, index) => `${first + index + 1}: ${clipLine(line)}`);
|
|
3564
|
+
return `Closest text in the file (lines ${first + 1}-${first + shown.length}):\n${shown.join("\n")}`;
|
|
3565
|
+
}
|
|
3566
|
+
|
|
3567
|
+
//#endregion
|
|
3568
|
+
//#region ../../packages/ai-workspace/src/edit/apply-replacement.ts
|
|
3569
|
+
/**
|
|
3570
|
+
* Apply one replacement to `content` under the edit rules: `oldString` must
|
|
3571
|
+
* be found (see {@link WorkspaceEditMatching}), and exactly once unless
|
|
3572
|
+
* `replaceAll` — a fuzzy match must always be unique, so a loose match never
|
|
3573
|
+
* rewrites several places at once.
|
|
3574
|
+
*/
|
|
3575
|
+
function applyReplacement(content, edit, inputPath, shownPath, options, editIndex) {
|
|
3576
|
+
const { matching } = options;
|
|
3577
|
+
const located = edit.oldString === "" ? null : locateEdit(content, edit.oldString, matching);
|
|
3578
|
+
const count = located?.ranges.length ?? 0;
|
|
3579
|
+
const which = editIndex === void 0 ? "" : `Edit ${editIndex + 1}: `;
|
|
3580
|
+
const nothingApplied = editIndex === void 0 ? "" : " None of the edits were applied.";
|
|
3581
|
+
const indexOption = editIndex === void 0 ? {} : { editIndex };
|
|
3582
|
+
if (located === null || count === 0) {
|
|
3583
|
+
let hint = describeMiss(content, edit.oldString, edit.newString, options.missHintLines);
|
|
3584
|
+
if (hint !== void 0 && options.hintMaxBytes !== void 0) hint = Buffer.byteLength(hint, "utf8") > options.hintMaxBytes ? void 0 : hint;
|
|
3585
|
+
throw new WorkspaceEditError(`${which}The text to replace was not found in "${inputPath}".` + (hint === void 0 ? "" : ` ${hint}`) + nothingApplied, {
|
|
3586
|
+
type: "not-found",
|
|
3587
|
+
path: shownPath,
|
|
3588
|
+
matches: 0,
|
|
3589
|
+
...hint !== void 0 ? { hint } : {},
|
|
3590
|
+
...indexOption
|
|
3591
|
+
});
|
|
3592
|
+
}
|
|
3593
|
+
const fuzzy = located.tier !== "exact";
|
|
3594
|
+
if (count > 1 && (!edit.replaceAll || fuzzy)) {
|
|
3595
|
+
const advice = fuzzy ? "include more surrounding context." : "pass replaceAll or include more surrounding context.";
|
|
3596
|
+
throw new WorkspaceEditError(`${which}The text to replace is not unique in "${inputPath}" (${count} matches${fuzzy && located.viaView ? ", found by loose matching" : ""}); ${advice}${nothingApplied}`, {
|
|
3597
|
+
type: "not-unique",
|
|
3598
|
+
path: shownPath,
|
|
3599
|
+
matches: count,
|
|
3600
|
+
...indexOption
|
|
3601
|
+
});
|
|
3602
|
+
}
|
|
3603
|
+
const eol = options.lineEndings === "lf" ? "\n" : options.lineEndings === "crlf" ? "\r\n" : matching === "tiered" ? detectEol(content) : void 0;
|
|
3604
|
+
const replacement = eol === void 0 ? edit.newString : withEol(edit.newString, eol);
|
|
3605
|
+
const ranges = edit.replaceAll ? located.ranges : located.ranges.slice(0, 1);
|
|
3606
|
+
let out = "";
|
|
3607
|
+
let cursor = 0;
|
|
3608
|
+
for (const range of ranges) {
|
|
3609
|
+
out += content.slice(cursor, range.start) + replacement;
|
|
3610
|
+
cursor = range.end;
|
|
3611
|
+
}
|
|
3612
|
+
out += content.slice(cursor);
|
|
3613
|
+
return {
|
|
3614
|
+
content: out,
|
|
3615
|
+
replacements: ranges.length,
|
|
3616
|
+
...fuzzy ? { matchTier: located.tier } : {}
|
|
3617
|
+
};
|
|
3618
|
+
}
|
|
3619
|
+
|
|
3620
|
+
//#endregion
|
|
3621
|
+
//#region ../../packages/ai-workspace/src/hooks/after-edit.ts
|
|
3622
|
+
/** How long a hook may take when the policy sets no `afterEditTimeoutMs`. */
|
|
3623
|
+
const DEFAULT_HOOK_TIMEOUT_MS = 1e4;
|
|
3624
|
+
/** Longest diagnostics text kept when the policy sets no `afterEditMaxChars`. */
|
|
3625
|
+
const DEFAULT_MAX_CHARS = 4e3;
|
|
3626
|
+
function wrap(text) {
|
|
3627
|
+
return `[diagnostics]\n${text}\n[/diagnostics]`;
|
|
3628
|
+
}
|
|
3629
|
+
/**
|
|
3630
|
+
* Run the policy's `afterEdit` hook for one call and return the text to put
|
|
3631
|
+
* on the result's `diagnostics`, or `undefined` when there is none. A throw
|
|
3632
|
+
* or a timeout yields "diagnostics unavailable"; it never rejects.
|
|
3633
|
+
*/
|
|
3634
|
+
async function runAfterEdit(policy, event) {
|
|
3635
|
+
const hook = policy.hooks?.afterEdit;
|
|
3636
|
+
if (hook === void 0 || event.paths.length === 0) return;
|
|
3637
|
+
const timeoutMs = policy.hooks?.afterEditTimeoutMs ?? DEFAULT_HOOK_TIMEOUT_MS;
|
|
3638
|
+
const maxChars = policy.hooks?.afterEditMaxChars ?? DEFAULT_MAX_CHARS;
|
|
3639
|
+
let timer;
|
|
3640
|
+
try {
|
|
3641
|
+
const answer = await Promise.race([Promise.resolve().then(() => hook(event)), new Promise((_resolve, reject) => {
|
|
3642
|
+
timer = setTimeout(() => reject(/* @__PURE__ */ new Error("afterEdit hook timed out")), timeoutMs);
|
|
3643
|
+
})]);
|
|
3644
|
+
if (typeof answer !== "string" || answer.trim() === "") return;
|
|
3645
|
+
const text = answer.trim();
|
|
3646
|
+
return wrap(text.length > maxChars ? `${text.slice(0, maxChars)}\n[diagnostics truncated: showing ${maxChars} of ${text.length} characters]` : text);
|
|
3647
|
+
} catch {
|
|
3648
|
+
return wrap("diagnostics unavailable");
|
|
3649
|
+
} finally {
|
|
3650
|
+
clearTimeout(timer);
|
|
3651
|
+
}
|
|
3652
|
+
}
|
|
3653
|
+
|
|
3654
|
+
//#endregion
|
|
3655
|
+
//#region ../../packages/ai-workspace/src/output/spill.ts
|
|
3656
|
+
const DEFAULT_MAX_LINES = 2e3;
|
|
3657
|
+
const DEFAULT_MAX_BYTES = 51200;
|
|
3658
|
+
const DEFAULT_RETENTION_MS = 6048e5;
|
|
3659
|
+
const SPILL_NAME = /^spill-(\d+)-[a-z0-9]+-[a-z0-9_-]+\.txt$/;
|
|
3660
|
+
function byteLength(value) {
|
|
3661
|
+
return Buffer.byteLength(value, "utf8");
|
|
3662
|
+
}
|
|
3663
|
+
/** The first `maxBytes` bytes of `value`, cut on a character boundary. */
|
|
3664
|
+
function headOf(value, maxBytes) {
|
|
3665
|
+
const bytes = Buffer.from(value, "utf8");
|
|
3666
|
+
if (bytes.byteLength <= maxBytes) return value;
|
|
3667
|
+
let cut = Math.max(0, maxBytes);
|
|
3668
|
+
while (cut > 0 && ((bytes[cut] ?? 0) & 192) === 128) cut--;
|
|
3669
|
+
return bytes.subarray(0, cut).toString("utf8");
|
|
3670
|
+
}
|
|
3671
|
+
/** The last `maxBytes` bytes of `value`, cut on a character boundary. */
|
|
3672
|
+
function tailOf(value, maxBytes) {
|
|
3673
|
+
const bytes = Buffer.from(value, "utf8");
|
|
3674
|
+
if (bytes.byteLength <= maxBytes) return value;
|
|
3675
|
+
let from = bytes.byteLength - Math.max(0, maxBytes);
|
|
3676
|
+
while (from < bytes.byteLength && ((bytes[from] ?? 0) & 192) === 128) from++;
|
|
3677
|
+
return bytes.subarray(from).toString("utf8");
|
|
3678
|
+
}
|
|
3679
|
+
/** Lines of `value`; a trailing line break does not add an empty line. */
|
|
3680
|
+
function splitLines(value) {
|
|
3681
|
+
if (value === "") return [];
|
|
3682
|
+
const lines = value.split("\n");
|
|
3683
|
+
if (lines[lines.length - 1] === "") lines.pop();
|
|
3684
|
+
return lines;
|
|
3685
|
+
}
|
|
3686
|
+
/** Pick lines from the front within `lines` / `bytes`; one oversized line is clipped. */
|
|
3687
|
+
function takeHead(lines, maxLines, maxBytes) {
|
|
3688
|
+
const out = [];
|
|
3689
|
+
let used = 0;
|
|
3690
|
+
for (const line of lines) {
|
|
3691
|
+
if (out.length >= maxLines) break;
|
|
3692
|
+
const cost = byteLength(line) + 1;
|
|
3693
|
+
if (used + cost > maxBytes) {
|
|
3694
|
+
if (out.length === 0 && maxBytes > 0) out.push(headOf(line, maxBytes));
|
|
3695
|
+
break;
|
|
3696
|
+
}
|
|
3697
|
+
out.push(line);
|
|
3698
|
+
used += cost;
|
|
3699
|
+
}
|
|
3700
|
+
return out;
|
|
3701
|
+
}
|
|
3702
|
+
/** Pick lines from the back, same limits; the result is in file order. */
|
|
3703
|
+
function takeTail(lines, maxLines, maxBytes) {
|
|
3704
|
+
const out = [];
|
|
3705
|
+
let used = 0;
|
|
3706
|
+
for (let index = lines.length - 1; index >= 0; index--) {
|
|
3707
|
+
const line = lines[index] ?? "";
|
|
3708
|
+
if (out.length >= maxLines) break;
|
|
3709
|
+
const cost = byteLength(line) + 1;
|
|
3710
|
+
if (used + cost > maxBytes) {
|
|
3711
|
+
if (out.length === 0 && maxBytes > 0) out.push(tailOf(line, maxBytes));
|
|
3712
|
+
break;
|
|
3713
|
+
}
|
|
3714
|
+
out.unshift(line);
|
|
3715
|
+
used += cost;
|
|
3716
|
+
}
|
|
3717
|
+
return out;
|
|
3718
|
+
}
|
|
3719
|
+
/** Choose the lines to keep for `keep`; `headCount` lines come from the front. */
|
|
3720
|
+
function select(lines, keep, maxLines, maxBytes) {
|
|
3721
|
+
if (keep === "head") return {
|
|
3722
|
+
head: takeHead(lines, maxLines, maxBytes),
|
|
3723
|
+
tail: []
|
|
3724
|
+
};
|
|
3725
|
+
if (keep === "tail") return {
|
|
3726
|
+
head: [],
|
|
3727
|
+
tail: takeTail(lines, maxLines, maxBytes)
|
|
3728
|
+
};
|
|
3729
|
+
const headLines = Math.ceil(maxLines / 2);
|
|
3730
|
+
const headBytes = Math.ceil(maxBytes / 2);
|
|
3731
|
+
const head = takeHead(lines, headLines, headBytes);
|
|
3732
|
+
return {
|
|
3733
|
+
head,
|
|
3734
|
+
tail: takeTail(lines.slice(head.length), maxLines - head.length, maxBytes - headBytes)
|
|
3735
|
+
};
|
|
3736
|
+
}
|
|
3737
|
+
/**
|
|
3738
|
+
* Keeps oversized command, search and read output from flooding the model:
|
|
3739
|
+
* with `output.spillDir` set it saves the full output to a file and returns
|
|
3740
|
+
* the part the policy keeps plus a pointer to it. One instance per ops layer;
|
|
3741
|
+
* it remembers the files it wrote so `dispose()` can remove them.
|
|
3742
|
+
*/
|
|
3743
|
+
var OutputSpiller = class {
|
|
3744
|
+
constructor(backend, policy) {
|
|
3745
|
+
this.backend = backend;
|
|
3746
|
+
this.policy = policy;
|
|
3747
|
+
this.created = [];
|
|
3748
|
+
this.dir = resolveSpillDir(policy);
|
|
3749
|
+
if (this.dir !== void 0 && this.retentionMs > 0) this.sweep();
|
|
3750
|
+
}
|
|
3751
|
+
/** True when `output.spillDir` is set. */
|
|
3752
|
+
get enabled() {
|
|
3753
|
+
return this.dir !== void 0;
|
|
3754
|
+
}
|
|
3755
|
+
/** The spill directory, when spilling is on. */
|
|
3756
|
+
get spillDir() {
|
|
3757
|
+
return this.dir;
|
|
3758
|
+
}
|
|
3759
|
+
get retentionMs() {
|
|
3760
|
+
return this.policy.output?.retentionMs ?? DEFAULT_RETENTION_MS;
|
|
3761
|
+
}
|
|
3762
|
+
limits(cap) {
|
|
3763
|
+
const configured = this.policy.output?.maxBytes ?? DEFAULT_MAX_BYTES;
|
|
3764
|
+
return {
|
|
3765
|
+
maxLines: Math.max(1, Math.floor(this.policy.output?.maxLines ?? DEFAULT_MAX_LINES)),
|
|
3766
|
+
maxBytes: Math.max(1, Math.floor(cap === void 0 ? configured : Math.min(configured, cap)))
|
|
3767
|
+
};
|
|
3768
|
+
}
|
|
3769
|
+
/** Remove spill files older than the retention (only files named like ours). */
|
|
3770
|
+
async sweep() {
|
|
3771
|
+
if (this.dir === void 0) return;
|
|
3772
|
+
try {
|
|
3773
|
+
const now = Date.now();
|
|
3774
|
+
for (const file of await this.backend.list(this.dir)) {
|
|
3775
|
+
const match = SPILL_NAME.exec(node_path.default.basename(file));
|
|
3776
|
+
if (match !== null && now - Number(match[1]) > this.retentionMs) await this.backend.remove(file).catch(() => void 0);
|
|
3777
|
+
}
|
|
3778
|
+
} catch {}
|
|
3779
|
+
}
|
|
3780
|
+
/** Write `content` to a new spill file and return its absolute path. */
|
|
3781
|
+
async write(label, content) {
|
|
3782
|
+
if (this.dir === void 0) return;
|
|
3783
|
+
try {
|
|
3784
|
+
if (this.retentionMs > 0) await this.sweep();
|
|
3785
|
+
const safe = label.toLowerCase().replace(/[^a-z0-9_-]+/g, "-") || "output";
|
|
3786
|
+
const rand = Math.random().toString(36).slice(2, 8) || "x";
|
|
3787
|
+
const file = node_path.default.join(this.dir, `spill-${Date.now()}-${rand}-${safe}.txt`);
|
|
3788
|
+
await this.backend.mkdir(this.dir);
|
|
3789
|
+
await this.backend.writeFile(file, content);
|
|
3790
|
+
this.created.push(file);
|
|
3791
|
+
return file;
|
|
3792
|
+
} catch {
|
|
3793
|
+
return;
|
|
3794
|
+
}
|
|
3795
|
+
}
|
|
3796
|
+
/**
|
|
3797
|
+
* Fit `value` to the policy. Returns it untouched when it fits. Over the
|
|
3798
|
+
* limits with spilling on, the whole text is saved and the kept lines come
|
|
3799
|
+
* back with a `[showing lines … of …; full output saved to …]` marker.
|
|
3800
|
+
* `cap` is a tighter byte limit from elsewhere (`shell.maxOutputBytes`).
|
|
3801
|
+
*/
|
|
3802
|
+
async text(label, value, defaultKeep, cap) {
|
|
3803
|
+
if (this.dir === void 0) return { value };
|
|
3804
|
+
const { maxLines, maxBytes } = this.limits(cap);
|
|
3805
|
+
const lines = splitLines(value);
|
|
3806
|
+
const totalBytes = byteLength(value);
|
|
3807
|
+
if (lines.length <= maxLines && totalBytes <= maxBytes) return { value };
|
|
3808
|
+
const { head, tail } = select(lines, this.policy.output?.keep ?? defaultKeep, maxLines, maxBytes);
|
|
3809
|
+
const file = await this.write(label, value);
|
|
3810
|
+
const shown = head.length + tail.length;
|
|
3811
|
+
const total = lines.length;
|
|
3812
|
+
const where = file === void 0 ? "the rest was dropped" : `full output saved to ${file}`;
|
|
3813
|
+
const marker = `[showing ${tail.length === 0 ? `lines 1-${head.length}` : head.length === 0 ? `lines ${total - tail.length + 1}-${total}` : `lines 1-${head.length} and ${total - tail.length + 1}-${total}`} of ${total}; ${where}]`;
|
|
3814
|
+
const parts = [...head.length > 0 ? [head.join("\n")] : [], marker];
|
|
3815
|
+
if (tail.length > 0) parts.push(tail.join("\n"));
|
|
3816
|
+
return {
|
|
3817
|
+
value: parts.join("\n"),
|
|
3818
|
+
truncation: {
|
|
3819
|
+
shown: { lines: shown },
|
|
3820
|
+
total: {
|
|
3821
|
+
lines: total,
|
|
3822
|
+
bytes: totalBytes
|
|
3823
|
+
},
|
|
3824
|
+
...file !== void 0 ? { path: file } : {}
|
|
3825
|
+
}
|
|
3826
|
+
};
|
|
3827
|
+
}
|
|
3828
|
+
/**
|
|
3829
|
+
* Fit a list of entries (one line each when rendered) to the policy; the
|
|
3830
|
+
* rendered full list is what gets saved.
|
|
3831
|
+
*/
|
|
3832
|
+
async list(label, items, render, defaultKeep) {
|
|
3833
|
+
if (this.dir === void 0) return { items };
|
|
3834
|
+
const { maxLines, maxBytes } = this.limits(void 0);
|
|
3835
|
+
const lines = items.map(render);
|
|
3836
|
+
const totalBytes = lines.reduce((sum, line) => sum + byteLength(line) + 1, 0);
|
|
3837
|
+
if (items.length <= maxLines && totalBytes <= maxBytes) return { items };
|
|
3838
|
+
const { head, tail } = select(lines, this.policy.output?.keep ?? defaultKeep, maxLines, maxBytes);
|
|
3839
|
+
const headCount = head.length;
|
|
3840
|
+
const tailCount = tail.length;
|
|
3841
|
+
const file = await this.write(label, lines.join("\n") + "\n");
|
|
3842
|
+
return {
|
|
3843
|
+
items: [...items.slice(0, headCount), ...items.slice(items.length - tailCount)],
|
|
3844
|
+
truncation: {
|
|
3845
|
+
shown: { lines: headCount + tailCount },
|
|
3846
|
+
total: {
|
|
3847
|
+
lines: items.length,
|
|
3848
|
+
bytes: totalBytes
|
|
3849
|
+
},
|
|
3850
|
+
...file !== void 0 ? { path: file } : {}
|
|
3851
|
+
}
|
|
3852
|
+
};
|
|
3853
|
+
}
|
|
3854
|
+
/** Save the complete text of an output that was already cut elsewhere. */
|
|
3855
|
+
async save(label, content) {
|
|
3856
|
+
return this.write(label, content);
|
|
3857
|
+
}
|
|
3858
|
+
/** Remove expired files, and this run's own when `retentionMs` is `0`. */
|
|
3859
|
+
async dispose() {
|
|
3860
|
+
if (this.dir === void 0) return;
|
|
3861
|
+
if (this.retentionMs === 0) {
|
|
3862
|
+
for (const file of this.created.splice(0)) await this.backend.remove(file).catch(() => void 0);
|
|
3863
|
+
return;
|
|
3864
|
+
}
|
|
3865
|
+
await this.sweep();
|
|
3866
|
+
}
|
|
3867
|
+
};
|
|
3868
|
+
|
|
2391
3869
|
//#endregion
|
|
2392
3870
|
//#region ../../packages/ai-workspace/src/shell-jobs.ts
|
|
2393
3871
|
/** Finished commands kept for a last `shell_output` read before the oldest are pruned. */
|
|
@@ -2764,6 +4242,26 @@ var Ops = class {
|
|
|
2764
4242
|
this.policy = policy;
|
|
2765
4243
|
this.runtime = runtime;
|
|
2766
4244
|
this.pathLocks = /* @__PURE__ */ new Map();
|
|
4245
|
+
this.resolvedShell = resolveShell(policy.shell);
|
|
4246
|
+
this.approvals = runtime.approvals ?? [];
|
|
4247
|
+
this.spiller = new OutputSpiller(backend, policy);
|
|
4248
|
+
}
|
|
4249
|
+
/** The edit-policy settings applied to every replacement. */
|
|
4250
|
+
editOptions() {
|
|
4251
|
+
const edit = this.policy.edit;
|
|
4252
|
+
return {
|
|
4253
|
+
matching: edit?.matching ?? "tiered",
|
|
4254
|
+
missHintLines: edit?.missHintLines ?? 12,
|
|
4255
|
+
lineEndings: edit?.lineEndings ?? "preserve",
|
|
4256
|
+
...this.policy.read?.maxBytes !== void 0 ? { hintMaxBytes: this.policy.read.maxBytes } : {}
|
|
4257
|
+
};
|
|
4258
|
+
}
|
|
4259
|
+
/** Run `afterEdit` for a call that wrote `paths`; `undefined` when there is no output. */
|
|
4260
|
+
diagnosticsFor(tool, paths) {
|
|
4261
|
+
return runAfterEdit(this.policy, {
|
|
4262
|
+
paths,
|
|
4263
|
+
tool
|
|
4264
|
+
});
|
|
2767
4265
|
}
|
|
2768
4266
|
/** Run `task` after every earlier mutation of `absolutePath` has settled. */
|
|
2769
4267
|
async withPathLock(absolutePath, task) {
|
|
@@ -2808,7 +4306,7 @@ var Ops = class {
|
|
|
2808
4306
|
}
|
|
2809
4307
|
}
|
|
2810
4308
|
async readFile(inputPath, opts) {
|
|
2811
|
-
const { absolutePath } = await resolveInJail(this.policy, inputPath);
|
|
4309
|
+
const { absolutePath } = await resolveInJail(this.policy, inputPath, "read");
|
|
2812
4310
|
const raw = await this.backend.readFile(absolutePath);
|
|
2813
4311
|
const hash = _warlock_js_fs.fs.hash.string(raw);
|
|
2814
4312
|
const lines = raw.split("\n");
|
|
@@ -2817,10 +4315,29 @@ var Ops = class {
|
|
|
2817
4315
|
const limit = opts?.limit ?? this.policy.read?.defaultLines ?? DEFAULT_READ_LINES;
|
|
2818
4316
|
const maxBytes = this.policy.read?.maxBytes;
|
|
2819
4317
|
const startIndex = offset - 1;
|
|
2820
|
-
const
|
|
4318
|
+
const window = lines.slice(startIndex, startIndex + Math.max(0, limit));
|
|
4319
|
+
const { content, returnedLines, bytesCapped } = numberWindow(window, offset, maxBytes === void 0 ? void 0 : Math.max(0, Math.floor(maxBytes)));
|
|
2821
4320
|
const endLine = offset + returnedLines - 1;
|
|
4321
|
+
const spillDir = this.spiller.spillDir;
|
|
4322
|
+
let spilled;
|
|
4323
|
+
if (bytesCapped && spillDir !== void 0 && !isInside(absolutePath, spillDir)) {
|
|
4324
|
+
const full = numberWindow(window, offset, void 0).content;
|
|
4325
|
+
const file = await this.spiller.save("read_file", full);
|
|
4326
|
+
if (file !== void 0) spilled = {
|
|
4327
|
+
content: `${content}\n[showing lines 1-${returnedLines} of ${window.length}; full output saved to ${file}]`,
|
|
4328
|
+
truncation: {
|
|
4329
|
+
shown: { lines: returnedLines },
|
|
4330
|
+
total: {
|
|
4331
|
+
lines: window.length,
|
|
4332
|
+
bytes: Buffer.byteLength(full, "utf8")
|
|
4333
|
+
},
|
|
4334
|
+
path: file
|
|
4335
|
+
}
|
|
4336
|
+
};
|
|
4337
|
+
}
|
|
2822
4338
|
return {
|
|
2823
|
-
content,
|
|
4339
|
+
content: spilled?.content ?? content,
|
|
4340
|
+
...spilled !== void 0 ? { truncation: spilled.truncation } : {},
|
|
2824
4341
|
startLine: offset,
|
|
2825
4342
|
endLine,
|
|
2826
4343
|
totalLines,
|
|
@@ -2831,8 +4348,8 @@ var Ops = class {
|
|
|
2831
4348
|
};
|
|
2832
4349
|
}
|
|
2833
4350
|
async writeFile(inputPath, content) {
|
|
2834
|
-
const { absolutePath } = await resolveInJail(this.policy, inputPath);
|
|
2835
|
-
|
|
4351
|
+
const { absolutePath, relativePath } = await resolveInJail(this.policy, inputPath);
|
|
4352
|
+
const written = await this.withPathLock(absolutePath, async () => {
|
|
2836
4353
|
const parent = node_path.default.dirname(absolutePath);
|
|
2837
4354
|
await this.backend.mkdir(parent);
|
|
2838
4355
|
await this.backend.writeFile(absolutePath, content);
|
|
@@ -2841,10 +4358,20 @@ var Ops = class {
|
|
|
2841
4358
|
bytesWritten: Buffer.byteLength(content, "utf8")
|
|
2842
4359
|
};
|
|
2843
4360
|
});
|
|
4361
|
+
const diagnostics = await this.diagnosticsFor("write_file", [relativePath || inputPath]);
|
|
4362
|
+
return {
|
|
4363
|
+
...written,
|
|
4364
|
+
...diagnostics !== void 0 ? { diagnostics } : {}
|
|
4365
|
+
};
|
|
2844
4366
|
}
|
|
2845
4367
|
async editFile(input) {
|
|
2846
4368
|
const { absolutePath, relativePath } = await resolveInJail(this.policy, input.path);
|
|
2847
|
-
|
|
4369
|
+
const result = await this.withPathLock(absolutePath, () => this.applyEdit(input, absolutePath, relativePath));
|
|
4370
|
+
const diagnostics = await this.diagnosticsFor("edit_file", [result.path]);
|
|
4371
|
+
return diagnostics === void 0 ? result : {
|
|
4372
|
+
...result,
|
|
4373
|
+
diagnostics
|
|
4374
|
+
};
|
|
2848
4375
|
}
|
|
2849
4376
|
/** The read-check-replace-write of {@link editFile}, run under its path lock. */
|
|
2850
4377
|
async applyEdit(input, absolutePath, relativePath) {
|
|
@@ -2856,19 +4383,20 @@ var Ops = class {
|
|
|
2856
4383
|
expectedHash: input.expectHash,
|
|
2857
4384
|
actualHash: currentHash
|
|
2858
4385
|
});
|
|
2859
|
-
const { content: updated, replacements } = applyReplacement(current, input, input.path, relativePath || input.path);
|
|
4386
|
+
const { content: updated, replacements, matchTier } = applyReplacement(current, input, input.path, relativePath || input.path, this.editOptions());
|
|
2860
4387
|
await this.backend.writeFile(absolutePath, updated);
|
|
2861
4388
|
return {
|
|
2862
4389
|
path: relativePath || input.path,
|
|
2863
4390
|
replacements,
|
|
2864
|
-
hash: _warlock_js_fs.fs.hash.string(updated)
|
|
4391
|
+
hash: _warlock_js_fs.fs.hash.string(updated),
|
|
4392
|
+
...matchTier !== void 0 ? { matchTier } : {}
|
|
2865
4393
|
};
|
|
2866
4394
|
}
|
|
2867
4395
|
async multiEdit(input) {
|
|
2868
4396
|
const { absolutePath, relativePath } = await resolveInJail(this.policy, input.path);
|
|
2869
4397
|
const shownPath = relativePath || input.path;
|
|
2870
4398
|
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}).`);
|
|
2871
|
-
|
|
4399
|
+
const result = await this.withPathLock(absolutePath, async () => {
|
|
2872
4400
|
const current = await this.backend.readFile(absolutePath);
|
|
2873
4401
|
const currentHash = _warlock_js_fs.fs.hash.string(current);
|
|
2874
4402
|
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.`, {
|
|
@@ -2879,21 +4407,40 @@ var Ops = class {
|
|
|
2879
4407
|
});
|
|
2880
4408
|
let content = current;
|
|
2881
4409
|
let replacements = 0;
|
|
4410
|
+
let matchTier;
|
|
4411
|
+
const options = this.editOptions();
|
|
2882
4412
|
input.edits.forEach((edit, editIndex) => {
|
|
2883
|
-
const applied = applyReplacement(content, edit, input.path, shownPath, editIndex);
|
|
4413
|
+
const applied = applyReplacement(content, edit, input.path, shownPath, options, editIndex);
|
|
2884
4414
|
content = applied.content;
|
|
2885
4415
|
replacements += applied.replacements;
|
|
4416
|
+
if (applied.matchTier === "whitespace" || matchTier === void 0) matchTier = applied.matchTier ?? matchTier;
|
|
2886
4417
|
});
|
|
2887
4418
|
await this.backend.writeFile(absolutePath, content);
|
|
2888
4419
|
return {
|
|
2889
4420
|
path: shownPath,
|
|
2890
4421
|
edits: input.edits.length,
|
|
2891
4422
|
replacements,
|
|
2892
|
-
hash: _warlock_js_fs.fs.hash.string(content)
|
|
4423
|
+
hash: _warlock_js_fs.fs.hash.string(content),
|
|
4424
|
+
...matchTier !== void 0 ? { matchTier } : {}
|
|
2893
4425
|
};
|
|
2894
4426
|
});
|
|
4427
|
+
const diagnostics = await this.diagnosticsFor("multi_edit", [shownPath]);
|
|
4428
|
+
return diagnostics === void 0 ? result : {
|
|
4429
|
+
...result,
|
|
4430
|
+
diagnostics
|
|
4431
|
+
};
|
|
2895
4432
|
}
|
|
2896
4433
|
async applyPatch(input) {
|
|
4434
|
+
const result = await this.runPatch(input);
|
|
4435
|
+
if (result.dryRun) return result;
|
|
4436
|
+
const paths = result.files.filter((file) => file.action !== "deleted").map((file) => file.path);
|
|
4437
|
+
const diagnostics = await this.diagnosticsFor("apply_patch", paths);
|
|
4438
|
+
return diagnostics === void 0 ? result : {
|
|
4439
|
+
...result,
|
|
4440
|
+
diagnostics
|
|
4441
|
+
};
|
|
4442
|
+
}
|
|
4443
|
+
runPatch(input) {
|
|
2897
4444
|
return executePatch(input, {
|
|
2898
4445
|
resolve: (inputPath) => resolveInJail(this.policy, inputPath),
|
|
2899
4446
|
exists: (absolutePath) => this.backend.exists(absolutePath),
|
|
@@ -2913,9 +4460,14 @@ var Ops = class {
|
|
|
2913
4460
|
* (argv or shell mode), then — shell mode — every redirection target and
|
|
2914
4461
|
* `cd` directory against the jail with symlinks resolved.
|
|
2915
4462
|
*/
|
|
2916
|
-
async assertCommandAllowed(command) {
|
|
2917
|
-
const verdict = checkShellCommand(this.policy, command
|
|
2918
|
-
|
|
4463
|
+
async assertCommandAllowed(command, signal) {
|
|
4464
|
+
const verdict = checkShellCommand(this.policy, command, {
|
|
4465
|
+
remembered: this.approvals,
|
|
4466
|
+
resolved: this.resolvedShell
|
|
4467
|
+
});
|
|
4468
|
+
const onAsk = this.policy.shell?.onAsk;
|
|
4469
|
+
const needsApproval = !verdict.allowed && verdict.reason === "approval-required" && verdict.asks.length > 0;
|
|
4470
|
+
if (!verdict.allowed && !(needsApproval && onAsk !== void 0)) throw new WorkspacePolicyError(verdict.message ?? `Command is not permitted by the workspace shell policy: ${command}`, {
|
|
2919
4471
|
type: "denied-command",
|
|
2920
4472
|
command,
|
|
2921
4473
|
...verdict.reason !== void 0 ? { reason: verdict.reason } : {},
|
|
@@ -2938,9 +4490,29 @@ var Ops = class {
|
|
|
2938
4490
|
reason: "not-allowed",
|
|
2939
4491
|
command
|
|
2940
4492
|
});
|
|
4493
|
+
if (needsApproval && onAsk !== void 0) await this.askForApproval(command, verdict.asks, onAsk, signal);
|
|
4494
|
+
}
|
|
4495
|
+
/** Put the commands that need approval to `shell.onAsk`; refuse the line unless it says yes. */
|
|
4496
|
+
async askForApproval(command, asks, onAsk, signal) {
|
|
4497
|
+
let decision;
|
|
4498
|
+
try {
|
|
4499
|
+
decision = await onAsk({
|
|
4500
|
+
command,
|
|
4501
|
+
asks,
|
|
4502
|
+
...signal ? { signal } : {}
|
|
4503
|
+
});
|
|
4504
|
+
} catch {
|
|
4505
|
+
decision = void 0;
|
|
4506
|
+
}
|
|
4507
|
+
if (decision?.allow !== true) throw new WorkspacePolicyError(`Running ${asks.map((ask) => `\`${ask.argv.join(" ")}\``).join(", ")} was not approved.`, {
|
|
4508
|
+
type: "denied-command",
|
|
4509
|
+
reason: "approval-denied",
|
|
4510
|
+
command
|
|
4511
|
+
});
|
|
4512
|
+
if (decision.remember !== void 0 && !this.approvals.includes(decision.remember)) this.approvals.push(decision.remember);
|
|
2941
4513
|
}
|
|
2942
4514
|
async exec(command, opts) {
|
|
2943
|
-
await this.assertCommandAllowed(command);
|
|
4515
|
+
await this.assertCommandAllowed(command, opts?.signal);
|
|
2944
4516
|
const signal = opts?.signal;
|
|
2945
4517
|
if (signal?.aborted) return {
|
|
2946
4518
|
exitCode: CANCELLED_BEFORE_START_EXIT_CODE,
|
|
@@ -2953,24 +4525,27 @@ var Ops = class {
|
|
|
2953
4525
|
const shell = this.policy.shell;
|
|
2954
4526
|
const timeoutMs = opts?.timeoutMs ?? shell?.timeoutMs;
|
|
2955
4527
|
const maxOutputBytes = shell?.maxOutputBytes ?? DEFAULT_MAX_OUTPUT_BYTES;
|
|
2956
|
-
const interpreter =
|
|
4528
|
+
const interpreter = this.resolvedShell.interpreter;
|
|
2957
4529
|
const result = await this.backend.exec(command, {
|
|
2958
4530
|
cwd: this.policy.cwd,
|
|
2959
4531
|
timeoutMs,
|
|
2960
4532
|
env: buildCommandEnv(this.policy),
|
|
2961
4533
|
...signal ? { signal } : {},
|
|
2962
|
-
...interpreter !== "none" ? { interpreter } : {}
|
|
4534
|
+
...interpreter !== "none" ? { interpreter } : {},
|
|
4535
|
+
...interpreter !== "none" && this.resolvedShell.file !== void 0 ? { interpreterPath: this.resolvedShell.file } : {}
|
|
2963
4536
|
});
|
|
2964
|
-
const stdout =
|
|
2965
|
-
const stderr =
|
|
4537
|
+
const stdout = await this.fitStream("run_shell-stdout", result.stdout, maxOutputBytes);
|
|
4538
|
+
const stderr = await this.fitStream("run_shell-stderr", result.stderr, maxOutputBytes);
|
|
2966
4539
|
const cancelled = result.cancelled === true;
|
|
4540
|
+
const truncation = streamTruncation(stdout.truncation, stderr.truncation);
|
|
2967
4541
|
return {
|
|
2968
4542
|
exitCode: result.exitCode,
|
|
2969
4543
|
stdout: stdout.value,
|
|
2970
4544
|
stderr: stderr.value,
|
|
2971
4545
|
truncated: stdout.truncated || stderr.truncated,
|
|
2972
4546
|
timedOut: result.timedOut && !cancelled,
|
|
2973
|
-
cancelled
|
|
4547
|
+
cancelled,
|
|
4548
|
+
...truncation !== void 0 ? { truncation } : {}
|
|
2974
4549
|
};
|
|
2975
4550
|
}
|
|
2976
4551
|
async startShell(command, opts) {
|
|
@@ -2988,7 +4563,8 @@ var Ops = class {
|
|
|
2988
4563
|
command
|
|
2989
4564
|
});
|
|
2990
4565
|
const shell = this.policy.shell;
|
|
2991
|
-
const interpreter =
|
|
4566
|
+
const interpreter = this.resolvedShell.interpreter;
|
|
4567
|
+
const interpreterPath = interpreter !== "none" ? this.resolvedShell.file : void 0;
|
|
2992
4568
|
const timeoutMs = lowerOf(background.timeoutMs, opts?.timeoutMs);
|
|
2993
4569
|
const env = buildCommandEnv(this.policy);
|
|
2994
4570
|
return this.runtime.jobs.start({
|
|
@@ -3000,12 +4576,51 @@ var Ops = class {
|
|
|
3000
4576
|
cwd: this.policy.cwd,
|
|
3001
4577
|
env,
|
|
3002
4578
|
...interpreter !== "none" ? { interpreter } : {},
|
|
4579
|
+
...interpreterPath !== void 0 ? { interpreterPath } : {},
|
|
3003
4580
|
onOutput
|
|
3004
4581
|
})
|
|
3005
4582
|
});
|
|
3006
4583
|
}
|
|
3007
|
-
shellOutput(id, opts) {
|
|
3008
|
-
|
|
4584
|
+
async shellOutput(id, opts) {
|
|
4585
|
+
const result = await this.runtime.jobs.output(id, opts);
|
|
4586
|
+
if (!this.spiller.enabled) return result;
|
|
4587
|
+
const stdout = await this.spiller.text(`shell_output-${id}-stdout`, result.stdout, "head+tail");
|
|
4588
|
+
const stderr = await this.spiller.text(`shell_output-${id}-stderr`, result.stderr, "head+tail");
|
|
4589
|
+
const truncation = streamTruncation(stdout.truncation, stderr.truncation);
|
|
4590
|
+
return truncation === void 0 ? result : {
|
|
4591
|
+
...result,
|
|
4592
|
+
stdout: stdout.value,
|
|
4593
|
+
stderr: stderr.value,
|
|
4594
|
+
truncated: true,
|
|
4595
|
+
truncation
|
|
4596
|
+
};
|
|
4597
|
+
}
|
|
4598
|
+
/**
|
|
4599
|
+
* Fit one captured stream to the limits: spilled to a file when
|
|
4600
|
+
* `output.spillDir` is set, otherwise clipped at `maxBytes` as always.
|
|
4601
|
+
*/
|
|
4602
|
+
async fitStream(label, value, maxBytes) {
|
|
4603
|
+
if (this.spiller.enabled) {
|
|
4604
|
+
const cap = this.policy.shell?.maxOutputBytes;
|
|
4605
|
+
const spilled = await this.spiller.text(label, value, "head+tail", cap);
|
|
4606
|
+
if (spilled.truncation !== void 0) return {
|
|
4607
|
+
value: spilled.value,
|
|
4608
|
+
truncated: true,
|
|
4609
|
+
truncation: spilled.truncation
|
|
4610
|
+
};
|
|
4611
|
+
}
|
|
4612
|
+
const capped = capOutput(value, maxBytes);
|
|
4613
|
+
if (!capped.truncated) return capped;
|
|
4614
|
+
return {
|
|
4615
|
+
...capped,
|
|
4616
|
+
truncation: {
|
|
4617
|
+
shown: { lines: splitLines(capped.value).length },
|
|
4618
|
+
total: {
|
|
4619
|
+
lines: splitLines(value).length,
|
|
4620
|
+
bytes: Buffer.byteLength(value, "utf8")
|
|
4621
|
+
}
|
|
4622
|
+
}
|
|
4623
|
+
};
|
|
3009
4624
|
}
|
|
3010
4625
|
shellKill(id) {
|
|
3011
4626
|
return this.runtime.jobs.kill(id);
|
|
@@ -3013,8 +4628,9 @@ var Ops = class {
|
|
|
3013
4628
|
listShells() {
|
|
3014
4629
|
return this.runtime.jobs.list();
|
|
3015
4630
|
}
|
|
3016
|
-
dispose() {
|
|
3017
|
-
|
|
4631
|
+
async dispose() {
|
|
4632
|
+
await this.runtime.jobs.dispose();
|
|
4633
|
+
await this.spiller.dispose();
|
|
3018
4634
|
}
|
|
3019
4635
|
async listDir(inputPath = ".", opts) {
|
|
3020
4636
|
const { absolutePath: root, relativePath: rootRelative } = await resolveInJail(this.policy, inputPath);
|
|
@@ -3118,16 +4734,32 @@ var Ops = class {
|
|
|
3118
4734
|
line: index + 1,
|
|
3119
4735
|
text: line
|
|
3120
4736
|
});
|
|
3121
|
-
if (matches.length >= DEFAULT_MAX_GREP_MATCHES) return
|
|
3122
|
-
matches,
|
|
3123
|
-
total: matches.length
|
|
3124
|
-
};
|
|
4737
|
+
if (matches.length >= DEFAULT_MAX_GREP_MATCHES) return this.fitMatches(matches);
|
|
3125
4738
|
}
|
|
3126
4739
|
}
|
|
3127
4740
|
}
|
|
3128
|
-
return
|
|
4741
|
+
return this.fitMatches(matches);
|
|
4742
|
+
}
|
|
4743
|
+
/** Apply the `output` limits to a finished match list. */
|
|
4744
|
+
async fitMatches(matches) {
|
|
4745
|
+
const fitted = await this.spiller.list("grep", matches, (match) => `${match.path}:${match.line}:${match.text}`, "head");
|
|
4746
|
+
return fitted.truncation === void 0 ? {
|
|
3129
4747
|
matches,
|
|
3130
4748
|
total: matches.length
|
|
4749
|
+
} : {
|
|
4750
|
+
matches: fitted.items,
|
|
4751
|
+
total: matches.length,
|
|
4752
|
+
truncated: true,
|
|
4753
|
+
truncation: fitted.truncation
|
|
4754
|
+
};
|
|
4755
|
+
}
|
|
4756
|
+
async globResult(pattern) {
|
|
4757
|
+
const all = await this.glob(pattern);
|
|
4758
|
+
const fitted = await this.spiller.list("glob", all, (entry) => entry, "head");
|
|
4759
|
+
return fitted.truncation === void 0 ? { paths: all } : {
|
|
4760
|
+
paths: fitted.items,
|
|
4761
|
+
truncated: true,
|
|
4762
|
+
truncation: fitted.truncation
|
|
3131
4763
|
};
|
|
3132
4764
|
}
|
|
3133
4765
|
async glob(pattern) {
|
|
@@ -3160,57 +4792,20 @@ var Ops = class {
|
|
|
3160
4792
|
return denyPaths !== void 0 && denyPaths.length > 0 && matchesAnyGlob(relativePath, denyPaths);
|
|
3161
4793
|
}
|
|
3162
4794
|
};
|
|
4795
|
+
/** Combine per-stream truncation metadata; `undefined` when neither stream was clipped. */
|
|
4796
|
+
function streamTruncation(stdout, stderr) {
|
|
4797
|
+
if (stdout === void 0 && stderr === void 0) return;
|
|
4798
|
+
return {
|
|
4799
|
+
...stdout !== void 0 ? { stdout } : {},
|
|
4800
|
+
...stderr !== void 0 ? { stderr } : {}
|
|
4801
|
+
};
|
|
4802
|
+
}
|
|
3163
4803
|
/** The lower of two optional limits (either may be absent). */
|
|
3164
4804
|
function lowerOf(policyLimit, requested) {
|
|
3165
4805
|
if (requested === void 0 || requested <= 0) return policyLimit;
|
|
3166
4806
|
return policyLimit === void 0 ? requested : Math.min(policyLimit, requested);
|
|
3167
4807
|
}
|
|
3168
4808
|
/**
|
|
3169
|
-
* Apply one exact-string replacement to `content` under the edit rules:
|
|
3170
|
-
* `oldString` must occur, and exactly once unless `replaceAll`.
|
|
3171
|
-
*/
|
|
3172
|
-
function applyReplacement(content, edit, inputPath, shownPath, editIndex) {
|
|
3173
|
-
const occurrences = countOccurrences(content, edit.oldString);
|
|
3174
|
-
const which = editIndex === void 0 ? "" : `Edit ${editIndex + 1}: `;
|
|
3175
|
-
const nothingApplied = editIndex === void 0 ? "" : " None of the edits were applied.";
|
|
3176
|
-
const indexOption = editIndex === void 0 ? {} : { editIndex };
|
|
3177
|
-
if (occurrences === 0) throw new WorkspaceEditError(`${which}The text to replace was not found in "${inputPath}".${nothingApplied}`, {
|
|
3178
|
-
type: "not-found",
|
|
3179
|
-
path: shownPath,
|
|
3180
|
-
matches: 0,
|
|
3181
|
-
...indexOption
|
|
3182
|
-
});
|
|
3183
|
-
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}`, {
|
|
3184
|
-
type: "not-unique",
|
|
3185
|
-
path: shownPath,
|
|
3186
|
-
matches: occurrences,
|
|
3187
|
-
...indexOption
|
|
3188
|
-
});
|
|
3189
|
-
return {
|
|
3190
|
-
content: edit.replaceAll ? content.split(edit.oldString).join(edit.newString) : replaceFirst(content, edit.oldString, edit.newString),
|
|
3191
|
-
replacements: edit.replaceAll ? occurrences : 1
|
|
3192
|
-
};
|
|
3193
|
-
}
|
|
3194
|
-
/** Count non-overlapping occurrences of `needle` in `haystack`. */
|
|
3195
|
-
function countOccurrences(haystack, needle) {
|
|
3196
|
-
if (needle === "") return 0;
|
|
3197
|
-
let count = 0;
|
|
3198
|
-
let from = 0;
|
|
3199
|
-
while (true) {
|
|
3200
|
-
const index = haystack.indexOf(needle, from);
|
|
3201
|
-
if (index === -1) break;
|
|
3202
|
-
count++;
|
|
3203
|
-
from = index + needle.length;
|
|
3204
|
-
}
|
|
3205
|
-
return count;
|
|
3206
|
-
}
|
|
3207
|
-
/** Replace the first occurrence of `needle` with `replacement`. */
|
|
3208
|
-
function replaceFirst(haystack, needle, replacement) {
|
|
3209
|
-
const index = haystack.indexOf(needle);
|
|
3210
|
-
if (index === -1) return haystack;
|
|
3211
|
-
return haystack.slice(0, index) + replacement + haystack.slice(index + needle.length);
|
|
3212
|
-
}
|
|
3213
|
-
/**
|
|
3214
4809
|
* Create the policy-enforced operation layer over a backend.
|
|
3215
4810
|
*
|
|
3216
4811
|
* The returned {@link WorkspaceOps} is the single seam both the
|
|
@@ -3337,25 +4932,39 @@ const POSIX_SHELL = "/bin/sh";
|
|
|
3337
4932
|
* `/s` keeps the line verbatim inside the outer quotes). The ops layer has
|
|
3338
4933
|
* already checked every command in the line.
|
|
3339
4934
|
*/
|
|
3340
|
-
function planSpawn(command, interpreter) {
|
|
3341
|
-
if (interpreter === "
|
|
3342
|
-
|
|
3343
|
-
|
|
3344
|
-
|
|
3345
|
-
|
|
3346
|
-
|
|
3347
|
-
|
|
3348
|
-
|
|
3349
|
-
|
|
3350
|
-
|
|
3351
|
-
|
|
3352
|
-
|
|
3353
|
-
|
|
3354
|
-
|
|
3355
|
-
|
|
3356
|
-
|
|
3357
|
-
|
|
3358
|
-
|
|
4935
|
+
function planSpawn(command, interpreter, interpreterPath, hostPlatform = node_process.platform) {
|
|
4936
|
+
if (interpreter === "powershell") return {
|
|
4937
|
+
file: interpreterPath ?? (hostPlatform === "win32" ? "powershell.exe" : "pwsh"),
|
|
4938
|
+
args: [
|
|
4939
|
+
"-NoLogo",
|
|
4940
|
+
"-NoProfile",
|
|
4941
|
+
"-NonInteractive",
|
|
4942
|
+
"-Command",
|
|
4943
|
+
command
|
|
4944
|
+
],
|
|
4945
|
+
windowsVerbatimArguments: false
|
|
4946
|
+
};
|
|
4947
|
+
if (interpreter === "bash") return {
|
|
4948
|
+
file: interpreterPath ?? "bash",
|
|
4949
|
+
args: ["-c", command],
|
|
4950
|
+
windowsVerbatimArguments: false
|
|
4951
|
+
};
|
|
4952
|
+
if (interpreter === "cmd" || interpreter === "sh" && hostPlatform === "win32") return {
|
|
4953
|
+
file: interpreterPath ?? process.env.ComSpec ?? "cmd.exe",
|
|
4954
|
+
args: [
|
|
4955
|
+
"/d",
|
|
4956
|
+
"/v:off",
|
|
4957
|
+
"/s",
|
|
4958
|
+
"/c",
|
|
4959
|
+
`"${command}"`
|
|
4960
|
+
],
|
|
4961
|
+
windowsVerbatimArguments: true
|
|
4962
|
+
};
|
|
4963
|
+
if (interpreter === "sh") return {
|
|
4964
|
+
file: interpreterPath ?? POSIX_SHELL,
|
|
4965
|
+
args: ["-c", command],
|
|
4966
|
+
windowsVerbatimArguments: false
|
|
4967
|
+
};
|
|
3359
4968
|
const argv = tokenizeCommand(command);
|
|
3360
4969
|
const executable = argv?.[0];
|
|
3361
4970
|
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." };
|
|
@@ -3511,7 +5120,7 @@ var LocalBackend = class {
|
|
|
3511
5120
|
});
|
|
3512
5121
|
return;
|
|
3513
5122
|
}
|
|
3514
|
-
const plan = planSpawn(command, opts.interpreter ?? "none");
|
|
5123
|
+
const plan = planSpawn(command, opts.interpreter ?? "none", opts.interpreterPath);
|
|
3515
5124
|
if ("refused" in plan) {
|
|
3516
5125
|
refuse(plan.refused);
|
|
3517
5126
|
return;
|
|
@@ -3585,7 +5194,7 @@ var LocalBackend = class {
|
|
|
3585
5194
|
* still-running tree is also killed when the Node process exits.
|
|
3586
5195
|
*/
|
|
3587
5196
|
start(command, opts) {
|
|
3588
|
-
const plan = planSpawn(command, opts.interpreter ?? "none");
|
|
5197
|
+
const plan = planSpawn(command, opts.interpreter ?? "none", opts.interpreterPath);
|
|
3589
5198
|
if ("refused" in plan) {
|
|
3590
5199
|
opts.onOutput("stderr", plan.refused);
|
|
3591
5200
|
return {
|
|
@@ -4310,6 +5919,7 @@ function makeGlobTool(ops, options) {
|
|
|
4310
5919
|
action: (input) => `Finding files matching ${input.pattern}`,
|
|
4311
5920
|
input: globInputSchema,
|
|
4312
5921
|
async execute(input) {
|
|
5922
|
+
if (ops.globResult !== void 0) return ops.globResult(input.pattern);
|
|
4313
5923
|
return { paths: await ops.glob(input.pattern) };
|
|
4314
5924
|
}
|
|
4315
5925
|
});
|
|
@@ -4605,6 +6215,23 @@ function runShellInputSchema(background) {
|
|
|
4605
6215
|
}
|
|
4606
6216
|
} }, runShellJsonSchema(background));
|
|
4607
6217
|
}
|
|
6218
|
+
const DIALECT_RULES = {
|
|
6219
|
+
posix: "Use POSIX shell syntax: `;`, `&&`, `||`, pipes, quotes, `$( )` and redirections are supported, and every command in the line is checked. Compound statements (`if`, `for`), brace expansion, arithmetic and `&` are not.",
|
|
6220
|
+
cmd: "Use cmd.exe syntax: `&&`, `||`, `&`, `|` and simple redirections are supported, and every command in the line is checked. `%` and `!` expansion, `^`, parentheses and `if` / `for` are not.",
|
|
6221
|
+
powershell: "Use PowerShell syntax: `;`, `&&`, `||`, pipes, `$( )`, the call operator `&` and the redirections `>`, `>>`, `2>` and `2>&1` are supported, and every command in the line is checked (aliases such as `rm` and `Remove-Item` are treated alike). Script blocks, `Invoke-Expression`, here-strings, splatting and `if` / `foreach` are not."
|
|
6222
|
+
};
|
|
6223
|
+
const PLATFORM_NAMES = {
|
|
6224
|
+
win32: "Windows",
|
|
6225
|
+
darwin: "macOS",
|
|
6226
|
+
linux: "Linux"
|
|
6227
|
+
};
|
|
6228
|
+
/** The sentence that names the shell and its syntax, or `""` in argv mode. */
|
|
6229
|
+
function shellSentence(options) {
|
|
6230
|
+
const dialect = options?.shellDialect;
|
|
6231
|
+
if (options?.shellLabel === void 0 || dialect === void 0 || dialect === "none") return "";
|
|
6232
|
+
const platform = options.platform ?? process.platform;
|
|
6233
|
+
return ` Commands run on ${PLATFORM_NAMES[platform] ?? platform} using ${options.shellLabel}. ` + DIALECT_RULES[dialect];
|
|
6234
|
+
}
|
|
4608
6235
|
/** The lower of the model's timeout and the cap (either may be absent). */
|
|
4609
6236
|
function cappedTimeout(requested, cap) {
|
|
4610
6237
|
if (requested === void 0) return cap;
|
|
@@ -4614,7 +6241,7 @@ function makeRunShellTool(ops, options) {
|
|
|
4614
6241
|
const background = options?.background === true;
|
|
4615
6242
|
return (0, _warlock_js_ai.tool)({
|
|
4616
6243
|
name: options?.name ?? DEFAULT_RUN_SHELL_TOOL_NAME,
|
|
4617
|
-
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." : ""),
|
|
6244
|
+
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." + shellSentence(options) + (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." : ""),
|
|
4618
6245
|
action: (input) => `Running \`${input.command}\``,
|
|
4619
6246
|
input: runShellInputSchema(background),
|
|
4620
6247
|
execute: async (input, ctx) => {
|
|
@@ -4674,8 +6301,9 @@ const runTestsInputSchema = withJsonSchema({ "~standard": {
|
|
|
4674
6301
|
}
|
|
4675
6302
|
} }, RUN_TESTS_JSON_SCHEMA);
|
|
4676
6303
|
/** Quote the test pattern as one literal argument for the line's interpreter. */
|
|
4677
|
-
function quotePattern(pattern, interpreter) {
|
|
4678
|
-
if (
|
|
6304
|
+
function quotePattern(pattern, interpreter, dialect, platform = process.platform) {
|
|
6305
|
+
if (dialect === "powershell" || interpreter === "powershell") return `'${pattern.replace(/['\u2018\u2019\u201A\u201B]/g, "$&$&")}'`;
|
|
6306
|
+
if (interpreter === "bash" || interpreter === "sh" && platform !== "win32") return `'${pattern.replace(/'/g, "'\\''")}'`;
|
|
4679
6307
|
return `"${pattern}"`;
|
|
4680
6308
|
}
|
|
4681
6309
|
/**
|
|
@@ -4710,7 +6338,7 @@ function makeRunTestsTool(ops, options) {
|
|
|
4710
6338
|
action: (input) => input.pattern ? `Running tests matching "${input.pattern}"` : "Running tests",
|
|
4711
6339
|
input: runTestsInputSchema,
|
|
4712
6340
|
execute: (input, ctx) => {
|
|
4713
|
-
const command = input.pattern ? `${baseCommand} ${quotePattern(input.pattern, options?.interpreter ?? "none")}` : baseCommand;
|
|
6341
|
+
const command = input.pattern ? `${baseCommand} ${quotePattern(input.pattern, options?.interpreter ?? "none", options?.dialect, options?.platform)}` : baseCommand;
|
|
4714
6342
|
return ops.exec(command, ctx?.signal ? { signal: ctx.signal } : void 0);
|
|
4715
6343
|
}
|
|
4716
6344
|
});
|
|
@@ -5016,11 +6644,12 @@ function makeWriteFileTool(ops, options) {
|
|
|
5016
6644
|
description: "Write full content to a workspace file, creating it (and any parent directories) if absent and overwriting it otherwise. The write is atomic. Returns the bytes written and the new content hash.",
|
|
5017
6645
|
input: inputSchema,
|
|
5018
6646
|
async execute(input) {
|
|
5019
|
-
const { hash, bytesWritten } = await ops.writeFile(input.path, input.content);
|
|
6647
|
+
const { hash, bytesWritten, diagnostics } = await ops.writeFile(input.path, input.content);
|
|
5020
6648
|
return {
|
|
5021
6649
|
path: input.path,
|
|
5022
6650
|
bytesWritten,
|
|
5023
|
-
hash
|
|
6651
|
+
hash,
|
|
6652
|
+
...diagnostics !== void 0 ? { diagnostics } : {}
|
|
5024
6653
|
};
|
|
5025
6654
|
}
|
|
5026
6655
|
});
|
|
@@ -5094,10 +6723,16 @@ var WorkspaceImpl = class WorkspaceImpl {
|
|
|
5094
6723
|
this.allowedTools = new Set(allowedTools);
|
|
5095
6724
|
this.state = state ?? {
|
|
5096
6725
|
jobs: new ShellJobs(),
|
|
5097
|
-
todos: new TodoStore()
|
|
6726
|
+
todos: new TodoStore(),
|
|
6727
|
+
approvals: []
|
|
5098
6728
|
};
|
|
6729
|
+
this.resolvedShell = resolveShell(policy.shell);
|
|
6730
|
+
this.shell = { classify: (line) => classifyShellCommand(line, policy) };
|
|
5099
6731
|
const backend = selectBackend(policy);
|
|
5100
|
-
this.ops = createOps(backend, policy, {
|
|
6732
|
+
this.ops = createOps(backend, policy, {
|
|
6733
|
+
jobs: this.state.jobs,
|
|
6734
|
+
approvals: this.state.approvals
|
|
6735
|
+
});
|
|
5101
6736
|
this.tools = this.buildTools();
|
|
5102
6737
|
}
|
|
5103
6738
|
/**
|
|
@@ -5111,7 +6746,7 @@ var WorkspaceImpl = class WorkspaceImpl {
|
|
|
5111
6746
|
const erase = (contract) => contract;
|
|
5112
6747
|
const shell = this.policy.shell;
|
|
5113
6748
|
const background = shell?.background !== void 0;
|
|
5114
|
-
const
|
|
6749
|
+
const resolvedShell = this.resolvedShell;
|
|
5115
6750
|
const factories = {
|
|
5116
6751
|
readFile: (opts) => erase(makeReadFileTool(this.ops, opts)),
|
|
5117
6752
|
editFile: (opts) => erase(makeEditFileTool(this.ops, opts)),
|
|
@@ -5122,13 +6757,16 @@ var WorkspaceImpl = class WorkspaceImpl {
|
|
|
5122
6757
|
runShell: (opts) => erase(makeRunShellTool(this.ops, {
|
|
5123
6758
|
...opts?.name !== void 0 ? { name: opts.name } : {},
|
|
5124
6759
|
...shell?.timeoutMs !== void 0 ? { maxTimeoutMs: shell.timeoutMs } : {},
|
|
5125
|
-
background
|
|
6760
|
+
background,
|
|
6761
|
+
...resolvedShell.interpreter !== "none" ? { shellLabel: resolvedShell.label } : {},
|
|
6762
|
+
shellDialect: resolvedShell.interpreter === "none" ? "none" : resolvedShell.dialect
|
|
5126
6763
|
})),
|
|
5127
6764
|
shellOutput: (opts) => erase(makeShellOutputTool(this.ops, opts)),
|
|
5128
6765
|
shellKill: (opts) => erase(makeShellKillTool(this.ops, opts)),
|
|
5129
6766
|
runTests: (opts) => erase(makeRunTestsTool(this.ops, {
|
|
5130
6767
|
...opts,
|
|
5131
|
-
interpreter
|
|
6768
|
+
interpreter: resolvedShell.interpreter,
|
|
6769
|
+
dialect: resolvedShell.dialect
|
|
5132
6770
|
})),
|
|
5133
6771
|
grep: (opts) => erase(makeGrepTool(this.ops, opts)),
|
|
5134
6772
|
glob: (opts) => erase(makeGlobTool(this.ops, opts)),
|
|
@@ -5311,6 +6949,7 @@ exports.applyHunks = applyHunks;
|
|
|
5311
6949
|
exports.applyPatchWithOps = applyPatchWithOps;
|
|
5312
6950
|
exports.buildEnv = buildEnv;
|
|
5313
6951
|
exports.checkShellCommand = checkShellCommand;
|
|
6952
|
+
exports.classifyShellCommand = classifyShellCommand;
|
|
5314
6953
|
exports.createLocalBackend = createLocalBackend;
|
|
5315
6954
|
exports.createMockBackend = createMockBackend;
|
|
5316
6955
|
exports.createOps = createOps;
|
|
@@ -5331,6 +6970,7 @@ exports.makeTodoWriteTool = makeTodoWriteTool;
|
|
|
5331
6970
|
exports.makeWriteFileTool = makeWriteFileTool;
|
|
5332
6971
|
exports.parsePatch = parsePatch;
|
|
5333
6972
|
exports.resolveInJail = resolveInJail;
|
|
6973
|
+
exports.resolveShell = resolveShell;
|
|
5334
6974
|
exports.tokenizeCommand = tokenizeCommand;
|
|
5335
6975
|
exports.workspace = workspace;
|
|
5336
6976
|
//# sourceMappingURL=index.cjs.map
|