@warlock.js/ai-workspace 5.43.0 → 5.45.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 +31 -0
- package/README.md +14 -1
- package/cjs/index.cjs +1847 -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 +88 -0
- package/esm/contracts/shell-approval.type.d.mts.map +1 -0
- package/esm/contracts/tool-io.type.d.mts +45 -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 +23 -1
- 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 +213 -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 +223 -0
- package/esm/output/spill.mjs.map +1 -0
- package/esm/policy/approval-helpers.mjs +22 -0
- package/esm/policy/approval-helpers.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 +420 -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 +63 -0
- package/esm/policy/shell-interpreter.d.mts.map +1 -0
- package/esm/policy/shell-interpreter.mjs +123 -0
- package/esm/policy/shell-interpreter.mjs.map +1 -0
- package/esm/policy/shell-parser.d.mts +33 -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 +19 -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 +27 -5
- package/esm/workspace.mjs.map +1 -1
- package/llms-full.txt +70 -17
- package/package.json +3 -3
- package/skills/use-a-workspace/SKILL.md +70 -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,826 @@ 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, edition) {
|
|
488
|
+
if (interpreter === "powershell") return edition === "desktop" ? "powershell5" : "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
|
+
/** The edition of the PowerShell executable `file`: `powershell.exe` is Windows PowerShell, anything else (`pwsh`) is PowerShell 7+. */
|
|
516
|
+
function editionOf(file) {
|
|
517
|
+
return /(^|[\\/])powershell(\.exe)?$/i.test(file) ? "desktop" : "core";
|
|
518
|
+
}
|
|
519
|
+
function powershellLabel(edition) {
|
|
520
|
+
if (edition === "desktop") return "Windows PowerShell 5.1 (powershell)";
|
|
521
|
+
return edition === "core" ? "PowerShell 7 (pwsh)" : "PowerShell";
|
|
522
|
+
}
|
|
523
|
+
/**
|
|
524
|
+
* Resolve a shell policy's `interpreter` (and `interpreterPath`) to the
|
|
525
|
+
* concrete shell that will run lines, and the grammar they are checked with.
|
|
526
|
+
*
|
|
527
|
+
* `"auto"` picks, on Windows, `pwsh` (PowerShell 7+), then Git Bash (found next
|
|
528
|
+
* to `git`), then Windows PowerShell 5.1, then `cmd.exe`; elsewhere `bash`,
|
|
529
|
+
* then `sh`. Windows PowerShell 5.1 gets its own dialect (`"powershell5"`)
|
|
530
|
+
* because it cannot run `&&` / `||`; `edition` and `version` say which one
|
|
531
|
+
* was found. An explicit interpreter whose executable
|
|
532
|
+
* is not on `PATH` keeps its name and has no `file`, so the spawn reports the
|
|
533
|
+
* missing shell instead of quietly running another one.
|
|
534
|
+
*
|
|
535
|
+
* @example
|
|
536
|
+
* resolveShell({ interpreter: "auto" }, { platform: "win32", pathEnv: "C:\\bin", isExecutable })
|
|
537
|
+
*/
|
|
538
|
+
function resolveShell(shell, host = currentShellHost()) {
|
|
539
|
+
const requested = shell?.interpreter ?? "none";
|
|
540
|
+
const explicit = shell?.interpreterPath;
|
|
541
|
+
if (requested === "none") return {
|
|
542
|
+
interpreter: "none",
|
|
543
|
+
dialect: "posix",
|
|
544
|
+
label: "none"
|
|
545
|
+
};
|
|
546
|
+
let interpreter;
|
|
547
|
+
let file = explicit;
|
|
548
|
+
if (requested === "auto") {
|
|
549
|
+
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";
|
|
550
|
+
else if (host.platform === "win32") {
|
|
551
|
+
const core = findOnPath(["pwsh"], host);
|
|
552
|
+
const gitBash = core === void 0 ? findGitBash(host) : void 0;
|
|
553
|
+
const desktop = core === void 0 && gitBash === void 0 ? findOnPath(["powershell"], host) : void 0;
|
|
554
|
+
const found = core ?? gitBash ?? desktop;
|
|
555
|
+
interpreter = core !== void 0 || desktop !== void 0 ? "powershell" : gitBash !== void 0 ? "bash" : "cmd";
|
|
556
|
+
file = found;
|
|
557
|
+
} else {
|
|
558
|
+
const bash = findOnPath(["bash"], host);
|
|
559
|
+
interpreter = bash !== void 0 ? "bash" : "sh";
|
|
560
|
+
file = bash;
|
|
561
|
+
}
|
|
562
|
+
} else interpreter = requested;
|
|
563
|
+
if (file === void 0) {
|
|
564
|
+
if (interpreter === "powershell") file = findOnPath(powershellNames(host), host);
|
|
565
|
+
else if (interpreter === "bash") file = findOnPath(["bash"], host) ?? (host.platform === "win32" ? findGitBash(host) : void 0);
|
|
566
|
+
else if (interpreter === "cmd" && host.platform === "win32") file = process.env.ComSpec ?? "cmd.exe";
|
|
567
|
+
}
|
|
568
|
+
const edition = interpreter === "powershell" && file !== void 0 ? editionOf(file) : void 0;
|
|
569
|
+
const version = edition === "desktop" ? "5.1" : edition === "core" && file !== void 0 ? host.powershellVersion?.(file) : void 0;
|
|
570
|
+
const labels = {
|
|
571
|
+
none: "none",
|
|
572
|
+
sh: host.platform === "win32" ? "cmd.exe" : "sh",
|
|
573
|
+
bash: "bash",
|
|
574
|
+
cmd: "cmd.exe",
|
|
575
|
+
powershell: powershellLabel(edition)
|
|
576
|
+
};
|
|
577
|
+
return {
|
|
578
|
+
interpreter,
|
|
579
|
+
dialect: dialectOf(interpreter, host.platform, edition),
|
|
580
|
+
...edition !== void 0 ? { edition } : {},
|
|
581
|
+
...version !== void 0 ? { version } : {},
|
|
582
|
+
...file !== void 0 ? { file } : {},
|
|
583
|
+
label: labels[interpreter]
|
|
584
|
+
};
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
//#endregion
|
|
588
|
+
//#region ../../packages/ai-workspace/src/policy/powershell-parser.ts
|
|
589
|
+
/**
|
|
590
|
+
* Parser for the PowerShell dialect (`powershell -Command` / `pwsh -Command`).
|
|
591
|
+
*
|
|
592
|
+
* Like the other dialects it is deliberately conservative: it finds every
|
|
593
|
+
* simple command a line would run and every redirection target, and refuses
|
|
594
|
+
* what it cannot read with confidence. Supported: `;` and line breaks, `&&`,
|
|
595
|
+
* `||`, `|`, `$( )` subexpressions (their commands are checked too), the call
|
|
596
|
+
* operator `&` in front of a command, single and double
|
|
597
|
+
* quotes (including the typographic quotes PowerShell treats as quotes),
|
|
598
|
+
* backtick escapes, `$name` / `${name}` / `$env:NAME` variables (dynamic
|
|
599
|
+
* words), and the redirections `>`, `>>`, `2>`, `2>>`, `*>`, `2>&1`.
|
|
600
|
+
*
|
|
601
|
+
* Refused: script blocks and braces, bare parentheses,
|
|
602
|
+
* `@( )` / `@{ }` / splatting / here-strings, the stop-parsing token `--%`,
|
|
603
|
+
* `<` redirection, comments, the comma operator, compound-statement
|
|
604
|
+
* keywords (`if`, `foreach`, `function`, `try`, …), dot-sourcing, a `&` that
|
|
605
|
+
* is not a call operator (background jobs), and type literals.
|
|
606
|
+
*/
|
|
607
|
+
/** Statement keywords that start a compound command; refused in command position. */
|
|
608
|
+
const POWERSHELL_KEYWORDS = /* @__PURE__ */ new Set([
|
|
609
|
+
"if",
|
|
610
|
+
"elseif",
|
|
611
|
+
"else",
|
|
612
|
+
"switch",
|
|
613
|
+
"foreach",
|
|
614
|
+
"for",
|
|
615
|
+
"while",
|
|
616
|
+
"do",
|
|
617
|
+
"until",
|
|
618
|
+
"function",
|
|
619
|
+
"filter",
|
|
620
|
+
"workflow",
|
|
621
|
+
"param",
|
|
622
|
+
"begin",
|
|
623
|
+
"process",
|
|
624
|
+
"end",
|
|
625
|
+
"try",
|
|
626
|
+
"catch",
|
|
627
|
+
"finally",
|
|
628
|
+
"trap",
|
|
629
|
+
"data",
|
|
630
|
+
"class",
|
|
631
|
+
"enum",
|
|
632
|
+
"using",
|
|
633
|
+
"return",
|
|
634
|
+
"break",
|
|
635
|
+
"continue",
|
|
636
|
+
"throw",
|
|
637
|
+
"parallel",
|
|
638
|
+
"sequence",
|
|
639
|
+
"inlinescript"
|
|
640
|
+
]);
|
|
641
|
+
/** Characters PowerShell reads as a single quote. */
|
|
642
|
+
const SINGLE_QUOTES = "'‘’‚‛";
|
|
643
|
+
/** Characters PowerShell reads as a double quote. */
|
|
644
|
+
const DOUBLE_QUOTES = "\"“”„";
|
|
645
|
+
/** Variable-name characters after `$`. */
|
|
646
|
+
const VARIABLE_CHAR = /[A-Za-z0-9_:?^$]/;
|
|
647
|
+
/** Whether `char` (never the empty end-of-input string) is one of `set`. */
|
|
648
|
+
function inSet(set, char) {
|
|
649
|
+
return char !== "" && set.includes(char);
|
|
650
|
+
}
|
|
651
|
+
var PowerShellRefusal = class extends Error {
|
|
652
|
+
constructor(reason, message) {
|
|
653
|
+
super(message);
|
|
654
|
+
this.reason = reason;
|
|
655
|
+
}
|
|
656
|
+
};
|
|
657
|
+
function unsupported$1(message) {
|
|
658
|
+
return new PowerShellRefusal("unsupported-syntax", message);
|
|
659
|
+
}
|
|
660
|
+
var PowerShellParser = class PowerShellParser {
|
|
661
|
+
constructor(src, commands = [], depth = 0, chaining = true) {
|
|
662
|
+
this.src = src;
|
|
663
|
+
this.commands = commands;
|
|
664
|
+
this.depth = depth;
|
|
665
|
+
this.chaining = chaining;
|
|
666
|
+
this.pos = 0;
|
|
667
|
+
}
|
|
668
|
+
atEnd() {
|
|
669
|
+
return this.pos >= this.src.length;
|
|
670
|
+
}
|
|
671
|
+
peek(offset = 0) {
|
|
672
|
+
return this.src.charAt(this.pos + offset);
|
|
673
|
+
}
|
|
674
|
+
skipBlanks() {
|
|
675
|
+
while (this.peek() === " " || this.peek() === " ") this.pos++;
|
|
676
|
+
}
|
|
677
|
+
atLineBreak() {
|
|
678
|
+
return this.peek() === "\n" || this.peek() === "\r";
|
|
679
|
+
}
|
|
680
|
+
parseProgram(segments) {
|
|
681
|
+
while (true) {
|
|
682
|
+
this.skipBlanks();
|
|
683
|
+
if (this.atEnd()) {
|
|
684
|
+
if (segments.length === 0) throw unsupported$1("The command is empty.");
|
|
685
|
+
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.");
|
|
686
|
+
return;
|
|
687
|
+
}
|
|
688
|
+
const start = this.pos;
|
|
689
|
+
this.parsePipeline();
|
|
690
|
+
const end = this.pos;
|
|
691
|
+
this.skipBlanks();
|
|
692
|
+
const operator = this.readListOperator();
|
|
693
|
+
segments.push({
|
|
694
|
+
source: this.src.slice(start, end).trim(),
|
|
695
|
+
...operator !== null ? { operator } : {}
|
|
696
|
+
});
|
|
697
|
+
if (operator === null) {
|
|
698
|
+
if (!this.atEnd()) throw unsupported$1(`Unexpected \`${this.peek()}\`.`);
|
|
699
|
+
return;
|
|
700
|
+
}
|
|
701
|
+
if (operator === ";" || operator === "\n") while (this.peek() === " " || this.peek() === " " || this.atLineBreak()) this.pos++;
|
|
702
|
+
else this.skipBlanks();
|
|
703
|
+
}
|
|
704
|
+
}
|
|
705
|
+
readListOperator() {
|
|
706
|
+
const char = this.peek();
|
|
707
|
+
if (!this.chaining && (char === "&" && this.peek(1) === "&" || char === "|" && this.peek(1) === "|")) throw unsupported$1(`Windows PowerShell 5.1 cannot run ${char}${char}; use ; or run each command in its own call instead.`);
|
|
708
|
+
if (char === "&" && this.peek(1) === "&") {
|
|
709
|
+
this.pos += 2;
|
|
710
|
+
return "&&";
|
|
711
|
+
}
|
|
712
|
+
if (char === "|" && this.peek(1) === "|") {
|
|
713
|
+
this.pos += 2;
|
|
714
|
+
return "||";
|
|
715
|
+
}
|
|
716
|
+
if (char === ";") {
|
|
717
|
+
this.pos++;
|
|
718
|
+
return ";";
|
|
719
|
+
}
|
|
720
|
+
if (this.atLineBreak()) {
|
|
721
|
+
while (this.atLineBreak()) this.pos++;
|
|
722
|
+
return "\n";
|
|
723
|
+
}
|
|
724
|
+
return null;
|
|
725
|
+
}
|
|
726
|
+
parsePipeline() {
|
|
727
|
+
this.parseCommand();
|
|
728
|
+
while (this.peek() === "|" && this.peek(1) !== "|") {
|
|
729
|
+
this.pos++;
|
|
730
|
+
this.skipBlanks();
|
|
731
|
+
if (this.atEnd() || this.atLineBreak() || this.peek() === ";") throw unsupported$1("The line ends with `|` but no command follows it.");
|
|
732
|
+
this.parseCommand();
|
|
733
|
+
}
|
|
734
|
+
}
|
|
735
|
+
atRedirectStart() {
|
|
736
|
+
const char = this.peek();
|
|
737
|
+
if (char === ">") return true;
|
|
738
|
+
return /[1-6*]/.test(char) && this.peek(1) === ">";
|
|
739
|
+
}
|
|
740
|
+
parseCommand() {
|
|
741
|
+
const start = this.pos;
|
|
742
|
+
const command = {
|
|
743
|
+
assignments: [],
|
|
744
|
+
words: [],
|
|
745
|
+
redirects: [],
|
|
746
|
+
source: ""
|
|
747
|
+
};
|
|
748
|
+
let callOperator = false;
|
|
749
|
+
if (this.peek() === "&" && this.peek(1) !== "&") {
|
|
750
|
+
callOperator = true;
|
|
751
|
+
this.pos++;
|
|
752
|
+
this.skipBlanks();
|
|
753
|
+
}
|
|
754
|
+
while (true) {
|
|
755
|
+
this.skipBlanks();
|
|
756
|
+
if (this.atEnd() || this.atLineBreak() || this.peek() === ";" || this.peek() === "|") break;
|
|
757
|
+
if (this.peek() === "&") {
|
|
758
|
+
if (this.peek(1) === "&") break;
|
|
759
|
+
throw unsupported$1("`&` is only supported as the call operator in front of a command (not as a background job).");
|
|
760
|
+
}
|
|
761
|
+
if (this.peek() === "<") throw unsupported$1("`<` is not supported in PowerShell command lines.");
|
|
762
|
+
if (this.atRedirectStart()) {
|
|
763
|
+
command.redirects.push(this.parseRedirect());
|
|
764
|
+
continue;
|
|
765
|
+
}
|
|
766
|
+
const word = this.parseWord();
|
|
767
|
+
command.words.push(word);
|
|
768
|
+
if (command.words.length === 1) this.checkCommandWord(word);
|
|
769
|
+
}
|
|
770
|
+
if (command.words.length === 0 && (callOperator || command.redirects.length === 0)) throw unsupported$1("The line has an empty command (two operators in a row?).");
|
|
771
|
+
command.source = this.src.slice(start, this.pos).trim();
|
|
772
|
+
this.commands.push(command);
|
|
773
|
+
}
|
|
774
|
+
checkCommandWord(word) {
|
|
775
|
+
if (word.raw.startsWith("[")) throw unsupported$1(`\`${word.raw}\` (a type literal) is not supported.`);
|
|
776
|
+
if (!word.quoted && word.value === ".") throw unsupported$1("Dot-sourcing (`. file`) runs a script in the current scope and is not supported.");
|
|
777
|
+
if (!word.quoted && POWERSHELL_KEYWORDS.has(word.value.toLowerCase())) throw unsupported$1(`\`${word.value}\` starts a PowerShell statement, which is not supported.`);
|
|
778
|
+
}
|
|
779
|
+
parseRedirect() {
|
|
780
|
+
let fd;
|
|
781
|
+
if (this.peek() === "*") this.pos++;
|
|
782
|
+
else if (/[1-6]/.test(this.peek())) {
|
|
783
|
+
fd = Number(this.peek());
|
|
784
|
+
this.pos++;
|
|
785
|
+
}
|
|
786
|
+
let op = ">";
|
|
787
|
+
if (this.peek(1) === ">") op = ">>";
|
|
788
|
+
this.pos += op.length;
|
|
789
|
+
if (op === ">" && this.peek() === "&") {
|
|
790
|
+
if (/[1-6]/.test(this.peek(1))) {
|
|
791
|
+
const target = {
|
|
792
|
+
raw: this.peek(1),
|
|
793
|
+
value: this.peek(1),
|
|
794
|
+
dynamic: false,
|
|
795
|
+
quoted: false
|
|
796
|
+
};
|
|
797
|
+
this.pos += 2;
|
|
798
|
+
return {
|
|
799
|
+
op: ">&",
|
|
800
|
+
...fd !== void 0 ? { fd } : {},
|
|
801
|
+
target,
|
|
802
|
+
type: "dup"
|
|
803
|
+
};
|
|
804
|
+
}
|
|
805
|
+
throw unsupported$1("Only `>&1`-style stream merges (`2>&1`) are supported.");
|
|
806
|
+
}
|
|
807
|
+
this.skipBlanks();
|
|
808
|
+
if (this.atEnd() || this.atLineBreak() || /[;&|<>]/.test(this.peek())) throw unsupported$1(`The redirection \`${op}\` has no target.`);
|
|
809
|
+
const target = this.parseWord();
|
|
810
|
+
return {
|
|
811
|
+
op,
|
|
812
|
+
...fd !== void 0 ? { fd } : {},
|
|
813
|
+
target,
|
|
814
|
+
type: "write"
|
|
815
|
+
};
|
|
816
|
+
}
|
|
817
|
+
parseWord() {
|
|
818
|
+
const start = this.pos;
|
|
819
|
+
let value = "";
|
|
820
|
+
let dynamic = false;
|
|
821
|
+
let quoted = false;
|
|
822
|
+
if (this.src.startsWith("--%", this.pos) && /[\s;|&]|^$/.test(this.src.charAt(this.pos + 3))) throw unsupported$1("The stop-parsing token `--%` is not supported.");
|
|
823
|
+
while (!this.atEnd()) {
|
|
824
|
+
const char = this.peek();
|
|
825
|
+
if (char === " " || char === " " || char === "\n" || char === "\r" || char === ";" || char === "|" || char === "&" || char === ">" || char === "<") break;
|
|
826
|
+
if (inSet(SINGLE_QUOTES, char)) {
|
|
827
|
+
this.pos++;
|
|
828
|
+
quoted = true;
|
|
829
|
+
while (true) {
|
|
830
|
+
if (this.atEnd()) throw new PowerShellRefusal("unbalanced-quotes", "A single quote is never closed.");
|
|
831
|
+
const inner = this.peek();
|
|
832
|
+
if (inSet(SINGLE_QUOTES, inner)) {
|
|
833
|
+
if (inSet(SINGLE_QUOTES, this.peek(1))) {
|
|
834
|
+
value += this.peek(1);
|
|
835
|
+
this.pos += 2;
|
|
836
|
+
continue;
|
|
837
|
+
}
|
|
838
|
+
this.pos++;
|
|
839
|
+
break;
|
|
840
|
+
}
|
|
841
|
+
value += inner;
|
|
842
|
+
this.pos++;
|
|
843
|
+
}
|
|
844
|
+
continue;
|
|
845
|
+
}
|
|
846
|
+
if (inSet(DOUBLE_QUOTES, char)) {
|
|
847
|
+
this.pos++;
|
|
848
|
+
quoted = true;
|
|
849
|
+
while (true) {
|
|
850
|
+
if (this.atEnd()) throw new PowerShellRefusal("unbalanced-quotes", "A double quote is never closed.");
|
|
851
|
+
const inner = this.peek();
|
|
852
|
+
if (inSet(DOUBLE_QUOTES, inner)) {
|
|
853
|
+
if (inSet(DOUBLE_QUOTES, this.peek(1))) {
|
|
854
|
+
value += this.peek(1);
|
|
855
|
+
this.pos += 2;
|
|
856
|
+
continue;
|
|
857
|
+
}
|
|
858
|
+
this.pos++;
|
|
859
|
+
break;
|
|
860
|
+
}
|
|
861
|
+
if (inner === "`") {
|
|
862
|
+
const next = this.peek(1);
|
|
863
|
+
if (next !== "" && (inSet(DOUBLE_QUOTES, next) || next === "`" || next === "$")) {
|
|
864
|
+
value += next;
|
|
865
|
+
this.pos += 2;
|
|
866
|
+
continue;
|
|
867
|
+
}
|
|
868
|
+
throw unsupported$1("Only `\"`, `` ` `` and `$` can follow a backtick inside double quotes.");
|
|
869
|
+
}
|
|
870
|
+
if (inner === "$") {
|
|
871
|
+
const read = this.readVariable();
|
|
872
|
+
value += read;
|
|
873
|
+
dynamic = true;
|
|
874
|
+
continue;
|
|
875
|
+
}
|
|
876
|
+
value += inner;
|
|
877
|
+
this.pos++;
|
|
878
|
+
}
|
|
879
|
+
continue;
|
|
880
|
+
}
|
|
881
|
+
if (char === "`") {
|
|
882
|
+
const next = this.peek(1);
|
|
883
|
+
if (next === "" || next === "\n" || next === "\r") throw unsupported$1("A backtick at the end of a line (line continuation) is not supported.");
|
|
884
|
+
value += next;
|
|
885
|
+
quoted = true;
|
|
886
|
+
this.pos += 2;
|
|
887
|
+
continue;
|
|
888
|
+
}
|
|
889
|
+
if (char === "$") {
|
|
890
|
+
const wasLiteral = this.src.charAt(this.pos + 1);
|
|
891
|
+
if (wasLiteral === "" || !(wasLiteral === "{" || wasLiteral === "(" || VARIABLE_CHAR.test(wasLiteral))) {
|
|
892
|
+
value += "$";
|
|
893
|
+
this.pos++;
|
|
894
|
+
continue;
|
|
895
|
+
}
|
|
896
|
+
value += this.readVariable();
|
|
897
|
+
dynamic = true;
|
|
898
|
+
continue;
|
|
899
|
+
}
|
|
900
|
+
if (char === "@" && this.pos === start) {
|
|
901
|
+
const next = this.peek(1);
|
|
902
|
+
if (next === "(" || next === "{" || next === "'" || next === "\"" || /[A-Za-z_$]/.test(next)) throw unsupported$1("Arrays (`@( )`), hashtables, splatting (`@name`) and here-strings are not supported.");
|
|
903
|
+
}
|
|
904
|
+
if (char === "{" || char === "}") throw unsupported$1("Script blocks and braces are not supported.");
|
|
905
|
+
if (char === "(" || char === ")") throw unsupported$1("Parentheses (grouping and `$( )` subexpressions) are not supported; quote the text.");
|
|
906
|
+
if (char === ",") throw unsupported$1("The comma operator builds an array and is not supported; quote the text.");
|
|
907
|
+
if (char === "#" && this.pos === start) throw unsupported$1("Comments are not supported in a command line.");
|
|
908
|
+
if (char === "*" || char === "?" || char === "[" || char === "~" && this.pos === start) dynamic = true;
|
|
909
|
+
value += char;
|
|
910
|
+
this.pos++;
|
|
911
|
+
}
|
|
912
|
+
return {
|
|
913
|
+
raw: this.src.slice(start, this.pos),
|
|
914
|
+
value,
|
|
915
|
+
dynamic,
|
|
916
|
+
quoted
|
|
917
|
+
};
|
|
918
|
+
}
|
|
919
|
+
/**
|
|
920
|
+
* Parse a `$( … )` subexpression: its commands run, so they are collected
|
|
921
|
+
* (before the command that contains it) and checked like any other.
|
|
922
|
+
*/
|
|
923
|
+
parseSubexpression() {
|
|
924
|
+
if (this.depth >= 8) throw unsupported$1("`$( )` subexpressions are nested too deeply.");
|
|
925
|
+
const open = this.pos;
|
|
926
|
+
let level = 0;
|
|
927
|
+
let index = open;
|
|
928
|
+
for (; index < this.src.length; index++) {
|
|
929
|
+
const char = this.src.charAt(index);
|
|
930
|
+
if (inSet(SINGLE_QUOTES, char) || inSet(DOUBLE_QUOTES, char)) {
|
|
931
|
+
const set = inSet(SINGLE_QUOTES, char) ? SINGLE_QUOTES : DOUBLE_QUOTES;
|
|
932
|
+
index++;
|
|
933
|
+
while (index < this.src.length) {
|
|
934
|
+
const inner = this.src.charAt(index);
|
|
935
|
+
if (inner === "`" && set === DOUBLE_QUOTES) {
|
|
936
|
+
index += 2;
|
|
937
|
+
continue;
|
|
938
|
+
}
|
|
939
|
+
if (inSet(set, inner)) {
|
|
940
|
+
if (inSet(set, this.src.charAt(index + 1))) {
|
|
941
|
+
index += 2;
|
|
942
|
+
continue;
|
|
943
|
+
}
|
|
944
|
+
break;
|
|
945
|
+
}
|
|
946
|
+
index++;
|
|
947
|
+
}
|
|
948
|
+
continue;
|
|
949
|
+
}
|
|
950
|
+
if (char === "`") {
|
|
951
|
+
index++;
|
|
952
|
+
continue;
|
|
953
|
+
}
|
|
954
|
+
if (char === "(") level++;
|
|
955
|
+
else if (char === ")") {
|
|
956
|
+
level--;
|
|
957
|
+
if (level === 0) break;
|
|
958
|
+
}
|
|
959
|
+
}
|
|
960
|
+
if (level !== 0) throw new PowerShellRefusal("unbalanced-quotes", "A `$(` subexpression is never closed.");
|
|
961
|
+
const inner = this.src.slice(open + 1, index);
|
|
962
|
+
new PowerShellParser(inner, this.commands, this.depth + 1, this.chaining).parseProgram([]);
|
|
963
|
+
this.pos = index + 1;
|
|
964
|
+
}
|
|
965
|
+
/** Read `$name`, `${name}` or `$env:NAME` at the cursor; a `$(` is refused. */
|
|
966
|
+
readVariable() {
|
|
967
|
+
const begin = this.pos;
|
|
968
|
+
this.pos++;
|
|
969
|
+
if (this.peek() === "(") {
|
|
970
|
+
this.parseSubexpression();
|
|
971
|
+
return this.src.slice(begin, this.pos);
|
|
972
|
+
}
|
|
973
|
+
if (this.peek() === "{") {
|
|
974
|
+
const close = this.src.indexOf("}", this.pos);
|
|
975
|
+
if (close === -1) throw new PowerShellRefusal("unbalanced-quotes", "A `${` variable name is never closed.");
|
|
976
|
+
this.pos = close + 1;
|
|
977
|
+
return this.src.slice(begin, this.pos);
|
|
978
|
+
}
|
|
979
|
+
while (!this.atEnd() && VARIABLE_CHAR.test(this.peek())) this.pos++;
|
|
980
|
+
return this.src.slice(begin, this.pos);
|
|
981
|
+
}
|
|
982
|
+
};
|
|
983
|
+
/** Parse a PowerShell command line for the shell policy. Never throws. */
|
|
984
|
+
function parsePowerShellLine(line, options) {
|
|
985
|
+
const segments = [];
|
|
986
|
+
const parser = new PowerShellParser(line, [], 0, options?.chaining !== false);
|
|
987
|
+
try {
|
|
988
|
+
parser.parseProgram(segments);
|
|
989
|
+
return {
|
|
990
|
+
ok: true,
|
|
991
|
+
commands: parser.commands,
|
|
992
|
+
segments,
|
|
993
|
+
background: false
|
|
994
|
+
};
|
|
995
|
+
} catch (error) {
|
|
996
|
+
if (error instanceof PowerShellRefusal) return {
|
|
997
|
+
ok: false,
|
|
998
|
+
reason: error.reason,
|
|
999
|
+
message: error.message
|
|
1000
|
+
};
|
|
1001
|
+
throw error;
|
|
1002
|
+
}
|
|
1003
|
+
}
|
|
1004
|
+
|
|
183
1005
|
//#endregion
|
|
184
1006
|
//#region ../../packages/ai-workspace/src/policy/shell-parser.ts
|
|
185
1007
|
/** Deepest nesting of subshells and substitutions the parser accepts. */
|
|
@@ -947,6 +1769,7 @@ var CmdParser = class {
|
|
|
947
1769
|
*/
|
|
948
1770
|
function parseShellLine(line, dialect) {
|
|
949
1771
|
const segments = [];
|
|
1772
|
+
if (dialect === "powershell" || dialect === "powershell5") return parsePowerShellLine(line, { chaining: dialect === "powershell" });
|
|
950
1773
|
try {
|
|
951
1774
|
if (dialect === "cmd") {
|
|
952
1775
|
const commands = [];
|
|
@@ -1159,6 +1982,26 @@ const OPAQUE_RUNNERS = /* @__PURE__ */ new Set([
|
|
|
1159
1982
|
"stdbuf",
|
|
1160
1983
|
"flock"
|
|
1161
1984
|
]);
|
|
1985
|
+
/**
|
|
1986
|
+
* PowerShell cmdlets and aliases that run code or commands the policy cannot
|
|
1987
|
+
* see, or change what later names mean (lowercase).
|
|
1988
|
+
*/
|
|
1989
|
+
const POWERSHELL_OPAQUE = /* @__PURE__ */ new Set([
|
|
1990
|
+
"iex",
|
|
1991
|
+
"invoke-expression",
|
|
1992
|
+
"invoke-command",
|
|
1993
|
+
"icm",
|
|
1994
|
+
"start-process",
|
|
1995
|
+
"saps",
|
|
1996
|
+
"start",
|
|
1997
|
+
"add-type",
|
|
1998
|
+
"set-alias",
|
|
1999
|
+
"sal",
|
|
2000
|
+
"new-alias",
|
|
2001
|
+
"nal",
|
|
2002
|
+
"invoke-item",
|
|
2003
|
+
"ii"
|
|
2004
|
+
]);
|
|
1162
2005
|
/** POSIX shell builtins that evaluate code or change how later names resolve. */
|
|
1163
2006
|
const SHELL_OPAQUE_BUILTINS = /* @__PURE__ */ new Set([
|
|
1164
2007
|
"eval",
|
|
@@ -1251,7 +2094,7 @@ const MAX_WRAPPER_DEPTH = 8;
|
|
|
1251
2094
|
const MAX_CD_CANDIDATES = 32;
|
|
1252
2095
|
/** Longest command shown verbatim in a refusal before it is abbreviated. */
|
|
1253
2096
|
const MAX_SHOWN_COMMAND = 48;
|
|
1254
|
-
/** The dialect
|
|
2097
|
+
/** The dialect `interpreter: "sh"` parses on this platform. */
|
|
1255
2098
|
function shellDialect() {
|
|
1256
2099
|
return process.platform === "win32" ? "cmd" : "posix";
|
|
1257
2100
|
}
|
|
@@ -1262,7 +2105,7 @@ function shellDialect() {
|
|
|
1262
2105
|
* stripped). The cmd dialect splits Windows paths too.
|
|
1263
2106
|
*/
|
|
1264
2107
|
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, "");
|
|
2108
|
+
return (dialect === "cmd" || dialect === "powershell" || dialect === "powershell5" ? node_path.default.win32.basename(firstToken) : node_path.default.basename(firstToken)).replace(dialect === "powershell" || dialect === "powershell5" ? /\.(exe|cmd|bat|com|ps1)$/i : /\.(exe|cmd|bat|com)$/i, "");
|
|
1266
2109
|
}
|
|
1267
2110
|
/** Show a command in a refusal, abbreviated at a word boundary when long. */
|
|
1268
2111
|
function show(source) {
|
|
@@ -1306,25 +2149,75 @@ var Refusal = class extends Error {
|
|
|
1306
2149
|
};
|
|
1307
2150
|
/** Per-line checker state. */
|
|
1308
2151
|
var LineChecker = class {
|
|
1309
|
-
constructor(policy, shell, line, dialect) {
|
|
2152
|
+
constructor(policy, shell, line, dialect, interpreter, remembered = []) {
|
|
1310
2153
|
this.policy = policy;
|
|
1311
2154
|
this.shell = shell;
|
|
1312
2155
|
this.line = line;
|
|
1313
2156
|
this.dialect = dialect;
|
|
2157
|
+
this.interpreter = interpreter;
|
|
1314
2158
|
this.commands = [];
|
|
1315
2159
|
this.paths = [];
|
|
2160
|
+
this.asks = [];
|
|
1316
2161
|
this.deny = new Set((shell.deny ?? []).map((name) => name.toLowerCase()));
|
|
1317
2162
|
this.allow = shell.allow ?? [];
|
|
1318
2163
|
this.patterns = (shell.denyPatterns ?? []).map(compilePattern);
|
|
1319
|
-
this.
|
|
1320
|
-
|
|
2164
|
+
this.rules = (shell.rules ?? []).map((rule) => ({
|
|
2165
|
+
rule,
|
|
2166
|
+
pattern: compilePattern(rule.match)
|
|
2167
|
+
}));
|
|
2168
|
+
this.remembered = remembered.map(compilePattern);
|
|
2169
|
+
this.askMode = shell.onAsk !== void 0;
|
|
2170
|
+
this.hasCommandRules = this.deny.size > 0 || this.patterns.length > 0 || this.rules.length > 0 || shell.allowCommand !== void 0;
|
|
1321
2171
|
}
|
|
1322
2172
|
/** Whether argument hooks (`denyPatterns` / `allowCommand`) need literal argv. */
|
|
1323
2173
|
get hasArgumentHooks() {
|
|
1324
2174
|
return this.patterns.length > 0 || this.shell.allowCommand !== void 0;
|
|
1325
2175
|
}
|
|
1326
2176
|
get shellMode() {
|
|
1327
|
-
return this.interpreter
|
|
2177
|
+
return this.interpreter !== "none";
|
|
2178
|
+
}
|
|
2179
|
+
/** The last rule that matches `argv` (later rules win). */
|
|
2180
|
+
matchRule(argv) {
|
|
2181
|
+
for (let index = this.rules.length - 1; index >= 0; index--) {
|
|
2182
|
+
const compiled = this.rules[index];
|
|
2183
|
+
if (compiled.pattern.test(argv, this.dialect)) return compiled.rule;
|
|
2184
|
+
}
|
|
2185
|
+
}
|
|
2186
|
+
isRemembered(argv) {
|
|
2187
|
+
return this.remembered.some((pattern) => pattern.test(argv, this.dialect));
|
|
2188
|
+
}
|
|
2189
|
+
/** Record a command that needs a decision (once per argv and reason). */
|
|
2190
|
+
addAsk(argv, reason, rule) {
|
|
2191
|
+
const key = `${reason}\u0000${argv.join("\0")}`;
|
|
2192
|
+
if (this.asks.some((ask) => `${ask.reason}\u0000${ask.argv.join("\0")}` === key)) return;
|
|
2193
|
+
this.asks.push({
|
|
2194
|
+
argv: [...argv],
|
|
2195
|
+
reason,
|
|
2196
|
+
...rule !== void 0 ? { rule } : {}
|
|
2197
|
+
});
|
|
2198
|
+
}
|
|
2199
|
+
/** Refuse when the matching rule denies. */
|
|
2200
|
+
assertNotDenied(argv, rule) {
|
|
2201
|
+
if (rule?.effect !== "deny") return;
|
|
2202
|
+
const explanation = rule.reason !== void 0 && rule.reason.trim() !== "" ? ` ${rule.reason.trim()}` : "";
|
|
2203
|
+
throw new Refusal("denied-pattern", `${show(argv.join(" "))} is denied by a workspace shell rule.${explanation}`, executableBasename(argv[0] ?? "", this.dialect));
|
|
2204
|
+
}
|
|
2205
|
+
/**
|
|
2206
|
+
* Check the canonical form of a PowerShell alias (`Remove-Item -Recurse
|
|
2207
|
+
* -Force x` as `rm -rf x`): deny, `denyPatterns`, `allowCommand` and the
|
|
2208
|
+
* rules, but not the `allow` list (which names the spelling that was written).
|
|
2209
|
+
*/
|
|
2210
|
+
checkCanonical(argv) {
|
|
2211
|
+
const key = executableBasename(argv[0] ?? "", this.dialect).toLowerCase();
|
|
2212
|
+
if (this.deny.has(key)) throw new Refusal("denied-executable", `\`${key}\` is denied by the workspace shell policy (in ${show(argv.join(" "))}).`, key);
|
|
2213
|
+
this.checkHooks(argv, true);
|
|
2214
|
+
const rule = this.matchRule(argv);
|
|
2215
|
+
this.assertNotDenied(argv, rule);
|
|
2216
|
+
if (rule?.effect === "ask" && !this.isRemembered(argv)) this.addAsk(argv, "rule", rule);
|
|
2217
|
+
}
|
|
2218
|
+
/** Refuse a denied executable (used for `cd` spellings). */
|
|
2219
|
+
assertExecutableNotDenied(key, argv) {
|
|
2220
|
+
if (this.deny.has(key)) throw new Refusal("denied-executable", `\`${key}\` is denied by the workspace shell policy (in ${show(argv.join(" "))}).`, key);
|
|
1328
2221
|
}
|
|
1329
2222
|
/** Run `denyPatterns` and `allowCommand` on one argv. */
|
|
1330
2223
|
checkHooks(argv, nested) {
|
|
@@ -1372,15 +2265,34 @@ var LineChecker = class {
|
|
|
1372
2265
|
throw new Refusal("not-allowed", `\`${executable}\` is not in the workspace shell allowlist. Allowed commands: ${listed}${this.allow.length > 20 ? ", …" : ""}.`, executable);
|
|
1373
2266
|
}
|
|
1374
2267
|
this.checkHooks(argv, depth > 0);
|
|
2268
|
+
const rule = this.matchRule(argv);
|
|
2269
|
+
this.assertNotDenied(argv, rule);
|
|
1375
2270
|
this.commands.push([...argv]);
|
|
1376
|
-
|
|
2271
|
+
const remembered = this.isRemembered(argv);
|
|
2272
|
+
const approved = rule?.effect === "allow" || remembered || this.explicitlyAllowed(executable);
|
|
2273
|
+
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" || this.dialect === "powershell5") && POWERSHELL_OPAQUE.has(key)) {
|
|
2274
|
+
if (approved) return;
|
|
2275
|
+
if (this.askMode || rule?.effect === "ask") {
|
|
2276
|
+
this.addAsk(argv, "nested", rule);
|
|
2277
|
+
return;
|
|
2278
|
+
}
|
|
1377
2279
|
this.checkOpaque(executable, argv);
|
|
1378
2280
|
return;
|
|
1379
2281
|
}
|
|
1380
2282
|
if (this.shellMode && this.dialect === "posix" && ARITHMETIC_SENSITIVE_BUILTINS.has(key)) this.checkArithmeticSensitive(executable, argv, dynamic);
|
|
1381
2283
|
const ranges = this.unwrap(key, argv);
|
|
2284
|
+
if (!approved && ranges === void 0) {
|
|
2285
|
+
if (rule?.effect === "ask") this.addAsk(argv, "rule", rule);
|
|
2286
|
+
else if (rule === void 0 && this.askMode && this.allow.length === 0) this.addAsk(argv, "rule");
|
|
2287
|
+
} else if (!approved && rule?.effect === "ask") this.addAsk(argv, "rule", rule);
|
|
1382
2288
|
if (ranges === void 0) return;
|
|
1383
|
-
if (ranges === null || depth >= MAX_WRAPPER_DEPTH)
|
|
2289
|
+
if (ranges === null || depth >= MAX_WRAPPER_DEPTH) {
|
|
2290
|
+
if (this.askMode) {
|
|
2291
|
+
this.addAsk(argv, "nested", rule);
|
|
2292
|
+
return;
|
|
2293
|
+
}
|
|
2294
|
+
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);
|
|
2295
|
+
}
|
|
1384
2296
|
for (const [start, end] of ranges) this.checkArgv(argv.slice(start, end), dynamic.slice(start, end), depth + 1);
|
|
1385
2297
|
}
|
|
1386
2298
|
/**
|
|
@@ -1446,6 +2358,7 @@ var LineChecker = class {
|
|
|
1446
2358
|
}
|
|
1447
2359
|
/** Check a redirection target from every possible cwd. */
|
|
1448
2360
|
checkRedirectTarget(word, access, cwds) {
|
|
2361
|
+
if ((this.dialect === "powershell" || this.dialect === "powershell5") && !word.quoted && word.value.toLowerCase() === "$null") return;
|
|
1449
2362
|
if (word.dynamic) throw new Refusal("unsupported-syntax", `The redirection target \`${word.raw}\` must be a literal path (no variables, globs or \`~\`).`);
|
|
1450
2363
|
const value = word.value;
|
|
1451
2364
|
if (this.dialect === "posix" && DEVICE_TARGETS.has(value) || this.dialect === "cmd" && CMD_DEVICE_TARGETS.has(value.toLowerCase())) return;
|
|
@@ -1638,11 +2551,17 @@ function argvModeRefusal(command) {
|
|
|
1638
2551
|
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
2552
|
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
2553
|
}
|
|
1641
|
-
/** Check a shell-mode
|
|
2554
|
+
/** Check a shell-mode line. */
|
|
1642
2555
|
function checkShellLine(checker, policy, command, dialect) {
|
|
1643
2556
|
if (/^[-+]/.test(command)) throw new Refusal("unsupported-syntax", "A command line may not start with `-` or `+`.");
|
|
1644
2557
|
const parsed = parseShellLine(command, dialect);
|
|
1645
|
-
if (!parsed.ok)
|
|
2558
|
+
if (!parsed.ok) {
|
|
2559
|
+
if (checker.askMode) {
|
|
2560
|
+
checker.addAsk([command], "unparsed");
|
|
2561
|
+
return;
|
|
2562
|
+
}
|
|
2563
|
+
throw new Refusal(parsed.reason, `${parsed.message} The workspace shell policy only runs lines it can check.`);
|
|
2564
|
+
}
|
|
1646
2565
|
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
2566
|
let cwds = [node_path.default.resolve(policy.cwd)];
|
|
1648
2567
|
for (const simple of parsed.commands) cwds = checkSimpleCommand(checker, simple, cwds, dialect);
|
|
@@ -1656,16 +2575,38 @@ function checkSimpleCommand(checker, simple, cwds, dialect) {
|
|
|
1656
2575
|
for (const redirect of simple.redirects) if (redirect.type === "read" || redirect.type === "write") checker.checkRedirectTarget(redirect.target, redirect.type, cwds);
|
|
1657
2576
|
const first = simple.words[0];
|
|
1658
2577
|
if (first === void 0) return cwds;
|
|
1659
|
-
|
|
1660
|
-
|
|
1661
|
-
if (
|
|
1662
|
-
|
|
1663
|
-
if (
|
|
2578
|
+
const executable = first.dynamic ? "" : executableBasename(first.value, dialect).toLowerCase();
|
|
2579
|
+
const powershell = dialect === "powershell" || dialect === "powershell5";
|
|
2580
|
+
if (executable === "cd" || powershell && POWERSHELL_CD_NAMES.has(executable)) {
|
|
2581
|
+
checker.assertExecutableNotDenied(executable, simple.words.map((word) => word.value));
|
|
2582
|
+
if (powershell) {
|
|
2583
|
+
const words = normalizePowerShellCd(simple.words);
|
|
2584
|
+
if (words === null) throw new Refusal("unsupported-syntax", "`Set-Location` is supported with one directory (`-Path` / `-LiteralPath` or positional) only.", "cd");
|
|
2585
|
+
return checker.checkCd(words, cwds);
|
|
2586
|
+
}
|
|
2587
|
+
return checker.checkCd(simple.words, cwds);
|
|
2588
|
+
}
|
|
2589
|
+
try {
|
|
2590
|
+
if (!first.dynamic && first.value.includes("=")) throw new Refusal("unsupported-syntax", `\`${first.raw}\` is not a plain command name or \`NAME=value\` assignment.`);
|
|
2591
|
+
if (/^[-+]/.test(first.value)) throw new Refusal("unsupported-syntax", `\`${first.raw}\` is not a command name.`);
|
|
2592
|
+
if (checker.hasArgumentHooks) {
|
|
2593
|
+
const dynamic = simple.words.find((word) => word.dynamic);
|
|
2594
|
+
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).`);
|
|
2595
|
+
}
|
|
2596
|
+
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"}\`.`);
|
|
2597
|
+
const argv = simple.words.map((word) => word.dynamic ? word.raw : word.value);
|
|
2598
|
+
checker.checkArgv(argv, simple.words.map((word) => word.dynamic), 0);
|
|
2599
|
+
if (powershell) {
|
|
2600
|
+
const canonical = canonicalPowerShellArgv(argv);
|
|
2601
|
+
if (canonical !== void 0 && canonical.join("\0") !== argv.join("\0")) checker.checkCanonical(canonical);
|
|
2602
|
+
}
|
|
2603
|
+
} catch (error) {
|
|
2604
|
+
if (error instanceof Refusal && error.reason === "unsupported-syntax" && checker.askMode) {
|
|
2605
|
+
checker.addAsk(simple.words.map((word) => word.dynamic ? word.raw : word.value), "unparsed");
|
|
2606
|
+
return cwds;
|
|
2607
|
+
}
|
|
2608
|
+
throw error;
|
|
1664
2609
|
}
|
|
1665
|
-
const executable = first.dynamic ? "" : executableBasename(first.value, dialect).toLowerCase();
|
|
1666
|
-
if (executable === "cd") return checker.checkCd(simple.words, cwds);
|
|
1667
|
-
if (dialect === "posix" && (executable === "pushd" || executable === "popd")) throw new Refusal("unsupported-syntax", `\`${executable}\` is not supported; use \`cd\`.`);
|
|
1668
|
-
checker.checkArgv(simple.words.map((word) => word.dynamic ? word.raw : word.value), simple.words.map((word) => word.dynamic), 0);
|
|
1669
2610
|
return cwds;
|
|
1670
2611
|
}
|
|
1671
2612
|
/**
|
|
@@ -1693,35 +2634,44 @@ function checkSimpleCommand(checker, simple, cwds, dialect) {
|
|
|
1693
2634
|
* checkShellCommand({ cwd, shell: { allow: ["git"] } }, "git add -A && git commit -m wip");
|
|
1694
2635
|
* // { allowed: false, reason: "metacharacter", message: "`&&` is not supported in argv mode: run `git add -A`, then `git commit -m wip`, …" }
|
|
1695
2636
|
*/
|
|
1696
|
-
function checkShellCommand(policy, command) {
|
|
1697
|
-
|
|
2637
|
+
function checkShellCommand(policy, command, options) {
|
|
2638
|
+
const resolved = options?.resolved ?? resolveShell(policy.shell, options?.host ?? currentShellHost());
|
|
2639
|
+
return runCheck(policy, command, resolved.interpreter, resolved.dialect, options?.remembered);
|
|
1698
2640
|
}
|
|
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) {
|
|
2641
|
+
function runCheck(policy, command, interpreter, dialect, remembered) {
|
|
1705
2642
|
const shell = policy.shell;
|
|
1706
2643
|
if (!shell) return {
|
|
1707
2644
|
allowed: false,
|
|
1708
2645
|
reason: "not-allowed",
|
|
1709
2646
|
message: "The workspace has no shell policy, so no command may run.",
|
|
2647
|
+
asks: [],
|
|
1710
2648
|
commands: [],
|
|
1711
2649
|
paths: []
|
|
1712
2650
|
};
|
|
1713
|
-
const
|
|
1714
|
-
const checker = new LineChecker(policy, shell, command,
|
|
2651
|
+
const shellMode = interpreter !== "none";
|
|
2652
|
+
const checker = new LineChecker(policy, shell, command, shellMode ? dialect : "posix", interpreter, remembered);
|
|
1715
2653
|
try {
|
|
1716
2654
|
if (command.trim() === "") throw new Refusal("not-allowed", "The command is empty.");
|
|
1717
|
-
if (
|
|
2655
|
+
if (shellMode) checkShellLine(checker, policy, command, dialect);
|
|
1718
2656
|
else {
|
|
1719
2657
|
const argv = tokenizeCommand(command);
|
|
1720
2658
|
if (argv === null) throw argvModeRefusal(command);
|
|
1721
2659
|
checker.checkArgv(argv, argv.map(() => false), 0);
|
|
1722
2660
|
}
|
|
2661
|
+
if (checker.asks.length > 0) {
|
|
2662
|
+
const first = checker.asks[0];
|
|
2663
|
+
return {
|
|
2664
|
+
allowed: false,
|
|
2665
|
+
reason: "approval-required",
|
|
2666
|
+
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}` : ""}`,
|
|
2667
|
+
asks: checker.asks,
|
|
2668
|
+
commands: checker.commands,
|
|
2669
|
+
paths: checker.paths
|
|
2670
|
+
};
|
|
2671
|
+
}
|
|
1723
2672
|
return {
|
|
1724
2673
|
allowed: true,
|
|
2674
|
+
asks: [],
|
|
1725
2675
|
commands: checker.commands,
|
|
1726
2676
|
paths: checker.paths
|
|
1727
2677
|
};
|
|
@@ -1733,11 +2683,34 @@ function checkShellCommandInDialect(policy, command, dialect) {
|
|
|
1733
2683
|
message: error.message,
|
|
1734
2684
|
...error.executable !== void 0 ? { executable: error.executable } : {},
|
|
1735
2685
|
...error.refusedPath !== void 0 ? { path: error.refusedPath } : {},
|
|
2686
|
+
asks: [],
|
|
1736
2687
|
commands: checker.commands,
|
|
1737
2688
|
paths: checker.paths
|
|
1738
2689
|
};
|
|
1739
2690
|
}
|
|
1740
2691
|
}
|
|
2692
|
+
/**
|
|
2693
|
+
* The simple commands a line would run (pipeline members, `&&` / `||` / `;`
|
|
2694
|
+
* parts, subshells and substitutions included), without checking anything.
|
|
2695
|
+
* A line the parser cannot read with confidence comes back with `opaque`
|
|
2696
|
+
* set to the reason and no commands.
|
|
2697
|
+
*
|
|
2698
|
+
* The line is read with the policy's shell dialect when its `interpreter`
|
|
2699
|
+
* is a shell, and with the platform's default shell otherwise.
|
|
2700
|
+
*
|
|
2701
|
+
* @example
|
|
2702
|
+
* classifyShellCommand("a | b"); // { commands: [["a"], ["b"]] }
|
|
2703
|
+
*/
|
|
2704
|
+
function classifyShellCommand(command, policy, host) {
|
|
2705
|
+
const resolved = resolveShell(policy?.shell, host ?? currentShellHost());
|
|
2706
|
+
const dialect = resolved.interpreter === "none" ? shellDialect() : resolved.dialect;
|
|
2707
|
+
const parsed = parseShellLine(command, dialect);
|
|
2708
|
+
if (!parsed.ok) return {
|
|
2709
|
+
commands: [],
|
|
2710
|
+
opaque: parsed.message
|
|
2711
|
+
};
|
|
2712
|
+
return { commands: parsed.commands.filter((simple) => simple.words.length > 0).map((simple) => simple.words.map((word) => word.dynamic ? word.raw : word.value)) };
|
|
2713
|
+
}
|
|
1741
2714
|
|
|
1742
2715
|
//#endregion
|
|
1743
2716
|
//#region ../../packages/ai-workspace/src/policy/policy.ts
|
|
@@ -1785,13 +2758,15 @@ async function canonicalize$1(target) {
|
|
|
1785
2758
|
*
|
|
1786
2759
|
* @param policy - The bounding policy (its `cwd` is the jail root).
|
|
1787
2760
|
* @param inputPath - A workspace-relative or absolute path to resolve.
|
|
2761
|
+
* @param access - `"read"` also accepts the `output.spillDir` directory (only
|
|
2762
|
+
* `readFile` asks for it); `"write"` (default) never does.
|
|
1788
2763
|
* @returns The canonical absolute path plus its `/`-separated relative form.
|
|
1789
2764
|
* @throws {WorkspacePolicyError} When the path escapes the jail or hits a deny glob.
|
|
1790
2765
|
*
|
|
1791
2766
|
* @example
|
|
1792
2767
|
* const { absolutePath } = await resolveInJail(policy, "src/index.ts");
|
|
1793
2768
|
*/
|
|
1794
|
-
async function resolveInJail(policy, inputPath) {
|
|
2769
|
+
async function resolveInJail(policy, inputPath, access = "write") {
|
|
1795
2770
|
const jailRoot = await canonicalize$1(policy.cwd);
|
|
1796
2771
|
const absolutePath = await canonicalize$1(node_path.default.isAbsolute(inputPath) ? inputPath : node_path.default.join(policy.cwd, inputPath));
|
|
1797
2772
|
const insideCwd = isInside(absolutePath, jailRoot);
|
|
@@ -1804,6 +2779,11 @@ async function resolveInJail(policy, inputPath) {
|
|
|
1804
2779
|
break;
|
|
1805
2780
|
}
|
|
1806
2781
|
}
|
|
2782
|
+
const spillDir = access === "read" && !insideCwd ? resolveSpillDir(policy) : void 0;
|
|
2783
|
+
if (spillDir !== void 0 && isInside(absolutePath, await canonicalize$1(spillDir))) return {
|
|
2784
|
+
absolutePath,
|
|
2785
|
+
relativePath: ""
|
|
2786
|
+
};
|
|
1807
2787
|
if (!insideCwd && !insideAllow) throw new WorkspacePolicyError(`Path "${inputPath}" resolves outside the workspace jail.`, {
|
|
1808
2788
|
type: "path-escape",
|
|
1809
2789
|
path: inputPath
|
|
@@ -1870,8 +2850,8 @@ function buildEnv(policy) {
|
|
|
1870
2850
|
return env;
|
|
1871
2851
|
}
|
|
1872
2852
|
/**
|
|
1873
|
-
* Variables removed from the environment of a shell-mode (`interpreter
|
|
1874
|
-
* "
|
|
2853
|
+
* Variables removed from the environment of a shell-mode (`interpreter`
|
|
2854
|
+
* other than `"none"`) command: each changes how the shell itself starts, parses or
|
|
1875
2855
|
* resolves the line (`ENV`/`BASH_ENV` run a startup file, `SHELLOPTS` +
|
|
1876
2856
|
* `PS4` can run command substitutions, `BASH_FUNC_*` imports functions that
|
|
1877
2857
|
* shadow commands, `CDPATH` moves `cd`, `IFS` re-splits words).
|
|
@@ -1893,7 +2873,7 @@ const SHELL_STARTUP_VARIABLES = /* @__PURE__ */ new Set([
|
|
|
1893
2873
|
*/
|
|
1894
2874
|
function buildCommandEnv(policy) {
|
|
1895
2875
|
const env = buildEnv(policy);
|
|
1896
|
-
if ((policy.shell?.interpreter ?? "none")
|
|
2876
|
+
if ((policy.shell?.interpreter ?? "none") === "none") return env;
|
|
1897
2877
|
for (const key of Object.keys(env)) {
|
|
1898
2878
|
const upper = key.toUpperCase();
|
|
1899
2879
|
if (SHELL_STARTUP_VARIABLES.has(upper) || upper.startsWith("BASH_FUNC_")) delete env[key];
|
|
@@ -2388,6 +3368,543 @@ function applyPatchWithOps(ops, input) {
|
|
|
2388
3368
|
});
|
|
2389
3369
|
}
|
|
2390
3370
|
|
|
3371
|
+
//#endregion
|
|
3372
|
+
//#region ../../packages/ai-workspace/src/edit/match.ts
|
|
3373
|
+
/** Most lines of file text a miss hint shows. */
|
|
3374
|
+
const MAX_HINT_LINES = 12;
|
|
3375
|
+
/** Most characters of one line a miss hint shows. */
|
|
3376
|
+
const MAX_HINT_LINE_CHARS = 200;
|
|
3377
|
+
const FOLD_MAP = {
|
|
3378
|
+
"‘": "'",
|
|
3379
|
+
"’": "'",
|
|
3380
|
+
"‚": "'",
|
|
3381
|
+
"‛": "'",
|
|
3382
|
+
"“": "\"",
|
|
3383
|
+
"”": "\"",
|
|
3384
|
+
"„": "\"",
|
|
3385
|
+
"‟": "\"",
|
|
3386
|
+
"‐": "-",
|
|
3387
|
+
"‑": "-",
|
|
3388
|
+
"‒": "-",
|
|
3389
|
+
"–": "-",
|
|
3390
|
+
"—": "-",
|
|
3391
|
+
"―": "-",
|
|
3392
|
+
"−": "-",
|
|
3393
|
+
"\xA0": " ",
|
|
3394
|
+
" ": " ",
|
|
3395
|
+
" ": " ",
|
|
3396
|
+
" ": " ",
|
|
3397
|
+
" ": " ",
|
|
3398
|
+
" ": " ",
|
|
3399
|
+
" ": " "
|
|
3400
|
+
};
|
|
3401
|
+
/** Fold look-alike characters one for one, so string indexes stay aligned. */
|
|
3402
|
+
function foldUnicode(value) {
|
|
3403
|
+
let out = "";
|
|
3404
|
+
for (const char of value) out += FOLD_MAP[char] ?? char;
|
|
3405
|
+
return out;
|
|
3406
|
+
}
|
|
3407
|
+
/** Every non-overlapping occurrence of `needle` in `haystack`. */
|
|
3408
|
+
function findAll(haystack, needle) {
|
|
3409
|
+
const ranges = [];
|
|
3410
|
+
if (needle === "") return ranges;
|
|
3411
|
+
let from = 0;
|
|
3412
|
+
for (;;) {
|
|
3413
|
+
const index = haystack.indexOf(needle, from);
|
|
3414
|
+
if (index === -1) return ranges;
|
|
3415
|
+
ranges.push({
|
|
3416
|
+
start: index,
|
|
3417
|
+
end: index + needle.length
|
|
3418
|
+
});
|
|
3419
|
+
from = index + needle.length;
|
|
3420
|
+
}
|
|
3421
|
+
}
|
|
3422
|
+
/** The file's dominant line ending. */
|
|
3423
|
+
function detectEol(content) {
|
|
3424
|
+
const crlf = content.split("\r\n").length - 1;
|
|
3425
|
+
return crlf > content.split("\n").length - 1 - crlf ? "\r\n" : "\n";
|
|
3426
|
+
}
|
|
3427
|
+
/** Convert any line endings in `text` to `eol`. */
|
|
3428
|
+
function withEol(text, eol) {
|
|
3429
|
+
const normalised = text.replace(/\r\n/g, "\n");
|
|
3430
|
+
return eol === "\n" ? normalised : normalised.replace(/\n/g, "\r\n");
|
|
3431
|
+
}
|
|
3432
|
+
/** An LF view of `content` plus the original index of each view character. */
|
|
3433
|
+
function lfView(content) {
|
|
3434
|
+
if (!content.includes("\r\n")) return {
|
|
3435
|
+
view: content,
|
|
3436
|
+
map: null
|
|
3437
|
+
};
|
|
3438
|
+
let view = "";
|
|
3439
|
+
const map = [];
|
|
3440
|
+
for (let index = 0; index < content.length; index++) {
|
|
3441
|
+
if (content[index] === "\r" && content[index + 1] === "\n") continue;
|
|
3442
|
+
map.push(content[index] === "\n" && content[index - 1] === "\r" ? index - 1 : index);
|
|
3443
|
+
view += content[index];
|
|
3444
|
+
}
|
|
3445
|
+
map.push(content.length);
|
|
3446
|
+
return {
|
|
3447
|
+
view,
|
|
3448
|
+
map
|
|
3449
|
+
};
|
|
3450
|
+
}
|
|
3451
|
+
/** Line-based search ignoring trailing whitespace, on already folded text. */
|
|
3452
|
+
function findByLines(view, needle) {
|
|
3453
|
+
const needleLines = needle.split("\n");
|
|
3454
|
+
const endsWithBreak = needleLines.length > 1 && needleLines[needleLines.length - 1] === "";
|
|
3455
|
+
if (endsWithBreak) needleLines.pop();
|
|
3456
|
+
const wanted = needleLines.map((line) => line.trimEnd());
|
|
3457
|
+
if (wanted.every((line) => line === "")) return [];
|
|
3458
|
+
const lines = view.split("\n");
|
|
3459
|
+
const starts = [];
|
|
3460
|
+
let offset = 0;
|
|
3461
|
+
for (const line of lines) {
|
|
3462
|
+
starts.push(offset);
|
|
3463
|
+
offset += line.length + 1;
|
|
3464
|
+
}
|
|
3465
|
+
const ranges = [];
|
|
3466
|
+
for (let index = 0; index + wanted.length <= lines.length;) {
|
|
3467
|
+
let same = true;
|
|
3468
|
+
for (let k = 0; k < wanted.length; k++) if ((lines[index + k] ?? "").trimEnd() !== wanted[k]) {
|
|
3469
|
+
same = false;
|
|
3470
|
+
break;
|
|
3471
|
+
}
|
|
3472
|
+
if (!same) {
|
|
3473
|
+
index++;
|
|
3474
|
+
continue;
|
|
3475
|
+
}
|
|
3476
|
+
const last = index + wanted.length - 1;
|
|
3477
|
+
let end = (starts[last] ?? 0) + (lines[last] ?? "").length;
|
|
3478
|
+
if (endsWithBreak && view[end] === "\n") end++;
|
|
3479
|
+
ranges.push({
|
|
3480
|
+
start: starts[index] ?? 0,
|
|
3481
|
+
end
|
|
3482
|
+
});
|
|
3483
|
+
index += wanted.length;
|
|
3484
|
+
}
|
|
3485
|
+
return ranges;
|
|
3486
|
+
}
|
|
3487
|
+
/**
|
|
3488
|
+
* Find `oldString` in `content`. `"exact"` only looks for the text as given;
|
|
3489
|
+
* `"tiered"` walks the tiers described on {@link WorkspaceEditMatching} and
|
|
3490
|
+
* stops at the first one with a match. The ranges are always in the original
|
|
3491
|
+
* content, whatever view found them.
|
|
3492
|
+
*/
|
|
3493
|
+
function locateEdit(content, oldString, matching) {
|
|
3494
|
+
const exact = findAll(content, oldString);
|
|
3495
|
+
if (exact.length > 0 || matching === "exact") return {
|
|
3496
|
+
tier: exact.length > 0 ? "exact" : null,
|
|
3497
|
+
ranges: exact,
|
|
3498
|
+
viaView: false
|
|
3499
|
+
};
|
|
3500
|
+
const { view, map } = lfView(content);
|
|
3501
|
+
const needle = oldString.replace(/\r\n/g, "\n");
|
|
3502
|
+
const back = (ranges) => map === null ? ranges : ranges.map((range) => ({
|
|
3503
|
+
start: map[range.start] ?? content.length,
|
|
3504
|
+
end: map[range.end] ?? content.length
|
|
3505
|
+
}));
|
|
3506
|
+
if (map !== null || needle !== oldString) {
|
|
3507
|
+
const plain = findAll(view, needle);
|
|
3508
|
+
if (plain.length > 0) return {
|
|
3509
|
+
tier: "exact",
|
|
3510
|
+
ranges: back(plain),
|
|
3511
|
+
viaView: true
|
|
3512
|
+
};
|
|
3513
|
+
}
|
|
3514
|
+
const foldedView = foldUnicode(view);
|
|
3515
|
+
const foldedNeedle = foldUnicode(needle);
|
|
3516
|
+
const folded = findAll(foldedView, foldedNeedle);
|
|
3517
|
+
if (folded.length > 0) return {
|
|
3518
|
+
tier: "unicode",
|
|
3519
|
+
ranges: back(folded),
|
|
3520
|
+
viaView: true
|
|
3521
|
+
};
|
|
3522
|
+
const lined = findByLines(foldedView, foldedNeedle);
|
|
3523
|
+
if (lined.length > 0) return {
|
|
3524
|
+
tier: "whitespace",
|
|
3525
|
+
ranges: back(lined),
|
|
3526
|
+
viaView: true
|
|
3527
|
+
};
|
|
3528
|
+
return {
|
|
3529
|
+
tier: null,
|
|
3530
|
+
ranges: [],
|
|
3531
|
+
viaView: false
|
|
3532
|
+
};
|
|
3533
|
+
}
|
|
3534
|
+
function clipLine(line) {
|
|
3535
|
+
const flat = line.replace(/\r$/, "");
|
|
3536
|
+
return flat.length > 200 ? `${flat.slice(0, 200)}…` : flat;
|
|
3537
|
+
}
|
|
3538
|
+
function tokens(line) {
|
|
3539
|
+
return new Set(line.match(/[A-Za-z0-9_$]+/g) ?? []);
|
|
3540
|
+
}
|
|
3541
|
+
function similarity(left, right) {
|
|
3542
|
+
if (left.size === 0 || right.size === 0) return 0;
|
|
3543
|
+
let shared = 0;
|
|
3544
|
+
for (const token of left) if (right.has(token)) shared++;
|
|
3545
|
+
return shared / (left.size + right.size - shared);
|
|
3546
|
+
}
|
|
3547
|
+
/**
|
|
3548
|
+
* Explain a miss: either `newString` is already in the file (the edit was
|
|
3549
|
+
* probably applied), or the file text closest to `oldString`, at most
|
|
3550
|
+
* {@link MAX_HINT_LINES} lines of {@link MAX_HINT_LINE_CHARS} characters.
|
|
3551
|
+
* `undefined` when nothing is close.
|
|
3552
|
+
*/
|
|
3553
|
+
function describeMiss(content, oldString, newString, maxLines = 12) {
|
|
3554
|
+
if (maxLines <= 0) return;
|
|
3555
|
+
const lines = content.split("\n");
|
|
3556
|
+
const present = newString.trim() === "" ? -1 : content.indexOf(newString);
|
|
3557
|
+
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.`;
|
|
3558
|
+
const fileTokens = lines.map((line) => tokens(line));
|
|
3559
|
+
const wanted = oldString.split("\n");
|
|
3560
|
+
let best = {
|
|
3561
|
+
score: 0,
|
|
3562
|
+
fileLine: -1,
|
|
3563
|
+
offset: 0
|
|
3564
|
+
};
|
|
3565
|
+
wanted.forEach((text, offset) => {
|
|
3566
|
+
const own = tokens(text);
|
|
3567
|
+
fileTokens.forEach((candidate, fileLine) => {
|
|
3568
|
+
const score = similarity(own, candidate);
|
|
3569
|
+
if (score > best.score) best = {
|
|
3570
|
+
score,
|
|
3571
|
+
fileLine,
|
|
3572
|
+
offset
|
|
3573
|
+
};
|
|
3574
|
+
});
|
|
3575
|
+
});
|
|
3576
|
+
if (best.fileLine === -1 || best.score < .5) return;
|
|
3577
|
+
const first = Math.max(0, best.fileLine - best.offset);
|
|
3578
|
+
const count = Math.min(maxLines, 12, Math.max(1, wanted.length));
|
|
3579
|
+
const shown = lines.slice(first, first + count).map((line, index) => `${first + index + 1}: ${clipLine(line)}`);
|
|
3580
|
+
return `Closest text in the file (lines ${first + 1}-${first + shown.length}):\n${shown.join("\n")}`;
|
|
3581
|
+
}
|
|
3582
|
+
|
|
3583
|
+
//#endregion
|
|
3584
|
+
//#region ../../packages/ai-workspace/src/edit/apply-replacement.ts
|
|
3585
|
+
/**
|
|
3586
|
+
* Apply one replacement to `content` under the edit rules: `oldString` must
|
|
3587
|
+
* be found (see {@link WorkspaceEditMatching}), and exactly once unless
|
|
3588
|
+
* `replaceAll` — a fuzzy match must always be unique, so a loose match never
|
|
3589
|
+
* rewrites several places at once.
|
|
3590
|
+
*/
|
|
3591
|
+
function applyReplacement(content, edit, inputPath, shownPath, options, editIndex) {
|
|
3592
|
+
const { matching } = options;
|
|
3593
|
+
const located = edit.oldString === "" ? null : locateEdit(content, edit.oldString, matching);
|
|
3594
|
+
const count = located?.ranges.length ?? 0;
|
|
3595
|
+
const which = editIndex === void 0 ? "" : `Edit ${editIndex + 1}: `;
|
|
3596
|
+
const nothingApplied = editIndex === void 0 ? "" : " None of the edits were applied.";
|
|
3597
|
+
const indexOption = editIndex === void 0 ? {} : { editIndex };
|
|
3598
|
+
if (located === null || count === 0) {
|
|
3599
|
+
let hint = describeMiss(content, edit.oldString, edit.newString, options.missHintLines);
|
|
3600
|
+
if (hint !== void 0 && options.hintMaxBytes !== void 0) hint = Buffer.byteLength(hint, "utf8") > options.hintMaxBytes ? void 0 : hint;
|
|
3601
|
+
throw new WorkspaceEditError(`${which}The text to replace was not found in "${inputPath}".` + (hint === void 0 ? "" : ` ${hint}`) + nothingApplied, {
|
|
3602
|
+
type: "not-found",
|
|
3603
|
+
path: shownPath,
|
|
3604
|
+
matches: 0,
|
|
3605
|
+
...hint !== void 0 ? { hint } : {},
|
|
3606
|
+
...indexOption
|
|
3607
|
+
});
|
|
3608
|
+
}
|
|
3609
|
+
const fuzzy = located.tier !== "exact";
|
|
3610
|
+
if (count > 1 && (!edit.replaceAll || fuzzy)) {
|
|
3611
|
+
const advice = fuzzy ? "include more surrounding context." : "pass replaceAll or include more surrounding context.";
|
|
3612
|
+
throw new WorkspaceEditError(`${which}The text to replace is not unique in "${inputPath}" (${count} matches${fuzzy && located.viaView ? ", found by loose matching" : ""}); ${advice}${nothingApplied}`, {
|
|
3613
|
+
type: "not-unique",
|
|
3614
|
+
path: shownPath,
|
|
3615
|
+
matches: count,
|
|
3616
|
+
...indexOption
|
|
3617
|
+
});
|
|
3618
|
+
}
|
|
3619
|
+
const eol = options.lineEndings === "lf" ? "\n" : options.lineEndings === "crlf" ? "\r\n" : matching === "tiered" ? detectEol(content) : void 0;
|
|
3620
|
+
const replacement = eol === void 0 ? edit.newString : withEol(edit.newString, eol);
|
|
3621
|
+
const ranges = edit.replaceAll ? located.ranges : located.ranges.slice(0, 1);
|
|
3622
|
+
let out = "";
|
|
3623
|
+
let cursor = 0;
|
|
3624
|
+
for (const range of ranges) {
|
|
3625
|
+
out += content.slice(cursor, range.start) + replacement;
|
|
3626
|
+
cursor = range.end;
|
|
3627
|
+
}
|
|
3628
|
+
out += content.slice(cursor);
|
|
3629
|
+
return {
|
|
3630
|
+
content: out,
|
|
3631
|
+
replacements: ranges.length,
|
|
3632
|
+
...fuzzy ? { matchTier: located.tier } : {}
|
|
3633
|
+
};
|
|
3634
|
+
}
|
|
3635
|
+
|
|
3636
|
+
//#endregion
|
|
3637
|
+
//#region ../../packages/ai-workspace/src/hooks/after-edit.ts
|
|
3638
|
+
/** How long a hook may take when the policy sets no `afterEditTimeoutMs`. */
|
|
3639
|
+
const DEFAULT_HOOK_TIMEOUT_MS = 1e4;
|
|
3640
|
+
/** Longest diagnostics text kept when the policy sets no `afterEditMaxChars`. */
|
|
3641
|
+
const DEFAULT_MAX_CHARS = 4e3;
|
|
3642
|
+
function wrap(text) {
|
|
3643
|
+
return `[diagnostics]\n${text}\n[/diagnostics]`;
|
|
3644
|
+
}
|
|
3645
|
+
/**
|
|
3646
|
+
* Run the policy's `afterEdit` hook for one call and return the text to put
|
|
3647
|
+
* on the result's `diagnostics`, or `undefined` when there is none. A throw
|
|
3648
|
+
* or a timeout yields "diagnostics unavailable"; it never rejects.
|
|
3649
|
+
*/
|
|
3650
|
+
async function runAfterEdit(policy, event) {
|
|
3651
|
+
const hook = policy.hooks?.afterEdit;
|
|
3652
|
+
if (hook === void 0 || event.paths.length === 0) return;
|
|
3653
|
+
const timeoutMs = policy.hooks?.afterEditTimeoutMs ?? DEFAULT_HOOK_TIMEOUT_MS;
|
|
3654
|
+
const maxChars = policy.hooks?.afterEditMaxChars ?? DEFAULT_MAX_CHARS;
|
|
3655
|
+
let timer;
|
|
3656
|
+
try {
|
|
3657
|
+
const answer = await Promise.race([Promise.resolve().then(() => hook(event)), new Promise((_resolve, reject) => {
|
|
3658
|
+
timer = setTimeout(() => reject(/* @__PURE__ */ new Error("afterEdit hook timed out")), timeoutMs);
|
|
3659
|
+
})]);
|
|
3660
|
+
if (typeof answer !== "string" || answer.trim() === "") return;
|
|
3661
|
+
const text = answer.trim();
|
|
3662
|
+
return wrap(text.length > maxChars ? `${text.slice(0, maxChars)}\n[diagnostics truncated: showing ${maxChars} of ${text.length} characters]` : text);
|
|
3663
|
+
} catch {
|
|
3664
|
+
return wrap("diagnostics unavailable");
|
|
3665
|
+
} finally {
|
|
3666
|
+
clearTimeout(timer);
|
|
3667
|
+
}
|
|
3668
|
+
}
|
|
3669
|
+
|
|
3670
|
+
//#endregion
|
|
3671
|
+
//#region ../../packages/ai-workspace/src/output/spill.ts
|
|
3672
|
+
const DEFAULT_MAX_LINES = 2e3;
|
|
3673
|
+
const DEFAULT_MAX_BYTES = 51200;
|
|
3674
|
+
const DEFAULT_RETENTION_MS = 6048e5;
|
|
3675
|
+
const SPILL_NAME = /^spill-(\d+)-[a-z0-9]+-[a-z0-9_-]+\.txt$/;
|
|
3676
|
+
function byteLength(value) {
|
|
3677
|
+
return Buffer.byteLength(value, "utf8");
|
|
3678
|
+
}
|
|
3679
|
+
/** The first `maxBytes` bytes of `value`, cut on a character boundary. */
|
|
3680
|
+
function headOf(value, maxBytes) {
|
|
3681
|
+
const bytes = Buffer.from(value, "utf8");
|
|
3682
|
+
if (bytes.byteLength <= maxBytes) return value;
|
|
3683
|
+
let cut = Math.max(0, maxBytes);
|
|
3684
|
+
while (cut > 0 && ((bytes[cut] ?? 0) & 192) === 128) cut--;
|
|
3685
|
+
return bytes.subarray(0, cut).toString("utf8");
|
|
3686
|
+
}
|
|
3687
|
+
/** The last `maxBytes` bytes of `value`, cut on a character boundary. */
|
|
3688
|
+
function tailOf(value, maxBytes) {
|
|
3689
|
+
const bytes = Buffer.from(value, "utf8");
|
|
3690
|
+
if (bytes.byteLength <= maxBytes) return value;
|
|
3691
|
+
let from = bytes.byteLength - Math.max(0, maxBytes);
|
|
3692
|
+
while (from < bytes.byteLength && ((bytes[from] ?? 0) & 192) === 128) from++;
|
|
3693
|
+
return bytes.subarray(from).toString("utf8");
|
|
3694
|
+
}
|
|
3695
|
+
/** Lines of `value`; a trailing line break does not add an empty line. */
|
|
3696
|
+
function splitLines(value) {
|
|
3697
|
+
if (value === "") return [];
|
|
3698
|
+
const lines = value.split("\n");
|
|
3699
|
+
if (lines[lines.length - 1] === "") lines.pop();
|
|
3700
|
+
return lines;
|
|
3701
|
+
}
|
|
3702
|
+
/** Pick lines from the front within `lines` / `bytes`; one oversized line is clipped. */
|
|
3703
|
+
function takeHead(lines, maxLines, maxBytes) {
|
|
3704
|
+
const out = [];
|
|
3705
|
+
let used = 0;
|
|
3706
|
+
for (const line of lines) {
|
|
3707
|
+
if (out.length >= maxLines) break;
|
|
3708
|
+
const cost = byteLength(line) + 1;
|
|
3709
|
+
if (used + cost > maxBytes) {
|
|
3710
|
+
if (out.length === 0 && maxBytes > 0) out.push(headOf(line, maxBytes));
|
|
3711
|
+
break;
|
|
3712
|
+
}
|
|
3713
|
+
out.push(line);
|
|
3714
|
+
used += cost;
|
|
3715
|
+
}
|
|
3716
|
+
return out;
|
|
3717
|
+
}
|
|
3718
|
+
/** Pick lines from the back, same limits; the result is in file order. */
|
|
3719
|
+
function takeTail(lines, maxLines, maxBytes) {
|
|
3720
|
+
const out = [];
|
|
3721
|
+
let used = 0;
|
|
3722
|
+
for (let index = lines.length - 1; index >= 0; index--) {
|
|
3723
|
+
const line = lines[index] ?? "";
|
|
3724
|
+
if (out.length >= maxLines) break;
|
|
3725
|
+
const cost = byteLength(line) + 1;
|
|
3726
|
+
if (used + cost > maxBytes) {
|
|
3727
|
+
if (out.length === 0 && maxBytes > 0) out.push(tailOf(line, maxBytes));
|
|
3728
|
+
break;
|
|
3729
|
+
}
|
|
3730
|
+
out.unshift(line);
|
|
3731
|
+
used += cost;
|
|
3732
|
+
}
|
|
3733
|
+
return out;
|
|
3734
|
+
}
|
|
3735
|
+
/** Choose the lines to keep for `keep`; `headCount` lines come from the front. */
|
|
3736
|
+
function select(lines, keep, maxLines, maxBytes) {
|
|
3737
|
+
if (keep === "head") return {
|
|
3738
|
+
head: takeHead(lines, maxLines, maxBytes),
|
|
3739
|
+
tail: []
|
|
3740
|
+
};
|
|
3741
|
+
if (keep === "tail") return {
|
|
3742
|
+
head: [],
|
|
3743
|
+
tail: takeTail(lines, maxLines, maxBytes)
|
|
3744
|
+
};
|
|
3745
|
+
const headLines = Math.ceil(maxLines / 2);
|
|
3746
|
+
const headBytes = Math.ceil(maxBytes / 2);
|
|
3747
|
+
const head = takeHead(lines, headLines, headBytes);
|
|
3748
|
+
return {
|
|
3749
|
+
head,
|
|
3750
|
+
tail: takeTail(lines.slice(head.length), maxLines - head.length, maxBytes - headBytes)
|
|
3751
|
+
};
|
|
3752
|
+
}
|
|
3753
|
+
/**
|
|
3754
|
+
* Keeps oversized command, search and read output from flooding the model:
|
|
3755
|
+
* with `output.spillDir` set it saves the full output to a file and returns
|
|
3756
|
+
* the part the policy keeps plus a pointer to it. One instance per ops layer;
|
|
3757
|
+
* it remembers the files it wrote so `dispose()` can remove them.
|
|
3758
|
+
*/
|
|
3759
|
+
var OutputSpiller = class {
|
|
3760
|
+
constructor(backend, policy) {
|
|
3761
|
+
this.backend = backend;
|
|
3762
|
+
this.policy = policy;
|
|
3763
|
+
this.created = [];
|
|
3764
|
+
this.dir = resolveSpillDir(policy);
|
|
3765
|
+
if (this.dir !== void 0 && this.retentionMs > 0) this.sweep();
|
|
3766
|
+
}
|
|
3767
|
+
/** True when `output.spillDir` is set. */
|
|
3768
|
+
get enabled() {
|
|
3769
|
+
return this.dir !== void 0;
|
|
3770
|
+
}
|
|
3771
|
+
/** The spill directory, when spilling is on. */
|
|
3772
|
+
get spillDir() {
|
|
3773
|
+
return this.dir;
|
|
3774
|
+
}
|
|
3775
|
+
get retentionMs() {
|
|
3776
|
+
return this.policy.output?.retentionMs ?? DEFAULT_RETENTION_MS;
|
|
3777
|
+
}
|
|
3778
|
+
limits(cap) {
|
|
3779
|
+
const configured = this.policy.output?.maxBytes ?? DEFAULT_MAX_BYTES;
|
|
3780
|
+
return {
|
|
3781
|
+
maxLines: Math.max(1, Math.floor(this.policy.output?.maxLines ?? DEFAULT_MAX_LINES)),
|
|
3782
|
+
maxBytes: Math.max(1, Math.floor(cap === void 0 ? configured : Math.min(configured, cap)))
|
|
3783
|
+
};
|
|
3784
|
+
}
|
|
3785
|
+
/** Remove spill files older than the retention (only files named like ours). */
|
|
3786
|
+
async sweep() {
|
|
3787
|
+
if (this.dir === void 0) return;
|
|
3788
|
+
try {
|
|
3789
|
+
const now = Date.now();
|
|
3790
|
+
for (const file of await this.backend.list(this.dir)) {
|
|
3791
|
+
const match = SPILL_NAME.exec(node_path.default.basename(file));
|
|
3792
|
+
if (match !== null && now - Number(match[1]) > this.retentionMs) await this.backend.remove(file).catch(() => void 0);
|
|
3793
|
+
}
|
|
3794
|
+
} catch {}
|
|
3795
|
+
}
|
|
3796
|
+
/** Write `content` to a new spill file and return its absolute path. */
|
|
3797
|
+
async write(label, content) {
|
|
3798
|
+
if (this.dir === void 0) return;
|
|
3799
|
+
try {
|
|
3800
|
+
if (this.retentionMs > 0) await this.sweep();
|
|
3801
|
+
const safe = label.toLowerCase().replace(/[^a-z0-9_-]+/g, "-") || "output";
|
|
3802
|
+
const rand = Math.random().toString(36).slice(2, 8) || "x";
|
|
3803
|
+
const file = node_path.default.join(this.dir, `spill-${Date.now()}-${rand}-${safe}.txt`);
|
|
3804
|
+
await this.backend.mkdir(this.dir);
|
|
3805
|
+
await this.backend.writeFile(file, content);
|
|
3806
|
+
this.created.push(file);
|
|
3807
|
+
return file;
|
|
3808
|
+
} catch {
|
|
3809
|
+
return;
|
|
3810
|
+
}
|
|
3811
|
+
}
|
|
3812
|
+
/**
|
|
3813
|
+
* Fit `value` to the policy. Returns it untouched when it fits. Over the
|
|
3814
|
+
* limits with spilling on, the whole text is saved and the kept lines come
|
|
3815
|
+
* back with a `[showing lines … of …; full output saved to …]` marker.
|
|
3816
|
+
* `cap` is a tighter byte limit from elsewhere (`shell.maxOutputBytes`).
|
|
3817
|
+
*/
|
|
3818
|
+
async text(label, value, defaultKeep, cap) {
|
|
3819
|
+
if (this.dir === void 0) return { value };
|
|
3820
|
+
const { maxLines, maxBytes } = this.limits(cap);
|
|
3821
|
+
const lines = splitLines(value);
|
|
3822
|
+
const totalBytes = byteLength(value);
|
|
3823
|
+
if (lines.length <= maxLines && totalBytes <= maxBytes) return { value };
|
|
3824
|
+
const { head, tail } = select(lines, this.policy.output?.keep ?? defaultKeep, maxLines, maxBytes);
|
|
3825
|
+
const file = await this.write(label, value);
|
|
3826
|
+
const shown = head.length + tail.length;
|
|
3827
|
+
const total = lines.length;
|
|
3828
|
+
const where = file === void 0 ? "the rest was dropped" : `full output saved to ${file}`;
|
|
3829
|
+
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}]`;
|
|
3830
|
+
const parts = [...head.length > 0 ? [head.join("\n")] : [], marker];
|
|
3831
|
+
if (tail.length > 0) {
|
|
3832
|
+
parts.push(tail.join("\n"));
|
|
3833
|
+
if (file !== void 0) parts.push(`[full output saved to ${file}]`);
|
|
3834
|
+
}
|
|
3835
|
+
return {
|
|
3836
|
+
value: parts.join("\n"),
|
|
3837
|
+
truncation: {
|
|
3838
|
+
shown: { lines: shown },
|
|
3839
|
+
total: {
|
|
3840
|
+
lines: total,
|
|
3841
|
+
bytes: totalBytes
|
|
3842
|
+
},
|
|
3843
|
+
...file !== void 0 ? { path: file } : {}
|
|
3844
|
+
}
|
|
3845
|
+
};
|
|
3846
|
+
}
|
|
3847
|
+
/**
|
|
3848
|
+
* Fit a list of entries (one line each when rendered) to the policy; the
|
|
3849
|
+
* rendered full list is what gets saved.
|
|
3850
|
+
*/
|
|
3851
|
+
async list(label, items, render, defaultKeep) {
|
|
3852
|
+
if (this.dir === void 0) return { items };
|
|
3853
|
+
const { maxLines, maxBytes } = this.limits(void 0);
|
|
3854
|
+
const lines = items.map(render);
|
|
3855
|
+
const totalBytes = lines.reduce((sum, line) => sum + byteLength(line) + 1, 0);
|
|
3856
|
+
if (items.length <= maxLines && totalBytes <= maxBytes) return { items };
|
|
3857
|
+
const { head, tail } = select(lines, this.policy.output?.keep ?? defaultKeep, maxLines, maxBytes);
|
|
3858
|
+
const headCount = head.length;
|
|
3859
|
+
const tailCount = tail.length;
|
|
3860
|
+
const file = await this.write(label, lines.join("\n") + "\n");
|
|
3861
|
+
return {
|
|
3862
|
+
items: [...items.slice(0, headCount), ...items.slice(items.length - tailCount)],
|
|
3863
|
+
truncation: {
|
|
3864
|
+
shown: { lines: headCount + tailCount },
|
|
3865
|
+
total: {
|
|
3866
|
+
lines: items.length,
|
|
3867
|
+
bytes: totalBytes
|
|
3868
|
+
},
|
|
3869
|
+
...file !== void 0 ? { path: file } : {}
|
|
3870
|
+
}
|
|
3871
|
+
};
|
|
3872
|
+
}
|
|
3873
|
+
/** Save the complete text of an output that was already cut elsewhere. */
|
|
3874
|
+
async save(label, content) {
|
|
3875
|
+
return this.write(label, content);
|
|
3876
|
+
}
|
|
3877
|
+
/** Remove expired files, and this run's own when `retentionMs` is `0`. */
|
|
3878
|
+
async dispose() {
|
|
3879
|
+
if (this.dir === void 0) return;
|
|
3880
|
+
if (this.retentionMs === 0) {
|
|
3881
|
+
for (const file of this.created.splice(0)) await this.backend.remove(file).catch(() => void 0);
|
|
3882
|
+
return;
|
|
3883
|
+
}
|
|
3884
|
+
await this.sweep();
|
|
3885
|
+
}
|
|
3886
|
+
};
|
|
3887
|
+
|
|
3888
|
+
//#endregion
|
|
3889
|
+
//#region ../../packages/ai-workspace/src/policy/approval-helpers.ts
|
|
3890
|
+
/** The longest refusal reason passed on to the model, in characters. */
|
|
3891
|
+
const MAX_REFUSAL_REASON = 500;
|
|
3892
|
+
/** Whether two command patterns are the same (strings by value, RegExps by source and flags). */
|
|
3893
|
+
function samePattern(a, b) {
|
|
3894
|
+
if (typeof a === "string" || typeof b === "string") return a === b;
|
|
3895
|
+
return a.source === b.source && a.flags === b.flags;
|
|
3896
|
+
}
|
|
3897
|
+
/**
|
|
3898
|
+
* The tail of a refusal message for an owner's reason: `"."` without one,
|
|
3899
|
+
* otherwise `" — <reason>"` cut at {@link MAX_REFUSAL_REASON} characters with
|
|
3900
|
+
* a visible marker.
|
|
3901
|
+
*/
|
|
3902
|
+
function refusalReason(reason) {
|
|
3903
|
+
const text = typeof reason === "string" ? reason.trim() : "";
|
|
3904
|
+
if (text.length === 0) return ".";
|
|
3905
|
+
return ` — ${text.length > 500 ? `${text.slice(0, 500)}… [truncated]` : text}`;
|
|
3906
|
+
}
|
|
3907
|
+
|
|
2391
3908
|
//#endregion
|
|
2392
3909
|
//#region ../../packages/ai-workspace/src/shell-jobs.ts
|
|
2393
3910
|
/** Finished commands kept for a last `shell_output` read before the oldest are pruned. */
|
|
@@ -2764,6 +4281,26 @@ var Ops = class {
|
|
|
2764
4281
|
this.policy = policy;
|
|
2765
4282
|
this.runtime = runtime;
|
|
2766
4283
|
this.pathLocks = /* @__PURE__ */ new Map();
|
|
4284
|
+
this.resolvedShell = resolveShell(policy.shell);
|
|
4285
|
+
this.approvals = runtime.approvals ?? [];
|
|
4286
|
+
this.spiller = new OutputSpiller(backend, policy);
|
|
4287
|
+
}
|
|
4288
|
+
/** The edit-policy settings applied to every replacement. */
|
|
4289
|
+
editOptions() {
|
|
4290
|
+
const edit = this.policy.edit;
|
|
4291
|
+
return {
|
|
4292
|
+
matching: edit?.matching ?? "tiered",
|
|
4293
|
+
missHintLines: edit?.missHintLines ?? 12,
|
|
4294
|
+
lineEndings: edit?.lineEndings ?? "preserve",
|
|
4295
|
+
...this.policy.read?.maxBytes !== void 0 ? { hintMaxBytes: this.policy.read.maxBytes } : {}
|
|
4296
|
+
};
|
|
4297
|
+
}
|
|
4298
|
+
/** Run `afterEdit` for a call that wrote `paths`; `undefined` when there is no output. */
|
|
4299
|
+
diagnosticsFor(tool, paths) {
|
|
4300
|
+
return runAfterEdit(this.policy, {
|
|
4301
|
+
paths,
|
|
4302
|
+
tool
|
|
4303
|
+
});
|
|
2767
4304
|
}
|
|
2768
4305
|
/** Run `task` after every earlier mutation of `absolutePath` has settled. */
|
|
2769
4306
|
async withPathLock(absolutePath, task) {
|
|
@@ -2808,7 +4345,7 @@ var Ops = class {
|
|
|
2808
4345
|
}
|
|
2809
4346
|
}
|
|
2810
4347
|
async readFile(inputPath, opts) {
|
|
2811
|
-
const { absolutePath } = await resolveInJail(this.policy, inputPath);
|
|
4348
|
+
const { absolutePath } = await resolveInJail(this.policy, inputPath, "read");
|
|
2812
4349
|
const raw = await this.backend.readFile(absolutePath);
|
|
2813
4350
|
const hash = _warlock_js_fs.fs.hash.string(raw);
|
|
2814
4351
|
const lines = raw.split("\n");
|
|
@@ -2817,10 +4354,30 @@ var Ops = class {
|
|
|
2817
4354
|
const limit = opts?.limit ?? this.policy.read?.defaultLines ?? DEFAULT_READ_LINES;
|
|
2818
4355
|
const maxBytes = this.policy.read?.maxBytes;
|
|
2819
4356
|
const startIndex = offset - 1;
|
|
2820
|
-
const
|
|
4357
|
+
const window = lines.slice(startIndex, startIndex + Math.max(0, limit));
|
|
4358
|
+
const { content, returnedLines, bytesCapped } = numberWindow(window, offset, maxBytes === void 0 ? void 0 : Math.max(0, Math.floor(maxBytes)));
|
|
2821
4359
|
const endLine = offset + returnedLines - 1;
|
|
4360
|
+
const spillDir = this.spiller.spillDir;
|
|
4361
|
+
let spilled;
|
|
4362
|
+
if (bytesCapped && spillDir !== void 0 && !isInside(absolutePath, spillDir)) {
|
|
4363
|
+
const full = numberWindow(window, offset, void 0).content;
|
|
4364
|
+
const file = await this.spiller.save("read_file", full);
|
|
4365
|
+
if (file !== void 0) spilled = {
|
|
4366
|
+
content: `${content}\n[showing lines 1-${returnedLines} of ${window.length}; full output saved to ${file}]`,
|
|
4367
|
+
truncation: {
|
|
4368
|
+
shown: { lines: returnedLines },
|
|
4369
|
+
total: {
|
|
4370
|
+
lines: window.length,
|
|
4371
|
+
bytes: Buffer.byteLength(full, "utf8")
|
|
4372
|
+
},
|
|
4373
|
+
path: file
|
|
4374
|
+
}
|
|
4375
|
+
};
|
|
4376
|
+
}
|
|
2822
4377
|
return {
|
|
2823
|
-
content,
|
|
4378
|
+
content: spilled?.content ?? content,
|
|
4379
|
+
...spilled !== void 0 ? { truncation: spilled.truncation } : {},
|
|
4380
|
+
...spilled?.truncation.path !== void 0 ? { spilledTo: [spilled.truncation.path] } : {},
|
|
2824
4381
|
startLine: offset,
|
|
2825
4382
|
endLine,
|
|
2826
4383
|
totalLines,
|
|
@@ -2831,8 +4388,8 @@ var Ops = class {
|
|
|
2831
4388
|
};
|
|
2832
4389
|
}
|
|
2833
4390
|
async writeFile(inputPath, content) {
|
|
2834
|
-
const { absolutePath } = await resolveInJail(this.policy, inputPath);
|
|
2835
|
-
|
|
4391
|
+
const { absolutePath, relativePath } = await resolveInJail(this.policy, inputPath);
|
|
4392
|
+
const written = await this.withPathLock(absolutePath, async () => {
|
|
2836
4393
|
const parent = node_path.default.dirname(absolutePath);
|
|
2837
4394
|
await this.backend.mkdir(parent);
|
|
2838
4395
|
await this.backend.writeFile(absolutePath, content);
|
|
@@ -2841,10 +4398,20 @@ var Ops = class {
|
|
|
2841
4398
|
bytesWritten: Buffer.byteLength(content, "utf8")
|
|
2842
4399
|
};
|
|
2843
4400
|
});
|
|
4401
|
+
const diagnostics = await this.diagnosticsFor("write_file", [relativePath || inputPath]);
|
|
4402
|
+
return {
|
|
4403
|
+
...written,
|
|
4404
|
+
...diagnostics !== void 0 ? { diagnostics } : {}
|
|
4405
|
+
};
|
|
2844
4406
|
}
|
|
2845
4407
|
async editFile(input) {
|
|
2846
4408
|
const { absolutePath, relativePath } = await resolveInJail(this.policy, input.path);
|
|
2847
|
-
|
|
4409
|
+
const result = await this.withPathLock(absolutePath, () => this.applyEdit(input, absolutePath, relativePath));
|
|
4410
|
+
const diagnostics = await this.diagnosticsFor("edit_file", [result.path]);
|
|
4411
|
+
return diagnostics === void 0 ? result : {
|
|
4412
|
+
...result,
|
|
4413
|
+
diagnostics
|
|
4414
|
+
};
|
|
2848
4415
|
}
|
|
2849
4416
|
/** The read-check-replace-write of {@link editFile}, run under its path lock. */
|
|
2850
4417
|
async applyEdit(input, absolutePath, relativePath) {
|
|
@@ -2856,19 +4423,20 @@ var Ops = class {
|
|
|
2856
4423
|
expectedHash: input.expectHash,
|
|
2857
4424
|
actualHash: currentHash
|
|
2858
4425
|
});
|
|
2859
|
-
const { content: updated, replacements } = applyReplacement(current, input, input.path, relativePath || input.path);
|
|
4426
|
+
const { content: updated, replacements, matchTier } = applyReplacement(current, input, input.path, relativePath || input.path, this.editOptions());
|
|
2860
4427
|
await this.backend.writeFile(absolutePath, updated);
|
|
2861
4428
|
return {
|
|
2862
4429
|
path: relativePath || input.path,
|
|
2863
4430
|
replacements,
|
|
2864
|
-
hash: _warlock_js_fs.fs.hash.string(updated)
|
|
4431
|
+
hash: _warlock_js_fs.fs.hash.string(updated),
|
|
4432
|
+
...matchTier !== void 0 ? { matchTier } : {}
|
|
2865
4433
|
};
|
|
2866
4434
|
}
|
|
2867
4435
|
async multiEdit(input) {
|
|
2868
4436
|
const { absolutePath, relativePath } = await resolveInJail(this.policy, input.path);
|
|
2869
4437
|
const shownPath = relativePath || input.path;
|
|
2870
4438
|
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
|
-
|
|
4439
|
+
const result = await this.withPathLock(absolutePath, async () => {
|
|
2872
4440
|
const current = await this.backend.readFile(absolutePath);
|
|
2873
4441
|
const currentHash = _warlock_js_fs.fs.hash.string(current);
|
|
2874
4442
|
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 +4447,40 @@ var Ops = class {
|
|
|
2879
4447
|
});
|
|
2880
4448
|
let content = current;
|
|
2881
4449
|
let replacements = 0;
|
|
4450
|
+
let matchTier;
|
|
4451
|
+
const options = this.editOptions();
|
|
2882
4452
|
input.edits.forEach((edit, editIndex) => {
|
|
2883
|
-
const applied = applyReplacement(content, edit, input.path, shownPath, editIndex);
|
|
4453
|
+
const applied = applyReplacement(content, edit, input.path, shownPath, options, editIndex);
|
|
2884
4454
|
content = applied.content;
|
|
2885
4455
|
replacements += applied.replacements;
|
|
4456
|
+
if (applied.matchTier === "whitespace" || matchTier === void 0) matchTier = applied.matchTier ?? matchTier;
|
|
2886
4457
|
});
|
|
2887
4458
|
await this.backend.writeFile(absolutePath, content);
|
|
2888
4459
|
return {
|
|
2889
4460
|
path: shownPath,
|
|
2890
4461
|
edits: input.edits.length,
|
|
2891
4462
|
replacements,
|
|
2892
|
-
hash: _warlock_js_fs.fs.hash.string(content)
|
|
4463
|
+
hash: _warlock_js_fs.fs.hash.string(content),
|
|
4464
|
+
...matchTier !== void 0 ? { matchTier } : {}
|
|
2893
4465
|
};
|
|
2894
4466
|
});
|
|
4467
|
+
const diagnostics = await this.diagnosticsFor("multi_edit", [shownPath]);
|
|
4468
|
+
return diagnostics === void 0 ? result : {
|
|
4469
|
+
...result,
|
|
4470
|
+
diagnostics
|
|
4471
|
+
};
|
|
2895
4472
|
}
|
|
2896
4473
|
async applyPatch(input) {
|
|
4474
|
+
const result = await this.runPatch(input);
|
|
4475
|
+
if (result.dryRun) return result;
|
|
4476
|
+
const paths = result.files.filter((file) => file.action !== "deleted").map((file) => file.path);
|
|
4477
|
+
const diagnostics = await this.diagnosticsFor("apply_patch", paths);
|
|
4478
|
+
return diagnostics === void 0 ? result : {
|
|
4479
|
+
...result,
|
|
4480
|
+
diagnostics
|
|
4481
|
+
};
|
|
4482
|
+
}
|
|
4483
|
+
runPatch(input) {
|
|
2897
4484
|
return executePatch(input, {
|
|
2898
4485
|
resolve: (inputPath) => resolveInJail(this.policy, inputPath),
|
|
2899
4486
|
exists: (absolutePath) => this.backend.exists(absolutePath),
|
|
@@ -2913,9 +4500,14 @@ var Ops = class {
|
|
|
2913
4500
|
* (argv or shell mode), then — shell mode — every redirection target and
|
|
2914
4501
|
* `cd` directory against the jail with symlinks resolved.
|
|
2915
4502
|
*/
|
|
2916
|
-
async assertCommandAllowed(command) {
|
|
2917
|
-
const verdict = checkShellCommand(this.policy, command
|
|
2918
|
-
|
|
4503
|
+
async assertCommandAllowed(command, signal) {
|
|
4504
|
+
const verdict = checkShellCommand(this.policy, command, {
|
|
4505
|
+
remembered: this.approvals,
|
|
4506
|
+
resolved: this.resolvedShell
|
|
4507
|
+
});
|
|
4508
|
+
const onAsk = this.policy.shell?.onAsk;
|
|
4509
|
+
const needsApproval = !verdict.allowed && verdict.reason === "approval-required" && verdict.asks.length > 0;
|
|
4510
|
+
if (!verdict.allowed && !(needsApproval && onAsk !== void 0)) throw new WorkspacePolicyError(verdict.message ?? `Command is not permitted by the workspace shell policy: ${command}`, {
|
|
2919
4511
|
type: "denied-command",
|
|
2920
4512
|
command,
|
|
2921
4513
|
...verdict.reason !== void 0 ? { reason: verdict.reason } : {},
|
|
@@ -2938,9 +4530,35 @@ var Ops = class {
|
|
|
2938
4530
|
reason: "not-allowed",
|
|
2939
4531
|
command
|
|
2940
4532
|
});
|
|
4533
|
+
if (needsApproval && onAsk !== void 0) await this.askForApproval(command, verdict.asks, onAsk, signal);
|
|
4534
|
+
}
|
|
4535
|
+
/** Put the commands that need approval to `shell.onAsk`; refuse the line unless it says yes. */
|
|
4536
|
+
async askForApproval(command, asks, onAsk, signal) {
|
|
4537
|
+
let decision;
|
|
4538
|
+
try {
|
|
4539
|
+
decision = await onAsk({
|
|
4540
|
+
command,
|
|
4541
|
+
asks,
|
|
4542
|
+
...signal ? { signal } : {}
|
|
4543
|
+
});
|
|
4544
|
+
} catch {
|
|
4545
|
+
decision = void 0;
|
|
4546
|
+
}
|
|
4547
|
+
if (decision?.allow !== true) {
|
|
4548
|
+
const reason = refusalReason(decision?.reason);
|
|
4549
|
+
throw new WorkspacePolicyError(`Running ${asks.map((ask) => `\`${ask.argv.join(" ")}\``).join(", ")} was not approved${reason}`, {
|
|
4550
|
+
type: "denied-command",
|
|
4551
|
+
reason: "approval-denied",
|
|
4552
|
+
command
|
|
4553
|
+
});
|
|
4554
|
+
}
|
|
4555
|
+
if (decision.remember !== void 0) {
|
|
4556
|
+
const patterns = Array.isArray(decision.remember) ? decision.remember : [decision.remember];
|
|
4557
|
+
for (const pattern of patterns) if (!this.approvals.some((entry) => samePattern(entry, pattern))) this.approvals.push(pattern);
|
|
4558
|
+
}
|
|
2941
4559
|
}
|
|
2942
4560
|
async exec(command, opts) {
|
|
2943
|
-
await this.assertCommandAllowed(command);
|
|
4561
|
+
await this.assertCommandAllowed(command, opts?.signal);
|
|
2944
4562
|
const signal = opts?.signal;
|
|
2945
4563
|
if (signal?.aborted) return {
|
|
2946
4564
|
exitCode: CANCELLED_BEFORE_START_EXIT_CODE,
|
|
@@ -2953,24 +4571,28 @@ var Ops = class {
|
|
|
2953
4571
|
const shell = this.policy.shell;
|
|
2954
4572
|
const timeoutMs = opts?.timeoutMs ?? shell?.timeoutMs;
|
|
2955
4573
|
const maxOutputBytes = shell?.maxOutputBytes ?? DEFAULT_MAX_OUTPUT_BYTES;
|
|
2956
|
-
const interpreter =
|
|
4574
|
+
const interpreter = this.resolvedShell.interpreter;
|
|
2957
4575
|
const result = await this.backend.exec(command, {
|
|
2958
4576
|
cwd: this.policy.cwd,
|
|
2959
4577
|
timeoutMs,
|
|
2960
4578
|
env: buildCommandEnv(this.policy),
|
|
2961
4579
|
...signal ? { signal } : {},
|
|
2962
|
-
...interpreter !== "none" ? { interpreter } : {}
|
|
4580
|
+
...interpreter !== "none" ? { interpreter } : {},
|
|
4581
|
+
...interpreter !== "none" && this.resolvedShell.file !== void 0 ? { interpreterPath: this.resolvedShell.file } : {}
|
|
2963
4582
|
});
|
|
2964
|
-
const stdout =
|
|
2965
|
-
const stderr =
|
|
4583
|
+
const stdout = await this.fitStream("run_shell-stdout", result.stdout, maxOutputBytes);
|
|
4584
|
+
const stderr = await this.fitStream("run_shell-stderr", result.stderr, maxOutputBytes);
|
|
2966
4585
|
const cancelled = result.cancelled === true;
|
|
4586
|
+
const truncation = streamTruncation(stdout.truncation, stderr.truncation);
|
|
2967
4587
|
return {
|
|
2968
4588
|
exitCode: result.exitCode,
|
|
2969
4589
|
stdout: stdout.value,
|
|
2970
4590
|
stderr: stderr.value,
|
|
2971
4591
|
truncated: stdout.truncated || stderr.truncated,
|
|
2972
4592
|
timedOut: result.timedOut && !cancelled,
|
|
2973
|
-
cancelled
|
|
4593
|
+
cancelled,
|
|
4594
|
+
...truncation !== void 0 ? { truncation } : {},
|
|
4595
|
+
...spilledField(truncation)
|
|
2974
4596
|
};
|
|
2975
4597
|
}
|
|
2976
4598
|
async startShell(command, opts) {
|
|
@@ -2988,7 +4610,8 @@ var Ops = class {
|
|
|
2988
4610
|
command
|
|
2989
4611
|
});
|
|
2990
4612
|
const shell = this.policy.shell;
|
|
2991
|
-
const interpreter =
|
|
4613
|
+
const interpreter = this.resolvedShell.interpreter;
|
|
4614
|
+
const interpreterPath = interpreter !== "none" ? this.resolvedShell.file : void 0;
|
|
2992
4615
|
const timeoutMs = lowerOf(background.timeoutMs, opts?.timeoutMs);
|
|
2993
4616
|
const env = buildCommandEnv(this.policy);
|
|
2994
4617
|
return this.runtime.jobs.start({
|
|
@@ -3000,12 +4623,52 @@ var Ops = class {
|
|
|
3000
4623
|
cwd: this.policy.cwd,
|
|
3001
4624
|
env,
|
|
3002
4625
|
...interpreter !== "none" ? { interpreter } : {},
|
|
4626
|
+
...interpreterPath !== void 0 ? { interpreterPath } : {},
|
|
3003
4627
|
onOutput
|
|
3004
4628
|
})
|
|
3005
4629
|
});
|
|
3006
4630
|
}
|
|
3007
|
-
shellOutput(id, opts) {
|
|
3008
|
-
|
|
4631
|
+
async shellOutput(id, opts) {
|
|
4632
|
+
const result = await this.runtime.jobs.output(id, opts);
|
|
4633
|
+
if (!this.spiller.enabled) return result;
|
|
4634
|
+
const stdout = await this.spiller.text(`shell_output-${id}-stdout`, result.stdout, "head+tail");
|
|
4635
|
+
const stderr = await this.spiller.text(`shell_output-${id}-stderr`, result.stderr, "head+tail");
|
|
4636
|
+
const truncation = streamTruncation(stdout.truncation, stderr.truncation);
|
|
4637
|
+
return truncation === void 0 ? result : {
|
|
4638
|
+
...result,
|
|
4639
|
+
stdout: stdout.value,
|
|
4640
|
+
stderr: stderr.value,
|
|
4641
|
+
truncated: true,
|
|
4642
|
+
truncation,
|
|
4643
|
+
...spilledField(truncation)
|
|
4644
|
+
};
|
|
4645
|
+
}
|
|
4646
|
+
/**
|
|
4647
|
+
* Fit one captured stream to the limits: spilled to a file when
|
|
4648
|
+
* `output.spillDir` is set, otherwise clipped at `maxBytes` as always.
|
|
4649
|
+
*/
|
|
4650
|
+
async fitStream(label, value, maxBytes) {
|
|
4651
|
+
if (this.spiller.enabled) {
|
|
4652
|
+
const cap = this.policy.shell?.maxOutputBytes;
|
|
4653
|
+
const spilled = await this.spiller.text(label, value, "head+tail", cap);
|
|
4654
|
+
if (spilled.truncation !== void 0) return {
|
|
4655
|
+
value: spilled.value,
|
|
4656
|
+
truncated: true,
|
|
4657
|
+
truncation: spilled.truncation
|
|
4658
|
+
};
|
|
4659
|
+
}
|
|
4660
|
+
const capped = capOutput(value, maxBytes);
|
|
4661
|
+
if (!capped.truncated) return capped;
|
|
4662
|
+
return {
|
|
4663
|
+
...capped,
|
|
4664
|
+
truncation: {
|
|
4665
|
+
shown: { lines: splitLines(capped.value).length },
|
|
4666
|
+
total: {
|
|
4667
|
+
lines: splitLines(value).length,
|
|
4668
|
+
bytes: Buffer.byteLength(value, "utf8")
|
|
4669
|
+
}
|
|
4670
|
+
}
|
|
4671
|
+
};
|
|
3009
4672
|
}
|
|
3010
4673
|
shellKill(id) {
|
|
3011
4674
|
return this.runtime.jobs.kill(id);
|
|
@@ -3013,8 +4676,9 @@ var Ops = class {
|
|
|
3013
4676
|
listShells() {
|
|
3014
4677
|
return this.runtime.jobs.list();
|
|
3015
4678
|
}
|
|
3016
|
-
dispose() {
|
|
3017
|
-
|
|
4679
|
+
async dispose() {
|
|
4680
|
+
await this.runtime.jobs.dispose();
|
|
4681
|
+
await this.spiller.dispose();
|
|
3018
4682
|
}
|
|
3019
4683
|
async listDir(inputPath = ".", opts) {
|
|
3020
4684
|
const { absolutePath: root, relativePath: rootRelative } = await resolveInJail(this.policy, inputPath);
|
|
@@ -3118,16 +4782,34 @@ var Ops = class {
|
|
|
3118
4782
|
line: index + 1,
|
|
3119
4783
|
text: line
|
|
3120
4784
|
});
|
|
3121
|
-
if (matches.length >= DEFAULT_MAX_GREP_MATCHES) return
|
|
3122
|
-
matches,
|
|
3123
|
-
total: matches.length
|
|
3124
|
-
};
|
|
4785
|
+
if (matches.length >= DEFAULT_MAX_GREP_MATCHES) return this.fitMatches(matches);
|
|
3125
4786
|
}
|
|
3126
4787
|
}
|
|
3127
4788
|
}
|
|
3128
|
-
return
|
|
4789
|
+
return this.fitMatches(matches);
|
|
4790
|
+
}
|
|
4791
|
+
/** Apply the `output` limits to a finished match list. */
|
|
4792
|
+
async fitMatches(matches) {
|
|
4793
|
+
const fitted = await this.spiller.list("grep", matches, (match) => `${match.path}:${match.line}:${match.text}`, "head");
|
|
4794
|
+
return fitted.truncation === void 0 ? {
|
|
3129
4795
|
matches,
|
|
3130
4796
|
total: matches.length
|
|
4797
|
+
} : {
|
|
4798
|
+
matches: fitted.items,
|
|
4799
|
+
total: matches.length,
|
|
4800
|
+
truncated: true,
|
|
4801
|
+
truncation: fitted.truncation,
|
|
4802
|
+
...spilledField({ stdout: fitted.truncation })
|
|
4803
|
+
};
|
|
4804
|
+
}
|
|
4805
|
+
async globResult(pattern) {
|
|
4806
|
+
const all = await this.glob(pattern);
|
|
4807
|
+
const fitted = await this.spiller.list("glob", all, (entry) => entry, "head");
|
|
4808
|
+
return fitted.truncation === void 0 ? { paths: all } : {
|
|
4809
|
+
paths: fitted.items,
|
|
4810
|
+
truncated: true,
|
|
4811
|
+
truncation: fitted.truncation,
|
|
4812
|
+
...spilledField({ stdout: fitted.truncation })
|
|
3131
4813
|
};
|
|
3132
4814
|
}
|
|
3133
4815
|
async glob(pattern) {
|
|
@@ -3160,55 +4842,23 @@ var Ops = class {
|
|
|
3160
4842
|
return denyPaths !== void 0 && denyPaths.length > 0 && matchesAnyGlob(relativePath, denyPaths);
|
|
3161
4843
|
}
|
|
3162
4844
|
};
|
|
3163
|
-
/**
|
|
3164
|
-
function
|
|
3165
|
-
|
|
3166
|
-
return
|
|
4845
|
+
/** `{ spilledTo }` for every spill file named in the truncation metadata, or nothing. */
|
|
4846
|
+
function spilledField(truncation) {
|
|
4847
|
+
const paths = [truncation?.stdout?.path, truncation?.stderr?.path].filter((file) => file !== void 0);
|
|
4848
|
+
return paths.length > 0 ? { spilledTo: paths } : {};
|
|
3167
4849
|
}
|
|
3168
|
-
/**
|
|
3169
|
-
|
|
3170
|
-
|
|
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
|
-
});
|
|
4850
|
+
/** Combine per-stream truncation metadata; `undefined` when neither stream was clipped. */
|
|
4851
|
+
function streamTruncation(stdout, stderr) {
|
|
4852
|
+
if (stdout === void 0 && stderr === void 0) return;
|
|
3189
4853
|
return {
|
|
3190
|
-
|
|
3191
|
-
|
|
4854
|
+
...stdout !== void 0 ? { stdout } : {},
|
|
4855
|
+
...stderr !== void 0 ? { stderr } : {}
|
|
3192
4856
|
};
|
|
3193
4857
|
}
|
|
3194
|
-
/**
|
|
3195
|
-
function
|
|
3196
|
-
if (
|
|
3197
|
-
|
|
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);
|
|
4858
|
+
/** The lower of two optional limits (either may be absent). */
|
|
4859
|
+
function lowerOf(policyLimit, requested) {
|
|
4860
|
+
if (requested === void 0 || requested <= 0) return policyLimit;
|
|
4861
|
+
return policyLimit === void 0 ? requested : Math.min(policyLimit, requested);
|
|
3212
4862
|
}
|
|
3213
4863
|
/**
|
|
3214
4864
|
* Create the policy-enforced operation layer over a backend.
|
|
@@ -3337,25 +4987,39 @@ const POSIX_SHELL = "/bin/sh";
|
|
|
3337
4987
|
* `/s` keeps the line verbatim inside the outer quotes). The ops layer has
|
|
3338
4988
|
* already checked every command in the line.
|
|
3339
4989
|
*/
|
|
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
|
-
|
|
4990
|
+
function planSpawn(command, interpreter, interpreterPath, hostPlatform = node_process.platform) {
|
|
4991
|
+
if (interpreter === "powershell") return {
|
|
4992
|
+
file: interpreterPath ?? (hostPlatform === "win32" ? "powershell.exe" : "pwsh"),
|
|
4993
|
+
args: [
|
|
4994
|
+
"-NoLogo",
|
|
4995
|
+
"-NoProfile",
|
|
4996
|
+
"-NonInteractive",
|
|
4997
|
+
"-Command",
|
|
4998
|
+
command
|
|
4999
|
+
],
|
|
5000
|
+
windowsVerbatimArguments: false
|
|
5001
|
+
};
|
|
5002
|
+
if (interpreter === "bash") return {
|
|
5003
|
+
file: interpreterPath ?? "bash",
|
|
5004
|
+
args: ["-c", command],
|
|
5005
|
+
windowsVerbatimArguments: false
|
|
5006
|
+
};
|
|
5007
|
+
if (interpreter === "cmd" || interpreter === "sh" && hostPlatform === "win32") return {
|
|
5008
|
+
file: interpreterPath ?? process.env.ComSpec ?? "cmd.exe",
|
|
5009
|
+
args: [
|
|
5010
|
+
"/d",
|
|
5011
|
+
"/v:off",
|
|
5012
|
+
"/s",
|
|
5013
|
+
"/c",
|
|
5014
|
+
`"${command}"`
|
|
5015
|
+
],
|
|
5016
|
+
windowsVerbatimArguments: true
|
|
5017
|
+
};
|
|
5018
|
+
if (interpreter === "sh") return {
|
|
5019
|
+
file: interpreterPath ?? POSIX_SHELL,
|
|
5020
|
+
args: ["-c", command],
|
|
5021
|
+
windowsVerbatimArguments: false
|
|
5022
|
+
};
|
|
3359
5023
|
const argv = tokenizeCommand(command);
|
|
3360
5024
|
const executable = argv?.[0];
|
|
3361
5025
|
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 +5175,7 @@ var LocalBackend = class {
|
|
|
3511
5175
|
});
|
|
3512
5176
|
return;
|
|
3513
5177
|
}
|
|
3514
|
-
const plan = planSpawn(command, opts.interpreter ?? "none");
|
|
5178
|
+
const plan = planSpawn(command, opts.interpreter ?? "none", opts.interpreterPath);
|
|
3515
5179
|
if ("refused" in plan) {
|
|
3516
5180
|
refuse(plan.refused);
|
|
3517
5181
|
return;
|
|
@@ -3585,7 +5249,7 @@ var LocalBackend = class {
|
|
|
3585
5249
|
* still-running tree is also killed when the Node process exits.
|
|
3586
5250
|
*/
|
|
3587
5251
|
start(command, opts) {
|
|
3588
|
-
const plan = planSpawn(command, opts.interpreter ?? "none");
|
|
5252
|
+
const plan = planSpawn(command, opts.interpreter ?? "none", opts.interpreterPath);
|
|
3589
5253
|
if ("refused" in plan) {
|
|
3590
5254
|
opts.onOutput("stderr", plan.refused);
|
|
3591
5255
|
return {
|
|
@@ -4310,6 +5974,7 @@ function makeGlobTool(ops, options) {
|
|
|
4310
5974
|
action: (input) => `Finding files matching ${input.pattern}`,
|
|
4311
5975
|
input: globInputSchema,
|
|
4312
5976
|
async execute(input) {
|
|
5977
|
+
if (ops.globResult !== void 0) return ops.globResult(input.pattern);
|
|
4313
5978
|
return { paths: await ops.glob(input.pattern) };
|
|
4314
5979
|
}
|
|
4315
5980
|
});
|
|
@@ -4605,6 +6270,24 @@ function runShellInputSchema(background) {
|
|
|
4605
6270
|
}
|
|
4606
6271
|
} }, runShellJsonSchema(background));
|
|
4607
6272
|
}
|
|
6273
|
+
const DIALECT_RULES = {
|
|
6274
|
+
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.",
|
|
6275
|
+
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.",
|
|
6276
|
+
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.",
|
|
6277
|
+
powershell5: "Use Windows PowerShell 5.1 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). This version cannot chain with `&&` or `||`: put one command per call, or join with `;` (which runs the next command whatever the exit code). Script blocks, `Invoke-Expression`, here-strings, splatting and `if` / `foreach` are not supported."
|
|
6278
|
+
};
|
|
6279
|
+
const PLATFORM_NAMES = {
|
|
6280
|
+
win32: "Windows",
|
|
6281
|
+
darwin: "macOS",
|
|
6282
|
+
linux: "Linux"
|
|
6283
|
+
};
|
|
6284
|
+
/** The sentence that names the shell and its syntax, or `""` in argv mode. */
|
|
6285
|
+
function shellSentence(options) {
|
|
6286
|
+
const dialect = options?.shellDialect;
|
|
6287
|
+
if (options?.shellLabel === void 0 || dialect === void 0 || dialect === "none") return "";
|
|
6288
|
+
const platform = options.platform ?? process.platform;
|
|
6289
|
+
return ` Commands run on ${PLATFORM_NAMES[platform] ?? platform} using ${options.shellLabel}. ` + DIALECT_RULES[dialect];
|
|
6290
|
+
}
|
|
4608
6291
|
/** The lower of the model's timeout and the cap (either may be absent). */
|
|
4609
6292
|
function cappedTimeout(requested, cap) {
|
|
4610
6293
|
if (requested === void 0) return cap;
|
|
@@ -4614,7 +6297,7 @@ function makeRunShellTool(ops, options) {
|
|
|
4614
6297
|
const background = options?.background === true;
|
|
4615
6298
|
return (0, _warlock_js_ai.tool)({
|
|
4616
6299
|
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." : ""),
|
|
6300
|
+
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
6301
|
action: (input) => `Running \`${input.command}\``,
|
|
4619
6302
|
input: runShellInputSchema(background),
|
|
4620
6303
|
execute: async (input, ctx) => {
|
|
@@ -4674,8 +6357,9 @@ const runTestsInputSchema = withJsonSchema({ "~standard": {
|
|
|
4674
6357
|
}
|
|
4675
6358
|
} }, RUN_TESTS_JSON_SCHEMA);
|
|
4676
6359
|
/** Quote the test pattern as one literal argument for the line's interpreter. */
|
|
4677
|
-
function quotePattern(pattern, interpreter) {
|
|
4678
|
-
if (
|
|
6360
|
+
function quotePattern(pattern, interpreter, dialect, platform = process.platform) {
|
|
6361
|
+
if (dialect === "powershell" || dialect === "powershell5" || interpreter === "powershell") return `'${pattern.replace(/['\u2018\u2019\u201A\u201B]/g, "$&$&")}'`;
|
|
6362
|
+
if (interpreter === "bash" || interpreter === "sh" && platform !== "win32") return `'${pattern.replace(/'/g, "'\\''")}'`;
|
|
4679
6363
|
return `"${pattern}"`;
|
|
4680
6364
|
}
|
|
4681
6365
|
/**
|
|
@@ -4710,7 +6394,7 @@ function makeRunTestsTool(ops, options) {
|
|
|
4710
6394
|
action: (input) => input.pattern ? `Running tests matching "${input.pattern}"` : "Running tests",
|
|
4711
6395
|
input: runTestsInputSchema,
|
|
4712
6396
|
execute: (input, ctx) => {
|
|
4713
|
-
const command = input.pattern ? `${baseCommand} ${quotePattern(input.pattern, options?.interpreter ?? "none")}` : baseCommand;
|
|
6397
|
+
const command = input.pattern ? `${baseCommand} ${quotePattern(input.pattern, options?.interpreter ?? "none", options?.dialect, options?.platform)}` : baseCommand;
|
|
4714
6398
|
return ops.exec(command, ctx?.signal ? { signal: ctx.signal } : void 0);
|
|
4715
6399
|
}
|
|
4716
6400
|
});
|
|
@@ -5016,11 +6700,12 @@ function makeWriteFileTool(ops, options) {
|
|
|
5016
6700
|
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
6701
|
input: inputSchema,
|
|
5018
6702
|
async execute(input) {
|
|
5019
|
-
const { hash, bytesWritten } = await ops.writeFile(input.path, input.content);
|
|
6703
|
+
const { hash, bytesWritten, diagnostics } = await ops.writeFile(input.path, input.content);
|
|
5020
6704
|
return {
|
|
5021
6705
|
path: input.path,
|
|
5022
6706
|
bytesWritten,
|
|
5023
|
-
hash
|
|
6707
|
+
hash,
|
|
6708
|
+
...diagnostics !== void 0 ? { diagnostics } : {}
|
|
5024
6709
|
};
|
|
5025
6710
|
}
|
|
5026
6711
|
});
|
|
@@ -5094,10 +6779,26 @@ var WorkspaceImpl = class WorkspaceImpl {
|
|
|
5094
6779
|
this.allowedTools = new Set(allowedTools);
|
|
5095
6780
|
this.state = state ?? {
|
|
5096
6781
|
jobs: new ShellJobs(),
|
|
5097
|
-
todos: new TodoStore()
|
|
6782
|
+
todos: new TodoStore(),
|
|
6783
|
+
approvals: []
|
|
6784
|
+
};
|
|
6785
|
+
this.resolvedShell = resolveShell(policy.shell);
|
|
6786
|
+
const approvals = this.state.approvals;
|
|
6787
|
+
this.shell = {
|
|
6788
|
+
classify: (line) => classifyShellCommand(line, policy),
|
|
6789
|
+
remembered: () => [...approvals],
|
|
6790
|
+
forget: (pattern) => {
|
|
6791
|
+
const index = approvals.findIndex((entry) => samePattern(entry, pattern));
|
|
6792
|
+
if (index === -1) return false;
|
|
6793
|
+
approvals.splice(index, 1);
|
|
6794
|
+
return true;
|
|
6795
|
+
}
|
|
5098
6796
|
};
|
|
5099
6797
|
const backend = selectBackend(policy);
|
|
5100
|
-
this.ops = createOps(backend, policy, {
|
|
6798
|
+
this.ops = createOps(backend, policy, {
|
|
6799
|
+
jobs: this.state.jobs,
|
|
6800
|
+
approvals: this.state.approvals
|
|
6801
|
+
});
|
|
5101
6802
|
this.tools = this.buildTools();
|
|
5102
6803
|
}
|
|
5103
6804
|
/**
|
|
@@ -5111,7 +6812,7 @@ var WorkspaceImpl = class WorkspaceImpl {
|
|
|
5111
6812
|
const erase = (contract) => contract;
|
|
5112
6813
|
const shell = this.policy.shell;
|
|
5113
6814
|
const background = shell?.background !== void 0;
|
|
5114
|
-
const
|
|
6815
|
+
const resolvedShell = this.resolvedShell;
|
|
5115
6816
|
const factories = {
|
|
5116
6817
|
readFile: (opts) => erase(makeReadFileTool(this.ops, opts)),
|
|
5117
6818
|
editFile: (opts) => erase(makeEditFileTool(this.ops, opts)),
|
|
@@ -5122,13 +6823,16 @@ var WorkspaceImpl = class WorkspaceImpl {
|
|
|
5122
6823
|
runShell: (opts) => erase(makeRunShellTool(this.ops, {
|
|
5123
6824
|
...opts?.name !== void 0 ? { name: opts.name } : {},
|
|
5124
6825
|
...shell?.timeoutMs !== void 0 ? { maxTimeoutMs: shell.timeoutMs } : {},
|
|
5125
|
-
background
|
|
6826
|
+
background,
|
|
6827
|
+
...resolvedShell.interpreter !== "none" ? { shellLabel: resolvedShell.label } : {},
|
|
6828
|
+
shellDialect: resolvedShell.interpreter === "none" ? "none" : resolvedShell.dialect
|
|
5126
6829
|
})),
|
|
5127
6830
|
shellOutput: (opts) => erase(makeShellOutputTool(this.ops, opts)),
|
|
5128
6831
|
shellKill: (opts) => erase(makeShellKillTool(this.ops, opts)),
|
|
5129
6832
|
runTests: (opts) => erase(makeRunTestsTool(this.ops, {
|
|
5130
6833
|
...opts,
|
|
5131
|
-
interpreter
|
|
6834
|
+
interpreter: resolvedShell.interpreter,
|
|
6835
|
+
dialect: resolvedShell.dialect
|
|
5132
6836
|
})),
|
|
5133
6837
|
grep: (opts) => erase(makeGrepTool(this.ops, opts)),
|
|
5134
6838
|
glob: (opts) => erase(makeGlobTool(this.ops, opts)),
|
|
@@ -5311,6 +7015,7 @@ exports.applyHunks = applyHunks;
|
|
|
5311
7015
|
exports.applyPatchWithOps = applyPatchWithOps;
|
|
5312
7016
|
exports.buildEnv = buildEnv;
|
|
5313
7017
|
exports.checkShellCommand = checkShellCommand;
|
|
7018
|
+
exports.classifyShellCommand = classifyShellCommand;
|
|
5314
7019
|
exports.createLocalBackend = createLocalBackend;
|
|
5315
7020
|
exports.createMockBackend = createMockBackend;
|
|
5316
7021
|
exports.createOps = createOps;
|
|
@@ -5331,6 +7036,7 @@ exports.makeTodoWriteTool = makeTodoWriteTool;
|
|
|
5331
7036
|
exports.makeWriteFileTool = makeWriteFileTool;
|
|
5332
7037
|
exports.parsePatch = parsePatch;
|
|
5333
7038
|
exports.resolveInJail = resolveInJail;
|
|
7039
|
+
exports.resolveShell = resolveShell;
|
|
5334
7040
|
exports.tokenizeCommand = tokenizeCommand;
|
|
5335
7041
|
exports.workspace = workspace;
|
|
5336
7042
|
//# sourceMappingURL=index.cjs.map
|