@gotgenes/pi-permission-system 40.1.0 → 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 +24 -0
- package/docs/configuration.md +6 -1
- package/package.json +1 -1
- package/src/access-intent/bash/bash-path-resolver.ts +33 -1
- package/src/access-intent/bash/command-enumeration.ts +54 -26
- package/src/access-intent/bash/nested-execution.ts +45 -0
- package/src/access-intent/bash/wrapper-analysis.ts +205 -8
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,30 @@ 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
|
+
|
|
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)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
### Bug Fixes
|
|
24
|
+
|
|
25
|
+
* **pi-permission-system:** commands inside time ( … ) are gated on their own rules ([0b77df6](https://github.com/gotgenes/pi-packages/commit/0b77df61d03c31e394cf68971df0e526b44a1abd)), closes [#1027](https://github.com/gotgenes/pi-packages/issues/1027)
|
|
26
|
+
* **pi-permission-system:** a cd inside time ( … ) resolves the paths after it in that subshell ([6994e15](https://github.com/gotgenes/pi-packages/commit/6994e15042e2ec6cb9e495973e0407d80fa2ff25)), closes [#1027](https://github.com/gotgenes/pi-packages/issues/1027)
|
|
27
|
+
|
|
28
|
+
### Documentation
|
|
29
|
+
|
|
30
|
+
* **pi-permission-system:** document the timed-subshell descent and mark #1027 complete ([8337f54](https://github.com/gotgenes/pi-packages/commit/8337f5494ddd7a6c84561e3092700112e1de78a8))
|
|
31
|
+
|
|
8
32
|
## [40.1.0](https://github.com/gotgenes/pi-packages/compare/pi-permission-system-v40.0.2...pi-permission-system-v40.1.0) (2026-10-08)
|
|
9
33
|
|
|
10
34
|
|
package/docs/configuration.md
CHANGED
|
@@ -1107,7 +1107,8 @@ All four of these must hold, and each keeps the decision about the command that
|
|
|
1107
1107
|
The admitted options are `time -p`/`-l`/`-h`/`-f`; `timeout -s`/`-k`/`-f`/`-p`/`-v` and their long forms; `nice -n`/`--adjustment`; and `stdbuf -i`/`-o`/`-e` and their long forms.
|
|
1108
1108
|
3. The command it runs is not itself a wrapper or an inline shell, so `timeout 5 bash -c '…'` stays floored.
|
|
1109
1109
|
4. The command it runs is named literally.
|
|
1110
|
-
`time ( … )`
|
|
1110
|
+
`time ( … )` is decided by the commands inside it, each on its own rule, so `time (rm -rf /tmp/x)` reaches an `rm *` deny.
|
|
1111
|
+
`time { …; }` stays floored: the parser reads the group as `time`'s arguments, so the commands inside are not yet gated on their own rules.
|
|
1111
1112
|
|
|
1112
1113
|
So under `bash: {"*": "allow", "git push *": "deny"}`, `time pnpm run lint >/tmp/lint.txt 2>&1` is allowed and `timeout 60 git push --force` is denied, exactly as without the wrapper.
|
|
1113
1114
|
No redirect refusal applies here: the redirect destination goes through `path` and `external_directory` as it does for the bare command, and a destination the parse cannot resolve (`> $OUT`) is unprojected for both forms alike.
|
|
@@ -1137,6 +1138,10 @@ If you run a permissive `bash` policy and want privilege elevation to prompt reg
|
|
|
1137
1138
|
|
|
1138
1139
|
That rule matches the wrapper's own text, so it is decided before the exemption is ever consulted.
|
|
1139
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
|
+
|
|
1140
1145
|
#### Which key to actually write
|
|
1141
1146
|
|
|
1142
1147
|
The useful *grants* are `*_read: allow` and the bare sugar key.
|
package/package.json
CHANGED
|
@@ -8,6 +8,7 @@ import { normalizePathPolicyLiteral } from "#src/access-intent/path-normalizatio
|
|
|
8
8
|
import type { PathNormalizer } from "#src/path/path-normalizer";
|
|
9
9
|
import { isSafeSystemPath } from "#src/path/safe-system-paths";
|
|
10
10
|
import type { ArgumentSpeller } from "./command-enumeration";
|
|
11
|
+
import { timedSubshellOf } from "./nested-execution";
|
|
11
12
|
import {
|
|
12
13
|
ARG_NODE_TYPES,
|
|
13
14
|
SKIP_SUBTREE_TYPES,
|
|
@@ -238,6 +239,8 @@ export class BashPathResolver {
|
|
|
238
239
|
* does not update the running directory; subshell and brace-group interiors
|
|
239
240
|
* inherit the enclosing base without folding their own `cd`s (a conservative
|
|
240
241
|
* first tier).
|
|
242
|
+
* A subshell timed by `time`, which the grammar reads as the argument of a
|
|
243
|
+
* command named `time`, is walked as a bare subshell is.
|
|
241
244
|
*/
|
|
242
245
|
private collectPathCandidates(
|
|
243
246
|
rootNode: TSNode,
|
|
@@ -263,9 +266,14 @@ export class BashPathResolver {
|
|
|
263
266
|
case "list":
|
|
264
267
|
case "redirected_statement":
|
|
265
268
|
return this.walkCurrentShellSequence(node, base, out);
|
|
266
|
-
case "command":
|
|
269
|
+
case "command": {
|
|
270
|
+
const timed = timedSubshellOf(node);
|
|
271
|
+
if (timed !== null) {
|
|
272
|
+
return this.walkTimedSubshell(node, timed, base, out);
|
|
273
|
+
}
|
|
267
274
|
tagTokens(collectCommandTokens(node, this.words), base, out);
|
|
268
275
|
return this.foldCd(node, base);
|
|
276
|
+
}
|
|
269
277
|
case "pipeline":
|
|
270
278
|
// tree-sitter-bash mis-groups a redirect-bearing `&&`/`;` list as the
|
|
271
279
|
// first stage of a pipeline (`cd a && pnpm x 2>&1 | tail` parses as
|
|
@@ -294,6 +302,30 @@ export class BashPathResolver {
|
|
|
294
302
|
}
|
|
295
303
|
}
|
|
296
304
|
|
|
305
|
+
/**
|
|
306
|
+
* Walk `time ( … )`, returning the base in force after it, which is `base`.
|
|
307
|
+
*
|
|
308
|
+
* The subshell is walked as a subshell, so its `cd`s fold for the commands
|
|
309
|
+
* after them inside it and reset on exit. Every other named child but the
|
|
310
|
+
* `time` name, such as a redirect the command hosts (`2>err time ( … )`),
|
|
311
|
+
* is collected against `base`.
|
|
312
|
+
*/
|
|
313
|
+
private walkTimedSubshell(
|
|
314
|
+
command: TSNode,
|
|
315
|
+
subshell: TSNode,
|
|
316
|
+
base: EffectiveBase,
|
|
317
|
+
out: PathCandidate[],
|
|
318
|
+
): EffectiveBase {
|
|
319
|
+
for (let i = 0; i < command.childCount; i++) {
|
|
320
|
+
const child = command.child(i);
|
|
321
|
+
if (!child?.isNamed || child.type === "command_name") continue;
|
|
322
|
+
if (child.startIndex === subshell.startIndex) continue;
|
|
323
|
+
tagTokens(collectPathCandidateTokens(child, this.words), base, out);
|
|
324
|
+
}
|
|
325
|
+
this.walkForCandidates(subshell, base, out);
|
|
326
|
+
return base;
|
|
327
|
+
}
|
|
328
|
+
|
|
297
329
|
/**
|
|
298
330
|
* Fold a current-shell sequence (`program` / `list` / `redirected_statement`):
|
|
299
331
|
* thread the effective base left-to-right through the children so a `cd`
|
|
@@ -1,5 +1,10 @@
|
|
|
1
1
|
import type { BashCommandContext, FloorExemption } from "#src/types";
|
|
2
|
-
import {
|
|
2
|
+
import {
|
|
3
|
+
commandWordNodes,
|
|
4
|
+
EXECUTION_HOST_TYPES,
|
|
5
|
+
forEachExecutionIn,
|
|
6
|
+
timedSubshellOf,
|
|
7
|
+
} from "./nested-execution";
|
|
3
8
|
import type { WordReader } from "./node-text";
|
|
4
9
|
import { parseUnresolvedWithin } from "./parse-health";
|
|
5
10
|
import type { TSNode } from "./parser";
|
|
@@ -269,7 +274,8 @@ const STATEMENT_TYPES = new Set([
|
|
|
269
274
|
* substitution (`$(…)`, backticks), process substitution (`<(…)`/`>(…)`), and
|
|
270
275
|
* subshells (`( … )`) — emitting each inner command as its own unit *in
|
|
271
276
|
* addition to* the enclosing command, since those inner commands really execute
|
|
272
|
-
* (#306).
|
|
277
|
+
* (#306). A subshell timed by `time`, which the grammar reads as the argument
|
|
278
|
+
* of a command named `time`, is descended the same way.
|
|
273
279
|
* A compound statement (control flow, a function definition, a `{ … }` brace
|
|
274
280
|
* group) is emitted whole and then descended for the statements it contains,
|
|
275
281
|
* while its operand words — a loop variable, a word list, a `case` subject, a
|
|
@@ -363,6 +369,11 @@ function collectCommandsInto(
|
|
|
363
369
|
const scope = unresolvedScope(node, inherited);
|
|
364
370
|
|
|
365
371
|
if (node.type === "command") {
|
|
372
|
+
const timed = timedSubshellOf(node);
|
|
373
|
+
if (timed !== null) {
|
|
374
|
+
collectTimedSubshell(node, timed, scope, out);
|
|
375
|
+
return;
|
|
376
|
+
}
|
|
366
377
|
out.push(makeCommandUnit(node, scope));
|
|
367
378
|
// A command's text already contains any substitution; descend its subtree
|
|
368
379
|
// to ALSO emit the inner commands of command/process substitutions.
|
|
@@ -422,6 +433,40 @@ function collectCommandsInto(
|
|
|
422
433
|
collectHostedCommands(node, scope, out);
|
|
423
434
|
}
|
|
424
435
|
|
|
436
|
+
/**
|
|
437
|
+
* Enumerate `time ( … )`: the `time` unit whole, then the subshell's commands
|
|
438
|
+
* as units of their own, exactly as a bare subshell's are.
|
|
439
|
+
*
|
|
440
|
+
* The `time` unit is the never-weaker whole emit, and it is exempt from the
|
|
441
|
+
* wrapper floor: the floor exists because a wrapper hides the command that
|
|
442
|
+
* should be gated, and every command in this subshell is a unit gated on its
|
|
443
|
+
* own rules. It resolves by its executed unit, the subshell's text, which is
|
|
444
|
+
* how the bare subshell's whole unit resolves.
|
|
445
|
+
*
|
|
446
|
+
* Substitutions the command hosts outside the subshell (in a redirect) are
|
|
447
|
+
* still enumerated, and the subshell is left to the descent so a substitution
|
|
448
|
+
* inside it is emitted once.
|
|
449
|
+
*/
|
|
450
|
+
function collectTimedSubshell(
|
|
451
|
+
node: TSNode,
|
|
452
|
+
subshell: TSNode,
|
|
453
|
+
scope: UnitScope,
|
|
454
|
+
out: BashCommand[],
|
|
455
|
+
): void {
|
|
456
|
+
out.push(makeCommandUnit(node, scope, "execution-modifier"));
|
|
457
|
+
for (let i = 0; i < node.childCount; i++) {
|
|
458
|
+
const child = node.child(i);
|
|
459
|
+
if (child && child.startIndex !== subshell.startIndex) {
|
|
460
|
+
collectHostedCommands(child, scope, out);
|
|
461
|
+
}
|
|
462
|
+
}
|
|
463
|
+
descendCommandChildren(
|
|
464
|
+
subshell,
|
|
465
|
+
{ ...redirectedScope(node, scope), context: "subshell" },
|
|
466
|
+
out,
|
|
467
|
+
);
|
|
468
|
+
}
|
|
469
|
+
|
|
425
470
|
/**
|
|
426
471
|
* The scope `node`'s own subtree establishes, marking it unresolved when
|
|
427
472
|
* tree-sitter could not parse a region within it (#840).
|
|
@@ -488,7 +533,11 @@ function makeUnit(
|
|
|
488
533
|
* before or between the words (`>/tmp/o xargs grep foo`) writes a file as
|
|
489
534
|
* surely as one on the enclosing statement.
|
|
490
535
|
*/
|
|
491
|
-
function makeCommandUnit(
|
|
536
|
+
function makeCommandUnit(
|
|
537
|
+
node: TSNode,
|
|
538
|
+
scope: UnitScope,
|
|
539
|
+
floorExemption?: FloorExemption,
|
|
540
|
+
): BashCommand {
|
|
492
541
|
const { text, words, argumentSpelling } = readCommandUnit(node, scope);
|
|
493
542
|
return makeUnit(text, scope, {
|
|
494
543
|
spellings: distinctSpellings(text, [
|
|
@@ -497,7 +546,8 @@ function makeCommandUnit(node: TSNode, scope: UnitScope): BashCommand {
|
|
|
497
546
|
]),
|
|
498
547
|
wrapperKind: classifyWrapperWords(words),
|
|
499
548
|
executedUnit: executedUnitOf(text, words) ?? undefined,
|
|
500
|
-
floorExemption:
|
|
549
|
+
floorExemption:
|
|
550
|
+
floorExemption ?? floorExemptionOf(words, redirectedScope(node, scope)),
|
|
501
551
|
});
|
|
502
552
|
}
|
|
503
553
|
|
|
@@ -626,28 +676,6 @@ function gapBetween(
|
|
|
626
676
|
);
|
|
627
677
|
}
|
|
628
678
|
|
|
629
|
-
/**
|
|
630
|
-
* The nodes {@link readCommandUnit} reports words for, in the same order: every
|
|
631
|
-
* named child except a prefix assignment and a hosted redirect.
|
|
632
|
-
*
|
|
633
|
-
* Split out so a consumer that needs a *node* rather than a word (the log's
|
|
634
|
-
* command masker, which offsets a re-parse by the payload node's `startIndex`)
|
|
635
|
-
* walks the identical filtered list. Two walks over the same children with the
|
|
636
|
-
* same filter, written twice, is how the two come to disagree about which word
|
|
637
|
-
* is at which index.
|
|
638
|
-
*/
|
|
639
|
-
function commandWordNodes(node: TSNode): TSNode[] {
|
|
640
|
-
const nodes: TSNode[] = [];
|
|
641
|
-
for (let i = 0; i < node.childCount; i++) {
|
|
642
|
-
const child = node.child(i);
|
|
643
|
-
if (!child?.isNamed) continue;
|
|
644
|
-
if (child.type === "variable_assignment") continue;
|
|
645
|
-
if (REDIRECT_NODE_TYPES.has(child.type)) continue;
|
|
646
|
-
nodes.push(child);
|
|
647
|
-
}
|
|
648
|
-
return nodes;
|
|
649
|
-
}
|
|
650
|
-
|
|
651
679
|
/** The redirects a `command` node hosts among its own children. */
|
|
652
680
|
function hostedRedirects(node: TSNode): TSNode[] {
|
|
653
681
|
const redirects: TSNode[] = [];
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { BashCommandContext } from "#src/types";
|
|
2
2
|
import type { TSNode } from "./parser";
|
|
3
|
+
import { REDIRECT_NODE_TYPES } from "./redirect-analysis";
|
|
3
4
|
|
|
4
5
|
/**
|
|
5
6
|
* AST node types whose interior commands really execute when the shell runs the
|
|
@@ -47,6 +48,50 @@ export const EXECUTION_HOST_TYPES: ReadonlySet<string> = new Set([
|
|
|
47
48
|
"heredoc_body",
|
|
48
49
|
]);
|
|
49
50
|
|
|
51
|
+
/**
|
|
52
|
+
* The word nodes of a `command` node, in source order: every named child except
|
|
53
|
+
* a prefix assignment and a hosted redirect.
|
|
54
|
+
*
|
|
55
|
+
* Shared so every consumer that reads a command's words, whether as words (the
|
|
56
|
+
* command enumerator's unit text) or as nodes (the log's command masker, which
|
|
57
|
+
* offsets a re-parse by the payload node's `startIndex`), walks the identical
|
|
58
|
+
* filtered list. Two walks over the same children with the same filter,
|
|
59
|
+
* written twice, is how the two come to disagree about which word is at which
|
|
60
|
+
* index.
|
|
61
|
+
*/
|
|
62
|
+
export function commandWordNodes(node: TSNode): TSNode[] {
|
|
63
|
+
const nodes: TSNode[] = [];
|
|
64
|
+
for (let i = 0; i < node.childCount; i++) {
|
|
65
|
+
const child = node.child(i);
|
|
66
|
+
if (!child?.isNamed) continue;
|
|
67
|
+
if (child.type === "variable_assignment") continue;
|
|
68
|
+
if (REDIRECT_NODE_TYPES.has(child.type)) continue;
|
|
69
|
+
nodes.push(child);
|
|
70
|
+
}
|
|
71
|
+
return nodes;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* The subshell a `command` node times, or `null` when it is not `time ( … )`.
|
|
76
|
+
*
|
|
77
|
+
* `tree-sitter-bash` has no `time` keyword, so `time (rm x)` parses as a
|
|
78
|
+
* command named `time` whose only argument is a `subshell`. Bash runs that
|
|
79
|
+
* subshell's commands as surely as a bare `( … )`'s, so both bash surfaces read
|
|
80
|
+
* the shape through this one recognizer rather than each re-deciding it.
|
|
81
|
+
*
|
|
82
|
+
* The words must be exactly a `time` spelled literally (a quoted `"time"` is
|
|
83
|
+
* not the keyword) and one subshell. Anything between them (`time -p ( … )`,
|
|
84
|
+
* whose `-p` the grammar cannot place) leaves the shape unrecognized, so it
|
|
85
|
+
* keeps whatever floor the enumerator gives it.
|
|
86
|
+
*/
|
|
87
|
+
export function timedSubshellOf(command: TSNode): TSNode | null {
|
|
88
|
+
const words = commandWordNodes(command);
|
|
89
|
+
if (words.length !== 2) return null;
|
|
90
|
+
const [name, argument] = words;
|
|
91
|
+
if (argument.type !== "subshell") return null;
|
|
92
|
+
return name.type === "command_name" && name.text === "time" ? argument : null;
|
|
93
|
+
}
|
|
94
|
+
|
|
50
95
|
/**
|
|
51
96
|
* Visit every execution context `node` *is or contains*, in source order.
|
|
52
97
|
*
|
|
@@ -223,7 +223,10 @@ export function floorExemptionOf(
|
|
|
223
223
|
* past.
|
|
224
224
|
* 4. The inner head is a literal command name. The grammar has no `time`
|
|
225
225
|
* keyword, so `time { …; }` and `time ( … )` reach here with shell syntax
|
|
226
|
-
* where the command name should be.
|
|
226
|
+
* where the command name should be. A word list cannot tell whether that
|
|
227
|
+
* syntax was enumerated, so both keep the floor here; the command
|
|
228
|
+
* enumerator, which descends a `time ( … )` subshell, exempts that shape
|
|
229
|
+
* itself.
|
|
227
230
|
*/
|
|
228
231
|
function onlyModifiesExecution(
|
|
229
232
|
peeled: readonly (readonly CommandWord[])[],
|
|
@@ -399,10 +402,10 @@ function unquote(text: string): string {
|
|
|
399
402
|
* Index of the word beginning the inner command, or `-1` when the wrapper's own
|
|
400
403
|
* options run out first.
|
|
401
404
|
*
|
|
402
|
-
*
|
|
403
|
-
*
|
|
404
|
-
*
|
|
405
|
-
*
|
|
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.
|
|
406
409
|
*/
|
|
407
410
|
function innerCommandIndex(words: readonly CommandWord[]): number {
|
|
408
411
|
const name = wrapperName(words);
|
|
@@ -412,6 +415,101 @@ function innerCommandIndex(words: readonly CommandWord[]): number {
|
|
|
412
415
|
const execFlag = execFlagIndex(name, argTexts);
|
|
413
416
|
if (execFlag !== -1) return execFlag + 2;
|
|
414
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 {
|
|
415
513
|
const valueTaking = VALUE_TAKING_FLAGS.get(name) ?? EMPTY_FLAGS;
|
|
416
514
|
let operandPending = LEADING_OPERAND_WRAPPERS.has(name);
|
|
417
515
|
let index = 1;
|
|
@@ -532,11 +630,15 @@ const EXEC_CONDITIONAL_WRAPPERS = new Map<string, ReadonlySet<string>>([
|
|
|
532
630
|
* Curated per-wrapper options that consume the following word, so skipping a
|
|
533
631
|
* wrapper's own arguments does not mistake an option's value for the inner
|
|
534
632
|
* command. Attached forms (`-I{}`, `--user=root`) need no entry — they are one
|
|
535
|
-
* word.
|
|
536
|
-
*
|
|
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.
|
|
537
640
|
*/
|
|
538
641
|
const VALUE_TAKING_FLAGS = new Map<string, ReadonlySet<string>>([
|
|
539
|
-
["sudo", new Set(["-u", "-g", "-p", "-C", "-h", "-U", "-r", "-t"])],
|
|
540
642
|
["doas", new Set(["-u", "-C"])],
|
|
541
643
|
["env", new Set(["-u", "-C", "--unset", "--chdir"])],
|
|
542
644
|
[
|
|
@@ -553,6 +655,101 @@ const VALUE_TAKING_FLAGS = new Map<string, ReadonlySet<string>>([
|
|
|
553
655
|
|
|
554
656
|
const EMPTY_FLAGS: ReadonlySet<string> = new Set<string>();
|
|
555
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
|
+
|
|
556
753
|
/**
|
|
557
754
|
* Wrappers that change only *how* the same visible command runs — timing, kill
|
|
558
755
|
* deadline, scheduling, buffering, session — each with the flags (options
|