@gotgenes/pi-permission-system 40.1.1 → 40.1.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/CHANGELOG.md +12 -0
- package/docs/configuration.md +4 -0
- package/package.json +1 -1
- package/src/access-intent/bash/wrapper-analysis.ts +201 -7
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,18 @@ All notable changes to this project will be documented in this file.
|
|
|
5
5
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
|
6
6
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
7
|
|
|
8
|
+
## [40.1.2](https://github.com/gotgenes/pi-packages/compare/pi-permission-system-v40.1.1...pi-permission-system-v40.1.2) (2026-10-09)
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
### Bug Fixes
|
|
12
|
+
|
|
13
|
+
* **pi-permission-system:** sudoedit and sudo's clustered or long options no longer earn the pure-reader exemption ([b94d064](https://github.com/gotgenes/pi-packages/commit/b94d064f27e3271a40b157ae311105d14aa83c1b)), closes [#1042](https://github.com/gotgenes/pi-packages/issues/1042)
|
|
14
|
+
* **pi-permission-system:** sudo's shell, login, chdir, and chroot modes keep the indirection floor ([3e36c7b](https://github.com/gotgenes/pi-packages/commit/3e36c7b4090299a147d877d25ea332ad95a8bc0c)), closes [#1042](https://github.com/gotgenes/pi-packages/issues/1042)
|
|
15
|
+
|
|
16
|
+
### Documentation
|
|
17
|
+
|
|
18
|
+
* **pi-permission-system:** document the sudo modes that keep the indirection floor ([bd77430](https://github.com/gotgenes/pi-packages/commit/bd774308a50d817b7913713acc963c0f49c635e0))
|
|
19
|
+
|
|
8
20
|
## [40.1.1](https://github.com/gotgenes/pi-packages/compare/pi-permission-system-v40.1.0...pi-permission-system-v40.1.1) (2026-10-08)
|
|
9
21
|
|
|
10
22
|
|
package/docs/configuration.md
CHANGED
|
@@ -1138,6 +1138,10 @@ If you run a permissive `bash` policy and want privilege elevation to prompt reg
|
|
|
1138
1138
|
|
|
1139
1139
|
That rule matches the wrapper's own text, so it is decided before the exemption is ever consulted.
|
|
1140
1140
|
|
|
1141
|
+
`sudo`'s own options are read the way `sudo` reads them, so `sudo -nu cat rm x` runs `rm x`, not a pure reader named `cat`.
|
|
1142
|
+
A `sudo` mode in which the command it names is not what runs as named keeps the floor whatever that command is: `-e`/`--edit` (sudoedit edits its operands as files, as root), `-s`/`--shell` and `-i`/`--login` (a shell runs the operand), and `-D`/`--chdir` and `-R`/`--chroot` (relative operands resolve somewhere the path surfaces do not look).
|
|
1143
|
+
So does `-h`, whose meaning depends on what follows it, and any option `sudo`'s manual does not list; a mode that keeps the floor is approved at the prompt or under `yoloMode`.
|
|
1144
|
+
|
|
1141
1145
|
#### Which key to actually write
|
|
1142
1146
|
|
|
1143
1147
|
The useful *grants* are `*_read: allow` and the bare sugar key.
|
package/package.json
CHANGED
|
@@ -402,10 +402,10 @@ function unquote(text: string): string {
|
|
|
402
402
|
* Index of the word beginning the inner command, or `-1` when the wrapper's own
|
|
403
403
|
* options run out first.
|
|
404
404
|
*
|
|
405
|
-
*
|
|
406
|
-
*
|
|
407
|
-
*
|
|
408
|
-
*
|
|
405
|
+
* An exec-conditional wrapper starts immediately after its exec flag. A wrapper
|
|
406
|
+
* with a {@link GETOPT_GRAMMARS} entry is read as getopt reads it, and answers
|
|
407
|
+
* `-1` for any option its grammar refuses; every other wrapper is read by the
|
|
408
|
+
* per-wrapper tables.
|
|
409
409
|
*/
|
|
410
410
|
function innerCommandIndex(words: readonly CommandWord[]): number {
|
|
411
411
|
const name = wrapperName(words);
|
|
@@ -415,6 +415,101 @@ function innerCommandIndex(words: readonly CommandWord[]): number {
|
|
|
415
415
|
const execFlag = execFlagIndex(name, argTexts);
|
|
416
416
|
if (execFlag !== -1) return execFlag + 2;
|
|
417
417
|
|
|
418
|
+
const grammar = GETOPT_GRAMMARS.get(name);
|
|
419
|
+
return grammar === undefined
|
|
420
|
+
? tableInnerIndex(words, name)
|
|
421
|
+
: getoptInnerIndex(words, grammar);
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
/**
|
|
425
|
+
* {@link innerCommandIndex} for a wrapper read as getopt_long reads it, with
|
|
426
|
+
* options permuted no further than the first non-option (sudo's `+` optstring).
|
|
427
|
+
*
|
|
428
|
+
* Options come first, then the `NAME=value` assignments the wrapper passes to
|
|
429
|
+
* the command, then the command. `-1` when an option is refused, unlisted, or
|
|
430
|
+
* an ambiguous abbreviation, or when the words run out first.
|
|
431
|
+
*/
|
|
432
|
+
function getoptInnerIndex(
|
|
433
|
+
words: readonly CommandWord[],
|
|
434
|
+
grammar: GetoptGrammar,
|
|
435
|
+
): number {
|
|
436
|
+
let index = 1;
|
|
437
|
+
while (index < words.length) {
|
|
438
|
+
const word = words[index].text;
|
|
439
|
+
if (word === "--") {
|
|
440
|
+
index++;
|
|
441
|
+
break;
|
|
442
|
+
}
|
|
443
|
+
if (!word.startsWith("-") || word === "-") break;
|
|
444
|
+
const consumed = word.startsWith("--")
|
|
445
|
+
? longOptionWords(word.slice(2), grammar.long)
|
|
446
|
+
: shortClusterWords(word.slice(1), grammar.short);
|
|
447
|
+
if (consumed === undefined) return -1;
|
|
448
|
+
index += consumed;
|
|
449
|
+
}
|
|
450
|
+
while (index < words.length && isEnvironmentAssignment(words[index].text)) {
|
|
451
|
+
index++;
|
|
452
|
+
}
|
|
453
|
+
return index < words.length ? index : -1;
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
/**
|
|
457
|
+
* How many words a short-option cluster spans (`-nu root` is two), or
|
|
458
|
+
* `undefined` when a letter in it is refused or unlisted.
|
|
459
|
+
*
|
|
460
|
+
* A value-taking letter ends the cluster: the rest of the cluster is its value
|
|
461
|
+
* (`-uedward`), or the following word when nothing is left (`-nu root`).
|
|
462
|
+
*/
|
|
463
|
+
function shortClusterWords(
|
|
464
|
+
letters: string,
|
|
465
|
+
table: ReadonlyMap<string, OptionArity>,
|
|
466
|
+
): number | undefined {
|
|
467
|
+
for (let position = 0; position < letters.length; position++) {
|
|
468
|
+
const arity = table.get(letters[position]);
|
|
469
|
+
if (arity === undefined || arity === "refuse") return undefined;
|
|
470
|
+
if (arity === "value") return position + 1 < letters.length ? 1 : 2;
|
|
471
|
+
}
|
|
472
|
+
return 1;
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
/**
|
|
476
|
+
* How many words a long option spans (`--user root` is two, `--user=root`
|
|
477
|
+
* one), or `undefined` when it is refused, unlisted, or ambiguous.
|
|
478
|
+
*/
|
|
479
|
+
function longOptionWords(
|
|
480
|
+
body: string,
|
|
481
|
+
table: ReadonlyMap<string, OptionArity>,
|
|
482
|
+
): number | undefined {
|
|
483
|
+
const equals = body.indexOf("=");
|
|
484
|
+
const name = equals === -1 ? body : body.slice(0, equals);
|
|
485
|
+
const arity = resolveLongOption(name, table);
|
|
486
|
+
if (arity === undefined || arity === "refuse") return undefined;
|
|
487
|
+
return arity === "value" && equals === -1 ? 2 : 1;
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
/**
|
|
491
|
+
* A long option's arity by its exact name, or by the one key it abbreviates.
|
|
492
|
+
*
|
|
493
|
+
* Abbreviations resolve against every key, refusing ones included, as
|
|
494
|
+
* getopt_long does: `--l` is ambiguous between `--list` and `--login`, and
|
|
495
|
+
* resolving against the admitted keys alone would read it as `--list`.
|
|
496
|
+
*/
|
|
497
|
+
function resolveLongOption(
|
|
498
|
+
name: string,
|
|
499
|
+
table: ReadonlyMap<string, OptionArity>,
|
|
500
|
+
): OptionArity | undefined {
|
|
501
|
+
const exact = table.get(name);
|
|
502
|
+
if (exact !== undefined) return exact;
|
|
503
|
+
const matches = [...table.keys()].filter((key) => key.startsWith(name));
|
|
504
|
+
return matches.length === 1 ? table.get(matches[0]) : undefined;
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
/**
|
|
508
|
+
* {@link innerCommandIndex} for a wrapper described by the per-wrapper tables:
|
|
509
|
+
* its value-taking options ({@link VALUE_TAKING_FLAGS}) and whether it takes a
|
|
510
|
+
* leading operand ({@link LEADING_OPERAND_WRAPPERS}).
|
|
511
|
+
*/
|
|
512
|
+
function tableInnerIndex(words: readonly CommandWord[], name: string): number {
|
|
418
513
|
const valueTaking = VALUE_TAKING_FLAGS.get(name) ?? EMPTY_FLAGS;
|
|
419
514
|
let operandPending = LEADING_OPERAND_WRAPPERS.has(name);
|
|
420
515
|
let index = 1;
|
|
@@ -535,11 +630,15 @@ const EXEC_CONDITIONAL_WRAPPERS = new Map<string, ReadonlySet<string>>([
|
|
|
535
630
|
* Curated per-wrapper options that consume the following word, so skipping a
|
|
536
631
|
* wrapper's own arguments does not mistake an option's value for the inner
|
|
537
632
|
* command. Attached forms (`-I{}`, `--user=root`) need no entry — they are one
|
|
538
|
-
* word.
|
|
539
|
-
*
|
|
633
|
+
* word.
|
|
634
|
+
*
|
|
635
|
+
* This table decides the gate, not only the display: {@link floorExemptionOf}
|
|
636
|
+
* judges the command it locates, so a missing entry can name a pure reader that
|
|
637
|
+
* is really an option's value (`xargs -J cat rm x`), and the execution-modifier
|
|
638
|
+
* clause admits value-taking options from it. A wrapper read by a
|
|
639
|
+
* {@link GETOPT_GRAMMARS} entry has no row here.
|
|
540
640
|
*/
|
|
541
641
|
const VALUE_TAKING_FLAGS = new Map<string, ReadonlySet<string>>([
|
|
542
|
-
["sudo", new Set(["-u", "-g", "-p", "-C", "-h", "-U", "-r", "-t"])],
|
|
543
642
|
["doas", new Set(["-u", "-C"])],
|
|
544
643
|
["env", new Set(["-u", "-C", "--unset", "--chdir"])],
|
|
545
644
|
[
|
|
@@ -556,6 +655,101 @@ const VALUE_TAKING_FLAGS = new Map<string, ReadonlySet<string>>([
|
|
|
556
655
|
|
|
557
656
|
const EMPTY_FLAGS: ReadonlySet<string> = new Set<string>();
|
|
558
657
|
|
|
658
|
+
/**
|
|
659
|
+
* How a getopt-parsed wrapper treats one of its options: a flag, an option
|
|
660
|
+
* taking a value, or a mode in which the command the peel would name is not
|
|
661
|
+
* the one that runs, which refuses the peel.
|
|
662
|
+
*/
|
|
663
|
+
type OptionArity = "flag" | "value" | "refuse";
|
|
664
|
+
|
|
665
|
+
/**
|
|
666
|
+
* A wrapper's options as getopt_long reads them, keyed by short letter and by
|
|
667
|
+
* full long name. Anything absent refuses, so a missing row costs a prompt and
|
|
668
|
+
* never misplaces the inner command.
|
|
669
|
+
*/
|
|
670
|
+
interface GetoptGrammar {
|
|
671
|
+
readonly short: ReadonlyMap<string, OptionArity>;
|
|
672
|
+
readonly long: ReadonlyMap<string, OptionArity>;
|
|
673
|
+
}
|
|
674
|
+
|
|
675
|
+
/**
|
|
676
|
+
* `sudo`'s options, verified against `man sudo` (1.9.17p2). The SELinux
|
|
677
|
+
* `-r`/`-t` (`--role`/`--type`) are absent from that page, so they are left
|
|
678
|
+
* unlisted and refuse.
|
|
679
|
+
*
|
|
680
|
+
* Each refusing mode is one where the command the peel would name is not what
|
|
681
|
+
* runs as named: `-e` (sudoedit) opens each operand in an editor and writes it
|
|
682
|
+
* back as root; `-s` and `-i` hand the operand to a shell, which still expands
|
|
683
|
+
* `$`; `-i`, `-D`, and `-R` move where its relative operands resolve, so the
|
|
684
|
+
* path surfaces would judge the wrong file; and `-h` is help alone but a remote
|
|
685
|
+
* host with a value, an arity the page does not settle.
|
|
686
|
+
*/
|
|
687
|
+
const SUDO_GRAMMAR: GetoptGrammar = {
|
|
688
|
+
short: new Map<string, OptionArity>([
|
|
689
|
+
["A", "flag"],
|
|
690
|
+
["B", "flag"],
|
|
691
|
+
["b", "flag"],
|
|
692
|
+
["E", "flag"],
|
|
693
|
+
["H", "flag"],
|
|
694
|
+
["K", "flag"],
|
|
695
|
+
["k", "flag"],
|
|
696
|
+
["l", "flag"],
|
|
697
|
+
["N", "flag"],
|
|
698
|
+
["n", "flag"],
|
|
699
|
+
["P", "flag"],
|
|
700
|
+
["S", "flag"],
|
|
701
|
+
["V", "flag"],
|
|
702
|
+
["v", "flag"],
|
|
703
|
+
["C", "value"],
|
|
704
|
+
["g", "value"],
|
|
705
|
+
["p", "value"],
|
|
706
|
+
["T", "value"],
|
|
707
|
+
["U", "value"],
|
|
708
|
+
["u", "value"],
|
|
709
|
+
["D", "refuse"],
|
|
710
|
+
["e", "refuse"],
|
|
711
|
+
["h", "refuse"],
|
|
712
|
+
["i", "refuse"],
|
|
713
|
+
["R", "refuse"],
|
|
714
|
+
["s", "refuse"],
|
|
715
|
+
]),
|
|
716
|
+
long: new Map<string, OptionArity>([
|
|
717
|
+
["askpass", "flag"],
|
|
718
|
+
["background", "flag"],
|
|
719
|
+
["bell", "flag"],
|
|
720
|
+
["help", "flag"],
|
|
721
|
+
["list", "flag"],
|
|
722
|
+
["no-update", "flag"],
|
|
723
|
+
["non-interactive", "flag"],
|
|
724
|
+
// Its value is attached only (`--preserve-env=list`), which a flag accepts.
|
|
725
|
+
["preserve-env", "flag"],
|
|
726
|
+
["preserve-groups", "flag"],
|
|
727
|
+
["remove-timestamp", "flag"],
|
|
728
|
+
["reset-timestamp", "flag"],
|
|
729
|
+
["set-home", "flag"],
|
|
730
|
+
["stdin", "flag"],
|
|
731
|
+
["validate", "flag"],
|
|
732
|
+
["version", "flag"],
|
|
733
|
+
["close-from", "value"],
|
|
734
|
+
["command-timeout", "value"],
|
|
735
|
+
["group", "value"],
|
|
736
|
+
["other-user", "value"],
|
|
737
|
+
["prompt", "value"],
|
|
738
|
+
["user", "value"],
|
|
739
|
+
["chdir", "refuse"],
|
|
740
|
+
["chroot", "refuse"],
|
|
741
|
+
["edit", "refuse"],
|
|
742
|
+
["host", "refuse"],
|
|
743
|
+
["login", "refuse"],
|
|
744
|
+
["shell", "refuse"],
|
|
745
|
+
]),
|
|
746
|
+
};
|
|
747
|
+
|
|
748
|
+
/** Wrappers whose inner command is located by a getopt grammar. */
|
|
749
|
+
const GETOPT_GRAMMARS = new Map<string, GetoptGrammar>([
|
|
750
|
+
["sudo", SUDO_GRAMMAR],
|
|
751
|
+
]);
|
|
752
|
+
|
|
559
753
|
/**
|
|
560
754
|
* Wrappers that change only *how* the same visible command runs — timing, kill
|
|
561
755
|
* deadline, scheduling, buffering, session — each with the flags (options
|