@f5-sales-demo/xcsh 19.101.1 → 19.101.2
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/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"type": "module",
|
|
3
3
|
"name": "@f5-sales-demo/xcsh",
|
|
4
|
-
"version": "19.101.
|
|
4
|
+
"version": "19.101.2",
|
|
5
5
|
"description": "Coding agent CLI with read, bash, edit, write tools and session management",
|
|
6
6
|
"homepage": "https://github.com/f5-sales-demo/xcsh",
|
|
7
7
|
"author": "Can Boluk",
|
|
@@ -57,13 +57,13 @@
|
|
|
57
57
|
"dependencies": {
|
|
58
58
|
"@agentclientprotocol/sdk": "1.3.0",
|
|
59
59
|
"@mozilla/readability": "^0.6",
|
|
60
|
-
"@f5-sales-demo/xcsh-stats": "19.101.
|
|
61
|
-
"@f5-sales-demo/pi-agent-core": "19.101.
|
|
62
|
-
"@f5-sales-demo/pi-ai": "19.101.
|
|
63
|
-
"@f5-sales-demo/pi-natives": "19.101.
|
|
64
|
-
"@f5-sales-demo/pi-resource-management": "19.101.
|
|
65
|
-
"@f5-sales-demo/pi-tui": "19.101.
|
|
66
|
-
"@f5-sales-demo/pi-utils": "19.101.
|
|
60
|
+
"@f5-sales-demo/xcsh-stats": "19.101.2",
|
|
61
|
+
"@f5-sales-demo/pi-agent-core": "19.101.2",
|
|
62
|
+
"@f5-sales-demo/pi-ai": "19.101.2",
|
|
63
|
+
"@f5-sales-demo/pi-natives": "19.101.2",
|
|
64
|
+
"@f5-sales-demo/pi-resource-management": "19.101.2",
|
|
65
|
+
"@f5-sales-demo/pi-tui": "19.101.2",
|
|
66
|
+
"@f5-sales-demo/pi-utils": "19.101.2",
|
|
67
67
|
"@sinclair/typebox": "^0.34",
|
|
68
68
|
"@xterm/headless": "^6.0",
|
|
69
69
|
"ajv": "^8.20",
|
|
@@ -17,17 +17,17 @@ export interface BuildInfo {
|
|
|
17
17
|
}
|
|
18
18
|
|
|
19
19
|
export const BUILD_INFO: BuildInfo = {
|
|
20
|
-
"version": "19.101.
|
|
21
|
-
"commit": "
|
|
22
|
-
"shortCommit": "
|
|
20
|
+
"version": "19.101.2",
|
|
21
|
+
"commit": "aa04beb39731933015b85ba67e1107c2c301ac01",
|
|
22
|
+
"shortCommit": "aa04beb",
|
|
23
23
|
"branch": "main",
|
|
24
|
-
"tag": "v19.101.
|
|
25
|
-
"commitDate": "2026-07-
|
|
26
|
-
"buildDate": "2026-07-
|
|
24
|
+
"tag": "v19.101.2",
|
|
25
|
+
"commitDate": "2026-07-30T03:09:56Z",
|
|
26
|
+
"buildDate": "2026-07-30T03:38:03.284Z",
|
|
27
27
|
"dirty": true,
|
|
28
28
|
"prNumber": "",
|
|
29
29
|
"repoUrl": "https://github.com/f5-sales-demo/xcsh",
|
|
30
30
|
"repoSlug": "f5-sales-demo/xcsh",
|
|
31
|
-
"commitUrl": "https://github.com/f5-sales-demo/xcsh/commit/
|
|
32
|
-
"releaseUrl": "https://github.com/f5-sales-demo/xcsh/releases/tag/v19.101.
|
|
31
|
+
"commitUrl": "https://github.com/f5-sales-demo/xcsh/commit/aa04beb39731933015b85ba67e1107c2c301ac01",
|
|
32
|
+
"releaseUrl": "https://github.com/f5-sales-demo/xcsh/releases/tag/v19.101.2"
|
|
33
33
|
};
|
|
@@ -278,3 +278,380 @@ export function provenExemptWords(cmd: ShellSimpleCommand): ShellWord[] {
|
|
|
278
278
|
|
|
279
279
|
return exempt;
|
|
280
280
|
}
|
|
281
|
+
|
|
282
|
+
/** Flag-only options shared by the compressors. */
|
|
283
|
+
const GZIP_BOOLEAN_OPTIONS = [
|
|
284
|
+
"-1",
|
|
285
|
+
"-2",
|
|
286
|
+
"-3",
|
|
287
|
+
"-4",
|
|
288
|
+
"-5",
|
|
289
|
+
"-6",
|
|
290
|
+
"-7",
|
|
291
|
+
"-8",
|
|
292
|
+
"-9",
|
|
293
|
+
"-c",
|
|
294
|
+
"--stdout",
|
|
295
|
+
"-d",
|
|
296
|
+
"--decompress",
|
|
297
|
+
"-f",
|
|
298
|
+
"--force",
|
|
299
|
+
"-k",
|
|
300
|
+
"--keep",
|
|
301
|
+
"-q",
|
|
302
|
+
"--quiet",
|
|
303
|
+
"-r",
|
|
304
|
+
"--recursive",
|
|
305
|
+
"-t",
|
|
306
|
+
"--test",
|
|
307
|
+
"-v",
|
|
308
|
+
"--verbose",
|
|
309
|
+
] as const satisfies readonly string[];
|
|
310
|
+
|
|
311
|
+
/** curl options that consume a separate value, so it is not mistaken for a path. */
|
|
312
|
+
const CURL_VALUE_OPTIONS = [
|
|
313
|
+
"-H",
|
|
314
|
+
"--header",
|
|
315
|
+
"-X",
|
|
316
|
+
"--request",
|
|
317
|
+
"-d",
|
|
318
|
+
"--data",
|
|
319
|
+
"-u",
|
|
320
|
+
"--user",
|
|
321
|
+
"-A",
|
|
322
|
+
"--user-agent",
|
|
323
|
+
"--connect-timeout",
|
|
324
|
+
"--max-time",
|
|
325
|
+
"-m",
|
|
326
|
+
"--retry",
|
|
327
|
+
"-b",
|
|
328
|
+
"--cookie",
|
|
329
|
+
"-c",
|
|
330
|
+
"--cookie-jar",
|
|
331
|
+
] as const satisfies readonly string[];
|
|
332
|
+
|
|
333
|
+
/** cp's flag-only options; shared so the mv/install entries stay readable. */
|
|
334
|
+
const COPY_BOOLEAN_OPTIONS = [
|
|
335
|
+
"-a",
|
|
336
|
+
"--archive",
|
|
337
|
+
"-b",
|
|
338
|
+
"--backup",
|
|
339
|
+
"-d",
|
|
340
|
+
"-f",
|
|
341
|
+
"--force",
|
|
342
|
+
"-i",
|
|
343
|
+
"--interactive",
|
|
344
|
+
"-H",
|
|
345
|
+
"-l",
|
|
346
|
+
"--link",
|
|
347
|
+
"-L",
|
|
348
|
+
"--dereference",
|
|
349
|
+
"-n",
|
|
350
|
+
"--no-clobber",
|
|
351
|
+
"-P",
|
|
352
|
+
"--no-dereference",
|
|
353
|
+
"-p",
|
|
354
|
+
"-R",
|
|
355
|
+
"-r",
|
|
356
|
+
"--recursive",
|
|
357
|
+
"-s",
|
|
358
|
+
"--symbolic-link",
|
|
359
|
+
"-u",
|
|
360
|
+
"--update",
|
|
361
|
+
"-v",
|
|
362
|
+
"--verbose",
|
|
363
|
+
"-x",
|
|
364
|
+
"--one-file-system",
|
|
365
|
+
] as const satisfies readonly string[];
|
|
366
|
+
|
|
367
|
+
/**
|
|
368
|
+
* Which operands a command *writes*, so the boundary check picks the right direction.
|
|
369
|
+
*
|
|
370
|
+
* The read/write split in `enforce.ts` is derived from shell redirections. A write the invoked program
|
|
371
|
+
* performs on one of its own operands — `tee FILE`, `dd of=FILE`, `cp SRC DST`, `sort -o FILE` — had no
|
|
372
|
+
* such signal and defaulted to a read check. Under an `allowRead`-only grant that check passes, so the
|
|
373
|
+
* write lands on a path shared for reading only; confirmed at the decision layer, where all four returned
|
|
374
|
+
* `block: false` (GHSA-q4hg). The same misclassification refused those commands against a *write-only*
|
|
375
|
+
* grant, which is the mirror-image false refusal.
|
|
376
|
+
*
|
|
377
|
+
* Adding to this table is monotonic. Marking an operand as a write that is really a read costs a false
|
|
378
|
+
* refusal, which is the direction the surrounding code already errs in; missing one leaves today's
|
|
379
|
+
* behaviour untouched. So it is safe to extend, and deliberately incomplete rather than guessed at:
|
|
380
|
+
* `tar -f` is absent because whether it reads or writes depends on the mode letters, and getting that
|
|
381
|
+
* wrong in the permissive direction is the bug being fixed.
|
|
382
|
+
*
|
|
383
|
+
* Same suppression rule as `provenExemptWords`: an option this model cannot parse exactly shifts operand
|
|
384
|
+
* positions, so anything unrecognized abandons the command rather than guessing at a slot.
|
|
385
|
+
*/
|
|
386
|
+
interface WriteOperandSpec {
|
|
387
|
+
/** Options taking a separate value, so it is not mistaken for a positional operand. */
|
|
388
|
+
valueOptions: readonly string[];
|
|
389
|
+
/** Options that are flags only. */
|
|
390
|
+
booleanOptions: readonly string[];
|
|
391
|
+
/** `--output=F` style options whose value is the written path. */
|
|
392
|
+
outputOptions?: readonly string[];
|
|
393
|
+
/** `of=F` style prefixes whose value is the written path. */
|
|
394
|
+
outputPrefixes?: readonly string[];
|
|
395
|
+
/** Every positional operand is written (`tee a b c`). */
|
|
396
|
+
writesAllPositional?: boolean;
|
|
397
|
+
/** The final positional operand is written (`cp src dst`). */
|
|
398
|
+
writesLastPositional?: boolean;
|
|
399
|
+
/** With this option present the destination moves into it, so positional slots stop being writes. */
|
|
400
|
+
destinationOption?: string;
|
|
401
|
+
/** Positional operands that are not paths at all — `chmod 644 f`, `chown me f`. */
|
|
402
|
+
skipLeadingPositional?: number;
|
|
403
|
+
/** Every positional is written once this option appears (`install -d a b c`). */
|
|
404
|
+
allPositionalOption?: string;
|
|
405
|
+
/** Sources are written too, because the command removes them (`mv`). */
|
|
406
|
+
writesSourcesToo?: boolean;
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
const WRITE_OPERAND_SPECS: Record<string, WriteOperandSpec> = {
|
|
410
|
+
// tee writes every file operand; it reads stdin, never the files.
|
|
411
|
+
tee: {
|
|
412
|
+
valueOptions: [],
|
|
413
|
+
booleanOptions: ["-a", "--append", "-i", "--ignore-interrupts", "-p"],
|
|
414
|
+
writesAllPositional: true,
|
|
415
|
+
},
|
|
416
|
+
// `of=` is the output file. `if=` stays a read, which is what the floor already gives it.
|
|
417
|
+
dd: { valueOptions: [], booleanOptions: [], outputPrefixes: ["of="] },
|
|
418
|
+
sort: {
|
|
419
|
+
valueOptions: ["-k", "-t", "-S", "-T", "--key", "--field-separator", "--buffer-size"],
|
|
420
|
+
booleanOptions: ["-b", "-d", "-f", "-g", "-i", "-M", "-h", "-n", "-R", "-r", "-u", "-V", "-z", "-c", "-s"],
|
|
421
|
+
outputOptions: ["-o", "--output"],
|
|
422
|
+
},
|
|
423
|
+
cp: {
|
|
424
|
+
valueOptions: ["-S", "--suffix", "-t", "--target-directory"],
|
|
425
|
+
booleanOptions: COPY_BOOLEAN_OPTIONS,
|
|
426
|
+
writesLastPositional: true,
|
|
427
|
+
destinationOption: "-t",
|
|
428
|
+
},
|
|
429
|
+
// `mv` also REMOVES its sources, so a source in a read-only root is a mutation of that root.
|
|
430
|
+
// Marking only the destination let `mv /shared/ctx/file .` delete from a read-allowed grant.
|
|
431
|
+
mv: {
|
|
432
|
+
valueOptions: ["-S", "--suffix", "-t", "--target-directory"],
|
|
433
|
+
booleanOptions: ["-f", "--force", "-i", "--interactive", "-n", "--no-clobber", "-v", "--verbose", "-u"],
|
|
434
|
+
writesLastPositional: true,
|
|
435
|
+
writesSourcesToo: true,
|
|
436
|
+
destinationOption: "-t",
|
|
437
|
+
},
|
|
438
|
+
install: {
|
|
439
|
+
valueOptions: ["-m", "--mode", "-o", "--owner", "-g", "--group", "-t", "--target-directory", "-S", "--suffix"],
|
|
440
|
+
booleanOptions: ["-b", "-c", "-C", "-d", "-D", "-p", "-s", "-v", "--verbose", "--backup"],
|
|
441
|
+
writesLastPositional: true,
|
|
442
|
+
destinationOption: "-t",
|
|
443
|
+
// `install -d a b c` creates every operand as a directory rather than copying into the last.
|
|
444
|
+
allPositionalOption: "-d",
|
|
445
|
+
},
|
|
446
|
+
// Plain mutators: every path operand is written. Their absence was the largest hole in the table —
|
|
447
|
+
// `rm` and `touch` against a read-only root are the obvious cases, and both were classified as reads.
|
|
448
|
+
rm: {
|
|
449
|
+
valueOptions: [],
|
|
450
|
+
booleanOptions: ["-f", "--force", "-i", "-I", "-r", "-R", "--recursive", "-d", "--dir", "-v", "--verbose"],
|
|
451
|
+
writesAllPositional: true,
|
|
452
|
+
},
|
|
453
|
+
rmdir: { valueOptions: [], booleanOptions: ["-p", "--parents", "-v", "--verbose"], writesAllPositional: true },
|
|
454
|
+
touch: {
|
|
455
|
+
valueOptions: ["-d", "--date", "-r", "--reference", "-t"],
|
|
456
|
+
booleanOptions: ["-a", "-c", "--no-create", "-f", "-h", "--no-dereference", "-m"],
|
|
457
|
+
writesAllPositional: true,
|
|
458
|
+
},
|
|
459
|
+
mkdir: {
|
|
460
|
+
valueOptions: ["-m", "--mode"],
|
|
461
|
+
booleanOptions: ["-p", "--parents", "-v", "--verbose"],
|
|
462
|
+
writesAllPositional: true,
|
|
463
|
+
},
|
|
464
|
+
ln: {
|
|
465
|
+
valueOptions: ["-S", "--suffix", "-t", "--target-directory"],
|
|
466
|
+
booleanOptions: ["-b", "-f", "--force", "-i", "-L", "-n", "-P", "-r", "-s", "--symbolic", "-v"],
|
|
467
|
+
writesLastPositional: true,
|
|
468
|
+
destinationOption: "-t",
|
|
469
|
+
},
|
|
470
|
+
shred: {
|
|
471
|
+
valueOptions: ["-n", "--iterations", "-s", "--size"],
|
|
472
|
+
booleanOptions: ["-f", "--force", "-u", "--remove", "-v", "--verbose", "-x", "-z", "--zero"],
|
|
473
|
+
writesAllPositional: true,
|
|
474
|
+
},
|
|
475
|
+
unlink: { valueOptions: [], booleanOptions: [], writesAllPositional: true },
|
|
476
|
+
// The first positional is a mode or an owner, not a path.
|
|
477
|
+
chmod: {
|
|
478
|
+
valueOptions: ["--reference"],
|
|
479
|
+
booleanOptions: ["-c", "-f", "-v", "-R", "--recursive", "--silent", "--changes", "--verbose"],
|
|
480
|
+
writesAllPositional: true,
|
|
481
|
+
skipLeadingPositional: 1,
|
|
482
|
+
},
|
|
483
|
+
chown: {
|
|
484
|
+
valueOptions: ["--reference", "--from"],
|
|
485
|
+
booleanOptions: ["-c", "-f", "-v", "-R", "--recursive", "-h", "--no-dereference", "--silent", "--changes"],
|
|
486
|
+
writesAllPositional: true,
|
|
487
|
+
skipLeadingPositional: 1,
|
|
488
|
+
},
|
|
489
|
+
// In-place editors and compressors REPLACE the file they are given.
|
|
490
|
+
//
|
|
491
|
+
// `sed -i` has famously ambiguous arity — GNU takes an attached suffix, BSD a separate one — so
|
|
492
|
+
// which positional is the script cannot be settled. Every literal positional is therefore marked
|
|
493
|
+
// written, which over-marks the script operand. That is the safe direction: a false refusal, not a
|
|
494
|
+
// permitted write. The marking only applies when `-i` is present at all.
|
|
495
|
+
sed: {
|
|
496
|
+
valueOptions: ["-e", "--expression", "-f", "--file", "-l", "--line-length"],
|
|
497
|
+
booleanOptions: ["-n", "--quiet", "--silent", "-E", "-r", "--regexp-extended", "-s", "-u", "-z", "--debug"],
|
|
498
|
+
writesAllPositional: false,
|
|
499
|
+
allPositionalOption: "-i",
|
|
500
|
+
},
|
|
501
|
+
gzip: { valueOptions: ["-S", "--suffix"], booleanOptions: GZIP_BOOLEAN_OPTIONS, writesAllPositional: true },
|
|
502
|
+
gunzip: { valueOptions: ["-S", "--suffix"], booleanOptions: GZIP_BOOLEAN_OPTIONS, writesAllPositional: true },
|
|
503
|
+
bzip2: { valueOptions: [], booleanOptions: GZIP_BOOLEAN_OPTIONS, writesAllPositional: true },
|
|
504
|
+
xz: { valueOptions: ["-T", "--threads"], booleanOptions: GZIP_BOOLEAN_OPTIONS, writesAllPositional: true },
|
|
505
|
+
// Downloaders name their output explicitly; the URL operand is not a path.
|
|
506
|
+
curl: {
|
|
507
|
+
valueOptions: CURL_VALUE_OPTIONS,
|
|
508
|
+
booleanOptions: ["-L", "--location", "-s", "--silent", "-S", "--show-error", "-f", "--fail", "-k", "-I", "-v"],
|
|
509
|
+
outputOptions: ["-o", "--output"],
|
|
510
|
+
},
|
|
511
|
+
wget: {
|
|
512
|
+
valueOptions: ["--user-agent", "--header", "-P", "--directory-prefix", "-T", "--timeout"],
|
|
513
|
+
booleanOptions: ["-q", "--quiet", "-c", "--continue", "-N", "--no-verbose", "-nv"],
|
|
514
|
+
outputOptions: ["-O", "--output-document"],
|
|
515
|
+
},
|
|
516
|
+
truncate: {
|
|
517
|
+
valueOptions: ["-s", "--size", "-r", "--reference"],
|
|
518
|
+
booleanOptions: ["-c", "--no-create", "-o", "--io-blocks"],
|
|
519
|
+
writesAllPositional: true,
|
|
520
|
+
},
|
|
521
|
+
};
|
|
522
|
+
|
|
523
|
+
/**
|
|
524
|
+
* Words of `cmd` the invoked command writes to.
|
|
525
|
+
*
|
|
526
|
+
* Empty whenever anything is uncertain — an unknown command, an unrecognized option, a non-literal word.
|
|
527
|
+
* A word returned here is checked against the write boundary instead of the read one.
|
|
528
|
+
*/
|
|
529
|
+
export function writtenOperandWords(cmd: ShellSimpleCommand): ShellWord[] {
|
|
530
|
+
if (cmd.name === undefined) return [];
|
|
531
|
+
const spec = WRITE_OPERAND_SPECS[cmd.name];
|
|
532
|
+
if (spec === undefined) return [];
|
|
533
|
+
|
|
534
|
+
const operandWords = cmd.words.slice(cmd.operandStart).filter(word => word.redirect === undefined);
|
|
535
|
+
|
|
536
|
+
// An unparsable option shifts every positional slot, so abandon rather than mark the wrong word.
|
|
537
|
+
// Everything after `--` is a positional, however much it looks like an option — otherwise a file
|
|
538
|
+
// literally named `-t` makes `cp -- -t /drop/secret out/` read as a target-directory option and
|
|
539
|
+
// relabels a source as a write target.
|
|
540
|
+
let sawEndOfOptions = false;
|
|
541
|
+
for (const word of operandWords) {
|
|
542
|
+
if (word.text === "--") {
|
|
543
|
+
sawEndOfOptions = true;
|
|
544
|
+
continue;
|
|
545
|
+
}
|
|
546
|
+
if (sawEndOfOptions) continue;
|
|
547
|
+
const option = optionName(word.text);
|
|
548
|
+
if (option === undefined) continue;
|
|
549
|
+
if (isOutputOption(spec, option) || spec.valueOptions.includes(option)) continue;
|
|
550
|
+
if (spec.booleanOptions.includes(option)) continue;
|
|
551
|
+
if (matchesAllPositionalOption(spec, option)) continue;
|
|
552
|
+
if (bundledBooleans(option, spec) !== undefined) continue;
|
|
553
|
+
return [];
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
const written: ShellWord[] = [];
|
|
557
|
+
const positional: ShellWord[] = [];
|
|
558
|
+
let destinationMoved = false;
|
|
559
|
+
let allPositionalWritten = spec.writesAllPositional ?? false;
|
|
560
|
+
let pastEndOfOptions = false;
|
|
561
|
+
|
|
562
|
+
for (let index = 0; index < operandWords.length; index += 1) {
|
|
563
|
+
const word = operandWords[index];
|
|
564
|
+
if (!pastEndOfOptions && word.text === "--") {
|
|
565
|
+
pastEndOfOptions = true;
|
|
566
|
+
continue;
|
|
567
|
+
}
|
|
568
|
+
const option = pastEndOfOptions ? undefined : optionName(word.text);
|
|
569
|
+
|
|
570
|
+
if (option !== undefined && matchesAllPositionalOption(spec, option)) {
|
|
571
|
+
// `install -d` stops copying and starts creating, so every operand becomes a written path.
|
|
572
|
+
allPositionalWritten = true;
|
|
573
|
+
continue;
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
if (option !== undefined) {
|
|
577
|
+
const attached = word.text.includes("=");
|
|
578
|
+
if (isOutputOption(spec, option)) {
|
|
579
|
+
// `--output=F` carries its value; `-o F` takes the next word.
|
|
580
|
+
const target = attached ? valueAfterEquals(word) : operandWords[index + 1];
|
|
581
|
+
if (!attached) index += 1;
|
|
582
|
+
if (target?.literal) written.push(target);
|
|
583
|
+
continue;
|
|
584
|
+
}
|
|
585
|
+
if (spec.valueOptions.includes(option)) {
|
|
586
|
+
if (option === spec.destinationOption) destinationMoved = true;
|
|
587
|
+
if (!attached) {
|
|
588
|
+
const target = operandWords[index + 1];
|
|
589
|
+
index += 1;
|
|
590
|
+
if (target?.literal) written.push(target);
|
|
591
|
+
} else if (word.literal) {
|
|
592
|
+
written.push(word);
|
|
593
|
+
}
|
|
594
|
+
}
|
|
595
|
+
continue;
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
// `of=PATH` and friends are positional in shape but name their own direction.
|
|
599
|
+
const prefix = spec.outputPrefixes?.find(candidate => word.text.startsWith(candidate));
|
|
600
|
+
if (prefix !== undefined) {
|
|
601
|
+
if (word.literal) written.push(word);
|
|
602
|
+
continue;
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
positional.push(word);
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
const paths = positional.slice(spec.skipLeadingPositional ?? 0);
|
|
609
|
+
if (allPositionalWritten) {
|
|
610
|
+
written.push(...paths.filter(word => word.literal));
|
|
611
|
+
} else if (spec.writesSourcesToo && spec.writesLastPositional && !destinationMoved && paths.length >= 2) {
|
|
612
|
+
// Destination AND sources: `mv` removes what it moves.
|
|
613
|
+
written.push(...paths.filter(word => word.literal));
|
|
614
|
+
} else if (spec.writesLastPositional && !destinationMoved && paths.length >= 2) {
|
|
615
|
+
// Only with a source present. A lone operand is `cp x` — an error, not a write to model.
|
|
616
|
+
const destination = paths[paths.length - 1];
|
|
617
|
+
if (destination.literal) written.push(destination);
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
return written;
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
/**
|
|
624
|
+
* Split a bundled short option (`-ai`) into its letters when every one is a known boolean flag.
|
|
625
|
+
*
|
|
626
|
+
* Without this, `tee -ai /shared/ctx/x` hit the unrecognized-option path and abandoned the command —
|
|
627
|
+
* failing open to a read check on a write, which is the defect this module exists to fix.
|
|
628
|
+
*/
|
|
629
|
+
function bundledBooleans(option: string, spec: WriteOperandSpec): string[] | undefined {
|
|
630
|
+
if (!option.startsWith("-") || option.startsWith("--") || option.length <= 2) return undefined;
|
|
631
|
+
const letters = [...option.slice(1)].map(letter => `-${letter}`);
|
|
632
|
+
return letters.every(letter => spec.booleanOptions.includes(letter)) ? letters : undefined;
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
/**
|
|
636
|
+
* Whether an option turns every operand into a written path.
|
|
637
|
+
*
|
|
638
|
+
* Prefix-matched for the single-letter form, because GNU `sed` attaches the backup suffix to the flag:
|
|
639
|
+
* `-i.bak` is the same in-place edit as `-i`, and exact matching let that spelling through.
|
|
640
|
+
*/
|
|
641
|
+
function matchesAllPositionalOption(spec: WriteOperandSpec, option: string): boolean {
|
|
642
|
+
const target = spec.allPositionalOption;
|
|
643
|
+
if (target === undefined) return false;
|
|
644
|
+
if (option === target) return true;
|
|
645
|
+
return target.length === 2 && !target.startsWith("--") && option.startsWith(target);
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
function isOutputOption(spec: WriteOperandSpec, option: string): boolean {
|
|
649
|
+
return spec.outputOptions?.includes(option) ?? false;
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
/** The value half of an `--option=value` word, as a span the caller can mark. */
|
|
653
|
+
function valueAfterEquals(word: ShellWord): ShellWord | undefined {
|
|
654
|
+
const equals = word.text.indexOf("=");
|
|
655
|
+
if (equals === -1) return undefined;
|
|
656
|
+
return { ...word, text: word.text.slice(equals + 1) };
|
|
657
|
+
}
|
package/src/sandbox/enforce.ts
CHANGED
|
@@ -44,7 +44,7 @@ import * as path from "node:path";
|
|
|
44
44
|
import { pathIsWithin } from "@f5-sales-demo/pi-utils";
|
|
45
45
|
import { expandPath, parseFindPattern, parseSearchPath, resolveToCwd, splitTopLevel } from "../tools/path-utils";
|
|
46
46
|
import { lexShellCommand, type ShellSimpleCommand } from "../tools/shell-lex";
|
|
47
|
-
import { provenExemptWords } from "./command-operands";
|
|
47
|
+
import { provenExemptWords, writtenOperandWords } from "./command-operands";
|
|
48
48
|
import type { SandboxAccess, SandboxPolicy } from "./policy";
|
|
49
49
|
|
|
50
50
|
export interface ToolCallCheck {
|
|
@@ -585,7 +585,12 @@ function shellPathCandidates(command: string): ShellScan {
|
|
|
585
585
|
// `<>` opens for both, so it stays a read here and picks up its write below: a floor occurrence
|
|
586
586
|
// may carry only one access, and read is the one the floor would have used anyway.
|
|
587
587
|
const writeTargets = lexed.words.filter(word => word.redirect === "write");
|
|
588
|
-
|
|
588
|
+
// Plus the operands the invoked program writes itself — `tee FILE`, `dd of=FILE`, `cp SRC DST`.
|
|
589
|
+
// Those had no direction signal and defaulted to a read check, so a write into an allowRead-only
|
|
590
|
+
// root passed and a write into an allowWrite-only root was refused (GHSA-q4hg).
|
|
591
|
+
const writtenOperands = lexed.commands.flatMap(simpleCommand => writtenOperandWords(simpleCommand));
|
|
592
|
+
const inWriteTarget = (at: number): boolean =>
|
|
593
|
+
[...writeTargets, ...writtenOperands].some(word => at >= word.start && at < word.end);
|
|
589
594
|
|
|
590
595
|
// A floor occurrence takes the access its own operator gave it; failing that, the span of a
|
|
591
596
|
// lexed write target it sits inside; failing that, read.
|