@gotgenes/pi-permission-system 35.0.2 → 36.0.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 +34 -0
- package/docs/configuration.md +26 -3
- package/package.json +1 -1
- package/src/access-intent/bash/awk-invocation.ts +86 -0
- package/src/access-intent/bash/command-effects.ts +61 -22
- package/src/access-intent/bash/command-enumeration.ts +7 -2
- package/src/access-intent/bash/node-text.ts +206 -0
- package/src/access-intent/bash/sed-invocation.ts +471 -0
- package/src/access-intent/bash/token-collection.ts +13 -5
- package/src/access-intent/bash/wrapper-analysis.ts +11 -5
- package/src/handlers/gates/bash-path-extractor.ts +0 -23
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,40 @@ 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
|
+
## [36.0.0](https://github.com/gotgenes/pi-packages/compare/pi-permission-system-v35.0.3...pi-permission-system-v36.0.0) (2026-09-29)
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
### Bug Fixes
|
|
12
|
+
|
|
13
|
+
* **pi-permission-system:** a digit argument no longer withdraws sed's or awk's read claim ([18df901](https://github.com/gotgenes/pi-packages/commit/18df9012843b5811746098bcd106c52d1bc15510)), closes [#992](https://github.com/gotgenes/pi-packages/issues/992)
|
|
14
|
+
* **pi-permission-system:** **breaking:** a computed argument that may spell an option withdraws find's, fd's, and sort's read claim ([8116d72](https://github.com/gotgenes/pi-packages/commit/8116d725b7d74ec610ef97cfb26e6a2df64a839d)), closes [#992](https://github.com/gotgenes/pi-packages/issues/992)
|
|
15
|
+
* **pi-permission-system:** a quoted "$@" after a literal withdraws find's, fd's, and sort's read claim ([45da982](https://github.com/gotgenes/pi-packages/commit/45da9825f80ce4fe2955081f2692a1bc678ddb26)), closes [#992](https://github.com/gotgenes/pi-packages/issues/992)
|
|
16
|
+
* **pi-permission-system:** a quoted indirect expansion after a literal withdraws find's, fd's, and sort's read claim ([dd78839](https://github.com/gotgenes/pi-packages/commit/dd78839f6327e7de51b1024b07ea21653e34b680)), closes [#992](https://github.com/gotgenes/pi-packages/issues/992)
|
|
17
|
+
* **pi-permission-system:** a quoted variable after a literal withdraws find's, fd's, and sort's read claim ([7727223](https://github.com/gotgenes/pi-packages/commit/7727223ec01b5dd0e8560248dd965e7f638930ae)), closes [#992](https://github.com/gotgenes/pi-packages/issues/992)
|
|
18
|
+
|
|
19
|
+
### Documentation
|
|
20
|
+
|
|
21
|
+
* **pi-permission-system:** document the computed-word rule for find, fd, and sort ([9f7deb9](https://github.com/gotgenes/pi-packages/commit/9f7deb9ca270a17c99a820f79e40fa64afc444d0))
|
|
22
|
+
* **pi-permission-system:** name a quoted "$@" among the words that split ([1595490](https://github.com/gotgenes/pi-packages/commit/15954901cc61154ad5548f3b5553b60bb48ab4d6))
|
|
23
|
+
* **pi-permission-system:** name an indirect expansion among the words that split ([7d875c3](https://github.com/gotgenes/pi-packages/commit/7d875c37d4130d06ae1969b4739f030b5ce79dd7))
|
|
24
|
+
* **pi-permission-system:** a quoted variable may split, since it may be a nameref ([0df4871](https://github.com/gotgenes/pi-packages/commit/0df4871f2ca42d4df6639f9bcf7d11d0647b3e31))
|
|
25
|
+
|
|
26
|
+
## [35.0.3](https://github.com/gotgenes/pi-packages/compare/pi-permission-system-v35.0.2...pi-permission-system-v35.0.3) (2026-09-29)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
### Bug Fixes
|
|
30
|
+
|
|
31
|
+
* **pi-permission-system:** a quoted withdrawing option keeps a wrapped find, fd, or sort floored ([d60653a](https://github.com/gotgenes/pi-packages/commit/d60653a3e53847851e889e8133a7fdab143b8cbb)), closes [#924](https://github.com/gotgenes/pi-packages/issues/924)
|
|
32
|
+
* **pi-permission-system:** sed that only prints resolves on the read surface ([274ba89](https://github.com/gotgenes/pi-packages/commit/274ba8963d899f97306bfcfd2ece10ca2720d29d)), closes [#924](https://github.com/gotgenes/pi-packages/issues/924)
|
|
33
|
+
* **pi-permission-system:** sed substitutions and regex addresses resolve on the read surface ([fbbc940](https://github.com/gotgenes/pi-packages/commit/fbbc94099fd7f0b31155b73c5c46b99359482ab9)), closes [#924](https://github.com/gotgenes/pi-packages/issues/924)
|
|
34
|
+
* **pi-permission-system:** awk that only reads its input resolves on the read surface ([136c739](https://github.com/gotgenes/pi-packages/commit/136c739e2025d3d47bfcfc72929533411e515232)), closes [#924](https://github.com/gotgenes/pi-packages/issues/924)
|
|
35
|
+
* **pi-permission-system:** a brace-expanded argument withdraws sed's and awk's read claim ([5b38dd3](https://github.com/gotgenes/pi-packages/commit/5b38dd3fb6e87ffa011dd25a80df372a955b4696)), closes [#924](https://github.com/gotgenes/pi-packages/issues/924)
|
|
36
|
+
|
|
37
|
+
### Documentation
|
|
38
|
+
|
|
39
|
+
* **pi-permission-system:** document sed and awk as presumed readers ([d3d0314](https://github.com/gotgenes/pi-packages/commit/d3d0314bf1a2940db237f384d1f377f727a3ea66)), closes [#924](https://github.com/gotgenes/pi-packages/issues/924)
|
|
40
|
+
* **pi-permission-system:** name brace expansion among the computed spellings ([e4fee09](https://github.com/gotgenes/pi-packages/commit/e4fee09286705c898dd211ed5618ab4c040472d1)), closes [#924](https://github.com/gotgenes/pi-packages/issues/924)
|
|
41
|
+
|
|
8
42
|
## [35.0.2](https://github.com/gotgenes/pi-packages/compare/pi-permission-system-v35.0.1...pi-permission-system-v35.0.2) (2026-09-28)
|
|
9
43
|
|
|
10
44
|
|
package/docs/configuration.md
CHANGED
|
@@ -879,12 +879,12 @@ A path token owned by one of them consults the `_read` surface alone:
|
|
|
879
879
|
|
|
880
880
|
<!-- BEGIN PURE_READER_CORE -->
|
|
881
881
|
|
|
882
|
-
`basename`, `cat`, `cd`, `diff`, `dirname`, `echo`, `egrep`, `fd`, `fgrep`, `find`, `grep`, `head`, `ls`, `pwd`, `realpath`, `rg`, `sort`, `stat`, `tail`, `wc`, `which`
|
|
882
|
+
`awk`, `basename`, `cat`, `cd`, `diff`, `dirname`, `echo`, `egrep`, `fd`, `fgrep`, `find`, `grep`, `head`, `ls`, `pwd`, `realpath`, `rg`, `sed`, `sort`, `stat`, `tail`, `wc`, `which`
|
|
883
883
|
|
|
884
884
|
<!-- END PURE_READER_CORE -->
|
|
885
885
|
|
|
886
886
|
The bar for admission is structural, not popularity: implementation-independent read-only-ness across GNU and BSD alike, no option that redirects output to a file, and effects that do not depend on argument content.
|
|
887
|
-
`
|
|
887
|
+
`uniq`, `tee`, `dd`, and `split` each have a positional or option that writes a file; `file` is excluded because `-C`/`--compile` writes a `magic.mgc` file; `less` and `more` can escape to a shell; `git`, `pnpm`, and `node` are subcommand-dependent; `gawk` and `nawk` are not admitted.
|
|
888
888
|
|
|
889
889
|
Three members are read-only **until an argument says otherwise**, and naming one of these options withdraws the claim — the token falls back to consulting both surfaces:
|
|
890
890
|
|
|
@@ -896,12 +896,35 @@ Three members are read-only **until an argument says otherwise**, and naming one
|
|
|
896
896
|
|
|
897
897
|
A long option is matched by any unambiguous abbreviation too (`sort --out=…` withdraws the claim exactly as `--output` does), and a short letter is matched anywhere in a cluster (`-uo`) or with its value attached (`-o/tmp/x`).
|
|
898
898
|
|
|
899
|
+
An argument whose value only the shell decides withdraws the claim too when it may arrive beginning with `-`, since it could spell any of these options: `$opt`, `"$dir"`, `$(cmd)`, and a leading glob such as `*` all do.
|
|
900
|
+
One whose first character is a literal other than `-` cannot become an option unless it can split into several words, so `find packages/*/docs` and `find . \( -name a \)` still read.
|
|
901
|
+
A variable can split even inside double quotes (`"x$@"` yields one word per positional parameter, and any variable may be a name reference to an array), so `find /src -name "x$y"` withdraws the claim too.
|
|
902
|
+
|
|
899
903
|
A core word counts only as a **bare basename**.
|
|
900
904
|
`./grep`, `/usr/bin/grep`, and `bin\grep` name programs this audit never saw, so they prove nothing and consult both surfaces.
|
|
901
905
|
|
|
902
906
|
The core cannot be extended or removed from configuration.
|
|
903
907
|
If you do not trust a member of it, deny or ask on the paths themselves — an effect proof only chooses which surface answers, and never overrides the answer.
|
|
904
908
|
|
|
909
|
+
##### `sed` and `awk`
|
|
910
|
+
|
|
911
|
+
`sed` and `awk` can write through their script or program as well as their options (`sed 'w out'`, `awk '{print > FILENAME}'`), so a withdrawing option is not enough to guard them.
|
|
912
|
+
They are read-only only when the whole command line is **proven** read-only, and anything the proof does not recognize withdraws the claim:
|
|
913
|
+
|
|
914
|
+
| Command | Proven read-only when |
|
|
915
|
+
| ------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
916
|
+
| `sed` | every option is one of `-n`, `-E`, `-r`, `-s`, `-u`, `-z` (clustered or not), `-e`/`--expression`, `--quiet`, `--silent`, `--regexp-extended`, `--separate`, `--unbuffered`, `--null-data`, `--posix`, `--debug`, and `--sandbox`, and every script uses only read-only commands |
|
|
917
|
+
| `awk` | the only options are `-F` and `-v`, and the program text holds no `>`, `\|`, `system`, or `@` |
|
|
918
|
+
|
|
919
|
+
So `sed -n '1,80p' file`, `sed 's/a/b/g' file`, and `awk '{print $1}' file` read, while `sed -i …`, `sed -f script.sed …`, a script with a `w`, `r`, `e`, `a`, `i`, or `c` command, `s///w` or `s///e`, `awk -f prog.awk …`, and `awk '{print > "out"}' …` consult both surfaces.
|
|
920
|
+
A long option is matched only when spelled in full, so every abbreviation (`--in`, `--qui`) withdraws the claim.
|
|
921
|
+
`awk`'s check is a character scan rather than a parse, so a comparison such as `NR>=100` withdraws it too.
|
|
922
|
+
|
|
923
|
+
Two more shapes withdraw the claim:
|
|
924
|
+
|
|
925
|
+
- An argument whose value only the shell decides (`"$range"`, `$f`, `$(cmd)`, `-*`, `-\i`, `{-i,-n}`), wherever it sits, because it could arrive as `-i` or as the script itself.
|
|
926
|
+
- A `sed` script where GNU and BSD would read different commands: a delimiter inside a bracket expression (`s/[/]/x/`), or an `-e` after the first positional.
|
|
927
|
+
|
|
905
928
|
#### Wrapper transparency
|
|
906
929
|
|
|
907
930
|
The [indirection-wrapper floor](#fail-closed-behavior) exists because a wrapper hides the command that should be gated.
|
|
@@ -921,7 +944,7 @@ All four of these must hold, and each is a way the floor's reason could still ap
|
|
|
921
944
|
4. The enclosing statement provably writes no file through a redirect.
|
|
922
945
|
A destination the parse cannot resolve — `> $OUT`, `> $(mktemp)` — counts against the exemption rather than for it.
|
|
923
946
|
|
|
924
|
-
So `xargs grep -l foo`, `xargs wc -l`, and `find . -name '*.ts' -exec cat {} +` stop prompting under a matching `bash` allow, while `xargs rm`, `xargs sed -i`, `time pnpm test`, and `find . -exec sh -c '…' \;` still prompt.
|
|
947
|
+
So `xargs grep -l foo`, `xargs wc -l`, `xargs sed -n 1p`, and `find . -name '*.ts' -exec cat {} +` stop prompting under a matching `bash` allow, while `xargs rm`, `xargs sed -i`, `time pnpm test`, and `find . -exec sh -c '…' \;` still prompt.
|
|
925
948
|
|
|
926
949
|
Three things this does **not** change:
|
|
927
950
|
|
package/package.json
CHANGED
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import type { ArgWord } from "./node-text";
|
|
2
|
+
|
|
3
|
+
// ── Public surface ─────────────────────────────────────────────────────────
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Whether an `awk` invocation's arguments withdraw its read claim.
|
|
7
|
+
*
|
|
8
|
+
* The claim stands only when the command line is **proven** read-only: the
|
|
9
|
+
* only options are `-F` and `-v`, the program arrives as literal text, and
|
|
10
|
+
* that text holds none of the constructs through which a POSIX or GNU awk
|
|
11
|
+
* program writes a file or runs a command.
|
|
12
|
+
*
|
|
13
|
+
* The program check is a character scan, not a parse, so it over-retracts:
|
|
14
|
+
* `NR>=100` withdraws the claim because `>` is also `print >`'s redirect.
|
|
15
|
+
* That costs relief and never safety — a withdrawn claim consults both
|
|
16
|
+
* surfaces, exactly as `awk` did before it joined the core.
|
|
17
|
+
*
|
|
18
|
+
* A computed word withdraws the claim wherever it sits, since it could spell
|
|
19
|
+
* `-f` or the program itself.
|
|
20
|
+
*/
|
|
21
|
+
export function awkWithdrawsReadClaim(argWords: readonly ArgWord[]): boolean {
|
|
22
|
+
if (argWords.some(({ computed }) => computed)) return true;
|
|
23
|
+
const program = selectProgram(argWords.map(({ value }) => value));
|
|
24
|
+
return program === null || !provesReadOnlyProgram(program);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// ── The option walk ────────────────────────────────────────────────────────
|
|
28
|
+
|
|
29
|
+
/** `-F sep` and `-v name=value`: the options every awk shares that cannot write. */
|
|
30
|
+
const VALUE_OPTIONS: ReadonlySet<string> = new Set(["-F", "-v"]);
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* The program text an argument list hands `awk`, or `null` when the list
|
|
34
|
+
* cannot be proven to hand it only that.
|
|
35
|
+
*
|
|
36
|
+
* The program is the first positional. An option-looking word anywhere else
|
|
37
|
+
* withdraws the claim: whether an awk stops reading options at the program
|
|
38
|
+
* varies by implementation, so a trailing `-f` is not assumed to be a file.
|
|
39
|
+
*/
|
|
40
|
+
function selectProgram(values: readonly string[]): string | null {
|
|
41
|
+
let program: string | null = null;
|
|
42
|
+
let optionsEnded = false;
|
|
43
|
+
for (let i = 0; i < values.length; i++) {
|
|
44
|
+
const value = values[i];
|
|
45
|
+
if (optionsEnded || !isOptionWord(value)) {
|
|
46
|
+
program ??= value;
|
|
47
|
+
continue;
|
|
48
|
+
}
|
|
49
|
+
if (program !== null) return null;
|
|
50
|
+
if (value === "--") {
|
|
51
|
+
optionsEnded = true;
|
|
52
|
+
continue;
|
|
53
|
+
}
|
|
54
|
+
if (VALUE_OPTIONS.has(value)) {
|
|
55
|
+
if (i + 1 >= values.length) return null;
|
|
56
|
+
i++;
|
|
57
|
+
continue;
|
|
58
|
+
}
|
|
59
|
+
if (!isAttachedValueOption(value)) return null;
|
|
60
|
+
}
|
|
61
|
+
return program;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function isOptionWord(value: string): boolean {
|
|
65
|
+
return value.startsWith("-") && value !== "-";
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** `-F:` or `-vn=3`: an allowed option carrying its value in the same word. */
|
|
69
|
+
function isAttachedValueOption(value: string): boolean {
|
|
70
|
+
return VALUE_OPTIONS.has(value.slice(0, 2)) && value.length > 2;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// ── The program scan ───────────────────────────────────────────────────────
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Text whose presence means the program may write a file or run a command.
|
|
77
|
+
*
|
|
78
|
+
* `>` covers `print >` and `>>`; `|` covers a pipe to or from a command and
|
|
79
|
+
* gawk's `|&` coprocess; `system` covers `system()`; `@` covers gawk's
|
|
80
|
+
* `@include`, `@load`, and indirect function calls.
|
|
81
|
+
*/
|
|
82
|
+
const WRITE_CAPABLE_TEXT: readonly string[] = [">", "|", "system", "@"];
|
|
83
|
+
|
|
84
|
+
function provesReadOnlyProgram(program: string): boolean {
|
|
85
|
+
return !WRITE_CAPABLE_TEXT.some((text) => program.includes(text));
|
|
86
|
+
}
|
|
@@ -1,4 +1,7 @@
|
|
|
1
1
|
import { type TokenEffect, UNPROVEN_EFFECT } from "#src/access-intent/effect";
|
|
2
|
+
import { awkWithdrawsReadClaim } from "./awk-invocation";
|
|
3
|
+
import type { ArgWord } from "./node-text";
|
|
4
|
+
import { sedWithdrawsReadClaim } from "./sed-invocation";
|
|
2
5
|
|
|
3
6
|
// ── Public surface ─────────────────────────────────────────────────────────
|
|
4
7
|
|
|
@@ -13,7 +16,7 @@ import { type TokenEffect, UNPROVEN_EFFECT } from "#src/access-intent/effect";
|
|
|
13
16
|
* without reading the host's path language.
|
|
14
17
|
*
|
|
15
18
|
* A guarded word's claim is withdrawn when an argument names one of its
|
|
16
|
-
* write-capable options, yielding `retracted` rather than a write: the command
|
|
19
|
+
* write-capable options, or is computed and may arrive as one, yielding `retracted` rather than a write: the command
|
|
17
20
|
* may still only read, so the fail-closed base case is the honest answer and
|
|
18
21
|
* `retracted` is the blame line that says why.
|
|
19
22
|
*
|
|
@@ -22,18 +25,16 @@ import { type TokenEffect, UNPROVEN_EFFECT } from "#src/access-intent/effect";
|
|
|
22
25
|
*/
|
|
23
26
|
export function proveCommandEffect(
|
|
24
27
|
headWord: string,
|
|
25
|
-
argWords: readonly
|
|
28
|
+
argWords: readonly ArgWord[],
|
|
26
29
|
): TokenEffect {
|
|
27
30
|
if (!isBareCoreWord(headWord)) return UNPROVEN_EFFECT;
|
|
28
|
-
const
|
|
29
|
-
if (
|
|
30
|
-
return RETRACTED_EFFECT;
|
|
31
|
-
}
|
|
31
|
+
const withdrawsClaim = RETRACTION_GUARDS.get(headWord);
|
|
32
|
+
if (withdrawsClaim?.(argWords)) return RETRACTED_EFFECT;
|
|
32
33
|
return CORE_READ_EFFECT;
|
|
33
34
|
}
|
|
34
35
|
|
|
35
36
|
/**
|
|
36
|
-
* The
|
|
37
|
+
* The pure-reader core: the command words that are read-only for any
|
|
37
38
|
* arguments, in any implementation.
|
|
38
39
|
*
|
|
39
40
|
* Exported so `docs/configuration.md`'s published roster is held to it by a
|
|
@@ -118,8 +119,7 @@ interface CoreAdmission {
|
|
|
118
119
|
*
|
|
119
120
|
* | Word | Why not |
|
|
120
121
|
* | ----------------------------------------------- | -------------------------------------------------------------- |
|
|
121
|
-
* | `
|
|
122
|
-
* | `sed` | `-i` is in-place, and BSD needs a separate argument where GNU attaches one, so the guard is dialect-variant |
|
|
122
|
+
* | `gawk`, `nawk` | Their own dialects' options are unaudited; `awk` is guarded instead |
|
|
123
123
|
* | `uniq` | `uniq IN OUT` writes its second positional |
|
|
124
124
|
* | `tee`, `dd`, `split`, `csplit`, `xxd`, `tree`, `curl`, `wget` | Each has a positional or option that writes a file |
|
|
125
125
|
* | `less`, `more` | Interactive shell escape (`!cmd`) and `LESSOPEN` preprocessing |
|
|
@@ -160,6 +160,16 @@ function coreAdmissions(): readonly CoreAdmission[] {
|
|
|
160
160
|
reason:
|
|
161
161
|
"Read-only until an argument says otherwise — see RETRACTION_GUARDS",
|
|
162
162
|
},
|
|
163
|
+
{
|
|
164
|
+
words: ["sed"],
|
|
165
|
+
reason:
|
|
166
|
+
"Read-only until the command line says otherwise: an allowlist proof over its options and script — see sed-invocation.ts",
|
|
167
|
+
},
|
|
168
|
+
{
|
|
169
|
+
words: ["awk"],
|
|
170
|
+
reason:
|
|
171
|
+
"Read-only until the command line says otherwise: an allowlist proof over its options and program text — see awk-invocation.ts",
|
|
172
|
+
},
|
|
163
173
|
];
|
|
164
174
|
}
|
|
165
175
|
|
|
@@ -189,18 +199,45 @@ interface RetractionGuard {
|
|
|
189
199
|
}
|
|
190
200
|
|
|
191
201
|
/**
|
|
192
|
-
*
|
|
202
|
+
* Whether a guarded word's arguments withdraw its read claim.
|
|
203
|
+
*
|
|
204
|
+
* Each guarded word owns its own predicate, so a word whose proof needs more
|
|
205
|
+
* than option spellings can supply one without widening the option shape.
|
|
206
|
+
*/
|
|
207
|
+
type ClaimWithdrawal = (argWords: readonly ArgWord[]) => boolean;
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* A withdrawal decided by option spellings: any argument naming one.
|
|
211
|
+
*
|
|
212
|
+
* A computed argument's value is its unresolved source spelling, not what the
|
|
213
|
+
* program receives, so it is asked only whether it may arrive beginning with
|
|
214
|
+
* `-`, the shape every guarded option has.
|
|
215
|
+
*/
|
|
216
|
+
function optionGuard(guard: RetractionGuard): ClaimWithdrawal {
|
|
217
|
+
return (argWords) =>
|
|
218
|
+
argWords.some((word) =>
|
|
219
|
+
word.computed ? word.mayLeadWithDash : retractsClaim(word.value, guard),
|
|
220
|
+
);
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/**
|
|
224
|
+
* The guarded words and what withdraws each one's claim.
|
|
193
225
|
*
|
|
194
|
-
*
|
|
195
|
-
*
|
|
196
|
-
*
|
|
197
|
-
*
|
|
198
|
-
*
|
|
226
|
+
* `find`, `fd`, and `sort` are guarded by option spellings, chosen because
|
|
227
|
+
* their write options spell identically in GNU and BSD; a computed argument
|
|
228
|
+
* that may lead with `-` withdraws their claim too, since it could spell any
|
|
229
|
+
* of them. `sed` needs a
|
|
230
|
+
* proof over its script too, since a `w` command writes whatever its options
|
|
231
|
+
* say, and `awk` a proof over its program, since `print >` does the same — so
|
|
232
|
+
* each owns a predicate of its own. `find`'s options are single-dash long
|
|
233
|
+
* words that never cluster, so they match as exact words; `sort`'s only short
|
|
234
|
+
* option containing `o` is `-o` itself, so the cluster rule cannot
|
|
235
|
+
* over-retract there.
|
|
199
236
|
*/
|
|
200
|
-
const RETRACTION_GUARDS: ReadonlyMap<string,
|
|
237
|
+
const RETRACTION_GUARDS: ReadonlyMap<string, ClaimWithdrawal> = new Map([
|
|
201
238
|
[
|
|
202
239
|
"find",
|
|
203
|
-
{
|
|
240
|
+
optionGuard({
|
|
204
241
|
exactWords: new Set([
|
|
205
242
|
"-exec",
|
|
206
243
|
"-execdir",
|
|
@@ -212,22 +249,24 @@ const RETRACTION_GUARDS: ReadonlyMap<string, RetractionGuard> = new Map([
|
|
|
212
249
|
"-fprintf",
|
|
213
250
|
"-fls",
|
|
214
251
|
]),
|
|
215
|
-
},
|
|
252
|
+
}),
|
|
216
253
|
],
|
|
217
254
|
[
|
|
218
255
|
"fd",
|
|
219
|
-
{
|
|
256
|
+
optionGuard({
|
|
220
257
|
longStems: new Set(["--exec", "--exec-batch"]),
|
|
221
258
|
shortLetters: new Set(["x", "X"]),
|
|
222
|
-
},
|
|
259
|
+
}),
|
|
223
260
|
],
|
|
224
261
|
[
|
|
225
262
|
"sort",
|
|
226
|
-
{
|
|
263
|
+
optionGuard({
|
|
227
264
|
longStems: new Set(["--output"]),
|
|
228
265
|
shortLetters: new Set(["o"]),
|
|
229
|
-
},
|
|
266
|
+
}),
|
|
230
267
|
],
|
|
268
|
+
["sed", sedWithdrawsReadClaim],
|
|
269
|
+
["awk", awkWithdrawsReadClaim],
|
|
231
270
|
]);
|
|
232
271
|
|
|
233
272
|
// ── Private helpers ────────────────────────────────────────────────────────
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { BashCommandContext, FloorExemption } from "#src/types";
|
|
2
2
|
import { EXECUTION_HOST_TYPES, forEachExecutionIn } from "./nested-execution";
|
|
3
|
+
import { readArgWord } from "./node-text";
|
|
3
4
|
import { parseUnresolvedWithin } from "./parse-health";
|
|
4
5
|
import type { TSNode } from "./parser";
|
|
5
6
|
import { REDIRECT_NODE_TYPES, redirectMayWriteFile } from "./redirect-analysis";
|
|
@@ -284,7 +285,11 @@ export function collectSalvagedCommands(node: TSNode): BashCommand[] {
|
|
|
284
285
|
export function inlineShellPayloadNode(command: TSNode): TSNode | null {
|
|
285
286
|
const nodes = commandWordNodes(command);
|
|
286
287
|
const index = inlineShellPayloadIndex(
|
|
287
|
-
nodes.map((node) => ({
|
|
288
|
+
nodes.map((node) => ({
|
|
289
|
+
...readArgWord(node),
|
|
290
|
+
text: node.text,
|
|
291
|
+
offset: node.startIndex,
|
|
292
|
+
})),
|
|
288
293
|
);
|
|
289
294
|
return index === -1 ? null : (nodes.at(index) ?? null);
|
|
290
295
|
}
|
|
@@ -490,7 +495,7 @@ function readCommandUnit(node: TSNode): {
|
|
|
490
495
|
let previous: TSNode | undefined;
|
|
491
496
|
for (const word of nodes) {
|
|
492
497
|
if (previous) text += gapBetween(node, previous, word, redirects);
|
|
493
|
-
words.push({ text: word.text, offset: text.length });
|
|
498
|
+
words.push({ ...readArgWord(word), text: word.text, offset: text.length });
|
|
494
499
|
text += word.text;
|
|
495
500
|
previous = word;
|
|
496
501
|
}
|
|
@@ -27,6 +27,212 @@ export const ARG_NODE_TYPES = new Set([
|
|
|
27
27
|
"raw_string",
|
|
28
28
|
]);
|
|
29
29
|
|
|
30
|
+
/**
|
|
31
|
+
* One argument as the program receives it, and whether that is knowable.
|
|
32
|
+
*
|
|
33
|
+
* A capability proof must read the value after quote removal, since `'-o'` and
|
|
34
|
+
* `-o` reach `sort` identically, and must know when the value is computed,
|
|
35
|
+
* since a word only the shell decides can spell any option at all.
|
|
36
|
+
*/
|
|
37
|
+
export interface ArgWord {
|
|
38
|
+
/** The string the shell passes after quote removal ({@link resolveNodeText}). */
|
|
39
|
+
readonly value: string;
|
|
40
|
+
/**
|
|
41
|
+
* Whether `value` may differ from what the program receives: a part only
|
|
42
|
+
* running the command decides ({@link hasComputedPart}), or a spelling the
|
|
43
|
+
* shell rewrites before the program sees it — an escape, a glob, a brace
|
|
44
|
+
* expansion, an ANSI-C string — which {@link resolveNodeText} passes through as written.
|
|
45
|
+
*/
|
|
46
|
+
readonly computed: boolean;
|
|
47
|
+
/**
|
|
48
|
+
* Whether the program may receive this argument, or a word split from it,
|
|
49
|
+
* beginning with `-` — the shape every option has. Exact for a word that is
|
|
50
|
+
* not computed; for a computed one, `false` only when a literal leading
|
|
51
|
+
* character survives every rewrite the shell applies.
|
|
52
|
+
*/
|
|
53
|
+
readonly mayLeadWithDash: boolean;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Read an argument node into the word the program receives. */
|
|
57
|
+
export function readArgWord(node: TSNode): ArgWord {
|
|
58
|
+
const value = resolveNodeText(node);
|
|
59
|
+
const computed = hasComputedPart(node) || !isSpelledExactly(node);
|
|
60
|
+
return {
|
|
61
|
+
value,
|
|
62
|
+
computed,
|
|
63
|
+
mayLeadWithDash: computed
|
|
64
|
+
? mayExpandToDashWord(node)
|
|
65
|
+
: value.startsWith("-"),
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Whether a computed word may reach the program — whole, or as one of the
|
|
71
|
+
* words the shell splits it into — beginning with `-`.
|
|
72
|
+
*
|
|
73
|
+
* Globbing, brace expansion, and escape removal each keep a literal prefix, so
|
|
74
|
+
* a word whose leading character is a literal other than `-` cannot become an
|
|
75
|
+
* option; an expansion that can split into several words decides nothing,
|
|
76
|
+
* since only the first of them carries the prefix.
|
|
77
|
+
*/
|
|
78
|
+
function mayExpandToDashWord(node: TSNode): boolean {
|
|
79
|
+
return (
|
|
80
|
+
maySplitIntoWords(node, false) || (leadingCharacterMayBeDash(node) ?? true)
|
|
81
|
+
);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** Expansions whose unquoted result the shell splits into words. */
|
|
85
|
+
const WORD_SPLITTING_TYPES: ReadonlySet<string> = new Set([
|
|
86
|
+
"simple_expansion",
|
|
87
|
+
"expansion",
|
|
88
|
+
"command_substitution",
|
|
89
|
+
"arithmetic_expansion",
|
|
90
|
+
]);
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Nodes whose inside is never split into the argument's words: a
|
|
94
|
+
* single-quoted or ANSI-C string, and a process substitution, whose body
|
|
95
|
+
* belongs to its own command.
|
|
96
|
+
*/
|
|
97
|
+
const UNSPLIT_TYPES: ReadonlySet<string> = new Set([
|
|
98
|
+
"raw_string",
|
|
99
|
+
"ansi_c_string",
|
|
100
|
+
"process_substitution",
|
|
101
|
+
]);
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Whether any expansion in the word may produce more than one word.
|
|
105
|
+
*
|
|
106
|
+
* Unquoted, every expansion splits. Inside double quotes a command
|
|
107
|
+
* substitution or arithmetic expansion is one word, but a parameter expansion
|
|
108
|
+
* may not be: `$@` and `${arr[@]}` expand to one word per element, an indirect
|
|
109
|
+
* `${!name}` may name either, and any variable may be a nameref
|
|
110
|
+
* (`declare -n s='arr[@]'`), with nothing in its own spelling to show it. So
|
|
111
|
+
* every quoted parameter expansion counts, over-counting a joined `$*`.
|
|
112
|
+
*/
|
|
113
|
+
function maySplitIntoWords(node: TSNode, quoted: boolean): boolean {
|
|
114
|
+
if (WORD_SPLITTING_TYPES.has(node.type)) {
|
|
115
|
+
return !quoted || PARAMETER_EXPANSION_TYPES.has(node.type);
|
|
116
|
+
}
|
|
117
|
+
if (UNSPLIT_TYPES.has(node.type)) return false;
|
|
118
|
+
const childrenQuoted = quoted || node.type === "string";
|
|
119
|
+
for (let i = 0; i < node.childCount; i++) {
|
|
120
|
+
const child = node.child(i);
|
|
121
|
+
if (child && maySplitIntoWords(child, childrenQuoted)) return true;
|
|
122
|
+
}
|
|
123
|
+
return false;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** Parameter expansions, which may yield several words even when quoted. */
|
|
127
|
+
const PARAMETER_EXPANSION_TYPES: ReadonlySet<string> = new Set([
|
|
128
|
+
"simple_expansion",
|
|
129
|
+
"expansion",
|
|
130
|
+
]);
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Whether the first character the word produces may be `-`, or `undefined`
|
|
134
|
+
* while every part read so far produced nothing (`""`, `''`).
|
|
135
|
+
*
|
|
136
|
+
* A part this does not know is non-literal, and so may lead with anything.
|
|
137
|
+
*/
|
|
138
|
+
function leadingCharacterMayBeDash(node: TSNode): boolean | undefined {
|
|
139
|
+
switch (node.type) {
|
|
140
|
+
case "word":
|
|
141
|
+
case "number":
|
|
142
|
+
return unquotedLiteralMayLeadWithDash(node.text);
|
|
143
|
+
case "raw_string":
|
|
144
|
+
return quotedLiteralMayLeadWithDash(node.text.slice(1, -1));
|
|
145
|
+
case "string_content":
|
|
146
|
+
return quotedLiteralMayLeadWithDash(node.text);
|
|
147
|
+
case '"':
|
|
148
|
+
return undefined;
|
|
149
|
+
case "process_substitution":
|
|
150
|
+
// The program receives a `/dev/fd/N` path.
|
|
151
|
+
return false;
|
|
152
|
+
case "string":
|
|
153
|
+
case "concatenation":
|
|
154
|
+
for (let i = 0; i < node.childCount; i++) {
|
|
155
|
+
const child = node.child(i);
|
|
156
|
+
const lead = child ? leadingCharacterMayBeDash(child) : undefined;
|
|
157
|
+
if (lead !== undefined) return lead;
|
|
158
|
+
}
|
|
159
|
+
return undefined;
|
|
160
|
+
default:
|
|
161
|
+
return true;
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* An unquoted literal: a leading glob or brace can produce any character, and
|
|
167
|
+
* an escape yields the character after it.
|
|
168
|
+
*/
|
|
169
|
+
function unquotedLiteralMayLeadWithDash(text: string): boolean | undefined {
|
|
170
|
+
if (text === "") return undefined;
|
|
171
|
+
if (text.startsWith("\\")) return text.length < 2 || text[1] === "-";
|
|
172
|
+
return UNDETERMINED_LEAD.test(text);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/** A leading character that may become, or already is, `-`. */
|
|
176
|
+
const UNDETERMINED_LEAD = /^[-*?[{]/;
|
|
177
|
+
|
|
178
|
+
/** A quoted literal reaches the program as written. */
|
|
179
|
+
function quotedLiteralMayLeadWithDash(text: string): boolean | undefined {
|
|
180
|
+
if (text === "") return undefined;
|
|
181
|
+
return text.startsWith("-");
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* Whether {@link resolveNodeText} returns exactly the string the shell passes.
|
|
186
|
+
*
|
|
187
|
+
* Answers `false` for any node type it does not know, which is the
|
|
188
|
+
* fail-closed direction for a caller proving what a word cannot be.
|
|
189
|
+
*/
|
|
190
|
+
function isSpelledExactly(node: TSNode): boolean {
|
|
191
|
+
switch (node.type) {
|
|
192
|
+
case "raw_string":
|
|
193
|
+
return true;
|
|
194
|
+
case "word":
|
|
195
|
+
return !SHELL_REWRITTEN_CHARACTERS.test(node.text);
|
|
196
|
+
case "string_content":
|
|
197
|
+
return !node.text.includes("\\");
|
|
198
|
+
case "simple_expansion":
|
|
199
|
+
case "expansion":
|
|
200
|
+
return resolvePlainVariableExpansion(node) !== null;
|
|
201
|
+
case "string":
|
|
202
|
+
// A digit run is an ordinary word to the shell; the grammar's `10#$x` form
|
|
203
|
+
// carries an expansion child, which answers for itself.
|
|
204
|
+
case "number":
|
|
205
|
+
return childrenSpelledExactly(node);
|
|
206
|
+
case "concatenation":
|
|
207
|
+
// The grammar splits `{-i,-n}` into plain words, so the expansion is
|
|
208
|
+
// visible only across the whole concatenation's text.
|
|
209
|
+
return !BRACE_EXPANSION.test(node.text) && childrenSpelledExactly(node);
|
|
210
|
+
default:
|
|
211
|
+
return false;
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/** A `"` delimiter is spelled exactly; every named child must be too. */
|
|
216
|
+
function childrenSpelledExactly(node: TSNode): boolean {
|
|
217
|
+
for (let i = 0; i < node.childCount; i++) {
|
|
218
|
+
const child = node.child(i);
|
|
219
|
+
if (!child || child.type === '"') continue;
|
|
220
|
+
if (!isSpelledExactly(child)) return false;
|
|
221
|
+
}
|
|
222
|
+
return true;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/** An escape, or a glob the shell may expand into other words. */
|
|
226
|
+
const SHELL_REWRITTEN_CHARACTERS = /[\\*?[]/;
|
|
227
|
+
|
|
228
|
+
/**
|
|
229
|
+
* A brace the shell expands: one holding a `,` or a `..` range.
|
|
230
|
+
*
|
|
231
|
+
* An empty `{}` is left alone by bash, which is why `find -exec … {} +` keeps
|
|
232
|
+
* its placeholder as written.
|
|
233
|
+
*/
|
|
234
|
+
const BRACE_EXPANSION = /\{[^}]*(,|\.\.)[^}]*\}/;
|
|
235
|
+
|
|
30
236
|
/**
|
|
31
237
|
* Whether an argument node's value is decided at run time: it contains a
|
|
32
238
|
* command or process substitution, an arithmetic expansion, or a variable
|
|
@@ -0,0 +1,471 @@
|
|
|
1
|
+
import type { ArgWord } from "./node-text";
|
|
2
|
+
|
|
3
|
+
// ── Public surface ─────────────────────────────────────────────────────────
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Whether a `sed` invocation's arguments withdraw its read claim.
|
|
7
|
+
*
|
|
8
|
+
* The claim stands only when the whole command line is **proven** read-only:
|
|
9
|
+
* every option is one the allowlist knows, and every script parses under a
|
|
10
|
+
* grammar that admits no command able to write a file or run one. Anything
|
|
11
|
+
* the walk does not recognize withdraws the claim, so an unknown option or
|
|
12
|
+
* command costs one ask rather than a missed write.
|
|
13
|
+
*
|
|
14
|
+
* A computed word withdraws the claim wherever it sits: GNU `sed` permutes
|
|
15
|
+
* options, so even a trailing `"$f"` can arrive as `-i`.
|
|
16
|
+
*
|
|
17
|
+
* The walk never needs to know which `sed` is installed. Where GNU and BSD
|
|
18
|
+
* would read the same argument list differently — an `-e` after the first
|
|
19
|
+
* positional, which GNU takes as a script and BSD as a file — it withdraws.
|
|
20
|
+
*/
|
|
21
|
+
export function sedWithdrawsReadClaim(argWords: readonly ArgWord[]): boolean {
|
|
22
|
+
if (argWords.some(({ computed }) => computed)) return true;
|
|
23
|
+
const scripts = selectScripts(argWords.map(({ value }) => value));
|
|
24
|
+
if (scripts === null) return true;
|
|
25
|
+
return !scripts.every(provesReadOnlyScript);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// ── The option walk ────────────────────────────────────────────────────────
|
|
29
|
+
|
|
30
|
+
/** Short options that take no argument and cannot write: `-n -E -r -s -u -z`. */
|
|
31
|
+
const FLAG_LETTERS: ReadonlySet<string> = new Set([
|
|
32
|
+
"n",
|
|
33
|
+
"E",
|
|
34
|
+
"r",
|
|
35
|
+
"s",
|
|
36
|
+
"u",
|
|
37
|
+
"z",
|
|
38
|
+
]);
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Long options that take no argument and cannot write, matched exactly.
|
|
42
|
+
*
|
|
43
|
+
* Abbreviations are deliberately absent: GNU accepts `--qui` for `--quiet`
|
|
44
|
+
* and `--in` for `--in-place` alike, and matching whole words only is what
|
|
45
|
+
* lets every abbreviation withdraw without a prefix rule.
|
|
46
|
+
*/
|
|
47
|
+
const FLAG_WORDS: ReadonlySet<string> = new Set([
|
|
48
|
+
"--quiet",
|
|
49
|
+
"--silent",
|
|
50
|
+
"--regexp-extended",
|
|
51
|
+
"--separate",
|
|
52
|
+
"--unbuffered",
|
|
53
|
+
"--null-data",
|
|
54
|
+
"--posix",
|
|
55
|
+
"--debug",
|
|
56
|
+
"--sandbox",
|
|
57
|
+
]);
|
|
58
|
+
|
|
59
|
+
const EXPRESSION_OPTION = "--expression";
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* The scripts an argument list hands `sed`, or `null` when the list cannot be
|
|
63
|
+
* proven to hand it only those.
|
|
64
|
+
*
|
|
65
|
+
* Every `-e` value is a script; with none, the first positional is. Options
|
|
66
|
+
* are recognized wherever they sit until a `--`, because GNU permutes them.
|
|
67
|
+
*/
|
|
68
|
+
function selectScripts(values: readonly string[]): string[] | null {
|
|
69
|
+
const scripts: string[] = [];
|
|
70
|
+
let firstPositional: string | undefined;
|
|
71
|
+
let optionsEnded = false;
|
|
72
|
+
for (let i = 0; i < values.length; i++) {
|
|
73
|
+
const value = values[i];
|
|
74
|
+
if (optionsEnded || !isOptionWord(value)) {
|
|
75
|
+
firstPositional ??= value;
|
|
76
|
+
continue;
|
|
77
|
+
}
|
|
78
|
+
if (value === "--") {
|
|
79
|
+
optionsEnded = true;
|
|
80
|
+
continue;
|
|
81
|
+
}
|
|
82
|
+
if (FLAG_WORDS.has(value)) continue;
|
|
83
|
+
const expression = expressionOf(value, values[i + 1]);
|
|
84
|
+
if (expression === null) return null;
|
|
85
|
+
// An -e after a positional is a script to GNU and a file to BSD.
|
|
86
|
+
if (expression.script !== undefined && firstPositional !== undefined) {
|
|
87
|
+
return null;
|
|
88
|
+
}
|
|
89
|
+
if (expression.script !== undefined) scripts.push(expression.script);
|
|
90
|
+
if (expression.consumesNext) i++;
|
|
91
|
+
}
|
|
92
|
+
if (scripts.length > 0) return scripts;
|
|
93
|
+
return firstPositional === undefined ? null : [firstPositional];
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function isOptionWord(value: string): boolean {
|
|
97
|
+
return value.startsWith("-") && value !== "-";
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** What one option word contributes: a script, and whether it took the next word. */
|
|
101
|
+
interface OptionReading {
|
|
102
|
+
readonly script?: string;
|
|
103
|
+
readonly consumesNext: boolean;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Read one option word, or `null` when it is not one the allowlist proves.
|
|
108
|
+
*
|
|
109
|
+
* A short cluster may hold only flag letters until an `e`, which ends it: the
|
|
110
|
+
* rest of the word is the script, or the next word is when nothing follows.
|
|
111
|
+
*/
|
|
112
|
+
function expressionOf(
|
|
113
|
+
value: string,
|
|
114
|
+
next: string | undefined,
|
|
115
|
+
): OptionReading | null {
|
|
116
|
+
if (value.startsWith("--")) return longExpressionOf(value, next);
|
|
117
|
+
for (let j = 1; j < value.length; j++) {
|
|
118
|
+
const letter = value[j];
|
|
119
|
+
if (letter === "e") {
|
|
120
|
+
const attached = value.slice(j + 1);
|
|
121
|
+
if (attached !== "") return { script: attached, consumesNext: false };
|
|
122
|
+
return next === undefined ? null : { script: next, consumesNext: true };
|
|
123
|
+
}
|
|
124
|
+
if (!FLAG_LETTERS.has(letter)) return null;
|
|
125
|
+
}
|
|
126
|
+
return { consumesNext: false };
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function longExpressionOf(
|
|
130
|
+
value: string,
|
|
131
|
+
next: string | undefined,
|
|
132
|
+
): OptionReading | null {
|
|
133
|
+
if (value.startsWith(`${EXPRESSION_OPTION}=`)) {
|
|
134
|
+
return {
|
|
135
|
+
script: value.slice(EXPRESSION_OPTION.length + 1),
|
|
136
|
+
consumesNext: false,
|
|
137
|
+
};
|
|
138
|
+
}
|
|
139
|
+
if (value !== EXPRESSION_OPTION || next === undefined) return null;
|
|
140
|
+
return { script: next, consumesNext: true };
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// ── The script grammar ─────────────────────────────────────────────────────
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Commands that take no argument and neither write a file nor run a command.
|
|
147
|
+
*
|
|
148
|
+
* `l`, `q`, and `Q` accept an optional number, read by the scanner.
|
|
149
|
+
*/
|
|
150
|
+
const READ_ONLY_COMMANDS: ReadonlySet<string> = new Set([
|
|
151
|
+
"p",
|
|
152
|
+
"P",
|
|
153
|
+
"d",
|
|
154
|
+
"D",
|
|
155
|
+
"n",
|
|
156
|
+
"N",
|
|
157
|
+
"g",
|
|
158
|
+
"G",
|
|
159
|
+
"h",
|
|
160
|
+
"H",
|
|
161
|
+
"x",
|
|
162
|
+
"=",
|
|
163
|
+
"z",
|
|
164
|
+
"F",
|
|
165
|
+
"l",
|
|
166
|
+
"q",
|
|
167
|
+
"Q",
|
|
168
|
+
]);
|
|
169
|
+
|
|
170
|
+
/** The commands among {@link READ_ONLY_COMMANDS} that take an optional number. */
|
|
171
|
+
const NUMBERED_COMMANDS: ReadonlySet<string> = new Set(["l", "q", "Q"]);
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* Whether a script is made only of commands that cannot write a file or run
|
|
175
|
+
* a command.
|
|
176
|
+
*
|
|
177
|
+
* An allowlist grammar: `w`, `W`, `r`, `R`, `e`, `v`, `a`, `i`, `c`, an `s`
|
|
178
|
+
* flag outside `g p i I m M` and digits (so `s///w` and `s///e`), and any
|
|
179
|
+
* character the scanner does not recognize end the proof.
|
|
180
|
+
*/
|
|
181
|
+
function provesReadOnlyScript(script: string): boolean {
|
|
182
|
+
return new ScriptScanner(script).provesReadOnly();
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/** Flags an `s` command may carry without writing a file or running one. */
|
|
186
|
+
const SUBSTITUTION_FLAGS = /[gpiImM0-9]/;
|
|
187
|
+
|
|
188
|
+
/** Flags a regex address may carry: GNU's case-insensitive and multiline. */
|
|
189
|
+
const ADDRESS_FLAGS: ReadonlySet<string> = new Set(["I", "M"]);
|
|
190
|
+
|
|
191
|
+
/** Commands whose argument is a label running to the end of the command. */
|
|
192
|
+
const LABEL_COMMANDS: ReadonlySet<string> = new Set(["b", "t", "T", ":"]);
|
|
193
|
+
|
|
194
|
+
/** How an address position read: nothing there, an address, or a malformed one. */
|
|
195
|
+
type AddressReading = "none" | "read" | "invalid";
|
|
196
|
+
|
|
197
|
+
/** A single left-to-right pass over one script. */
|
|
198
|
+
class ScriptScanner {
|
|
199
|
+
private position = 0;
|
|
200
|
+
private openBlocks = 0;
|
|
201
|
+
|
|
202
|
+
constructor(private readonly source: string) {}
|
|
203
|
+
|
|
204
|
+
provesReadOnly(): boolean {
|
|
205
|
+
this.skipSeparators();
|
|
206
|
+
while (!this.atEnd()) {
|
|
207
|
+
if (!this.readCommand()) return false;
|
|
208
|
+
this.skipSeparators();
|
|
209
|
+
}
|
|
210
|
+
return this.openBlocks === 0;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/** One `[address[,address]][!…]command`, and what may follow it. */
|
|
214
|
+
private readCommand(): boolean {
|
|
215
|
+
if (this.peek() === "#") return this.skipComment();
|
|
216
|
+
if (!this.readAddresses()) return false;
|
|
217
|
+
this.skipBlanks();
|
|
218
|
+
while (this.peek() === "!") {
|
|
219
|
+
this.position++;
|
|
220
|
+
this.skipBlanks();
|
|
221
|
+
}
|
|
222
|
+
const command = this.peek();
|
|
223
|
+
if (command === undefined) return false;
|
|
224
|
+
this.position++;
|
|
225
|
+
if (command === "{") {
|
|
226
|
+
this.openBlocks++;
|
|
227
|
+
return true;
|
|
228
|
+
}
|
|
229
|
+
if (!this.readCommandBody(command)) return false;
|
|
230
|
+
this.skipBlanks();
|
|
231
|
+
return this.atEnd() || this.atSeparator() || this.closesBlock();
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/** What follows a command letter, up to where a separator must come. */
|
|
235
|
+
private readCommandBody(command: string): boolean {
|
|
236
|
+
if (command === "}") return this.closeBlock();
|
|
237
|
+
if (command === "s") return this.readSubstitution();
|
|
238
|
+
if (command === "y") return this.readTransliteration();
|
|
239
|
+
if (LABEL_COMMANDS.has(command)) return this.readLabel(command);
|
|
240
|
+
if (!READ_ONLY_COMMANDS.has(command)) return false;
|
|
241
|
+
if (NUMBERED_COMMANDS.has(command)) {
|
|
242
|
+
this.skipBlanks();
|
|
243
|
+
this.skipDigits();
|
|
244
|
+
}
|
|
245
|
+
return true;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/** An optional address, then an optional `,` and a second one. */
|
|
249
|
+
private readAddresses(): boolean {
|
|
250
|
+
const first = this.readAddress();
|
|
251
|
+
if (first === "invalid") return false;
|
|
252
|
+
if (first === "none") return true;
|
|
253
|
+
this.skipBlanks();
|
|
254
|
+
if (this.peek() !== ",") return true;
|
|
255
|
+
this.position++;
|
|
256
|
+
this.skipBlanks();
|
|
257
|
+
return this.readSecondAddress();
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/** A line number, `first~step`, `$`, `/re/`, or `\cREc`. */
|
|
261
|
+
private readAddress(): AddressReading {
|
|
262
|
+
const next = this.peek();
|
|
263
|
+
if (next === "$") {
|
|
264
|
+
this.position++;
|
|
265
|
+
return "read";
|
|
266
|
+
}
|
|
267
|
+
if (next === "/" || next === "\\") return this.readRegexAddress();
|
|
268
|
+
if (!this.skipDigits()) return "none";
|
|
269
|
+
if (this.peek() === "~") {
|
|
270
|
+
this.position++;
|
|
271
|
+
this.skipDigits();
|
|
272
|
+
}
|
|
273
|
+
return "read";
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
private readRegexAddress(): AddressReading {
|
|
277
|
+
if (this.peek() === "\\") this.position++;
|
|
278
|
+
const delimiter = this.takeDelimiter();
|
|
279
|
+
if (delimiter === null || !this.readRegex(delimiter)) return "invalid";
|
|
280
|
+
while (ADDRESS_FLAGS.has(this.peek() ?? "")) this.position++;
|
|
281
|
+
return "read";
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
/** The end of a range: an address, or GNU's `+N` / `~N`. */
|
|
285
|
+
private readSecondAddress(): boolean {
|
|
286
|
+
const sign = this.peek();
|
|
287
|
+
if (sign === "+" || sign === "~") {
|
|
288
|
+
this.position++;
|
|
289
|
+
return this.skipDigits();
|
|
290
|
+
}
|
|
291
|
+
return this.readAddress() === "read";
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
/** `s/regex/replacement/flags`, whose flags must not write or execute. */
|
|
295
|
+
private readSubstitution(): boolean {
|
|
296
|
+
const delimiter = this.takeDelimiter();
|
|
297
|
+
if (delimiter === null) return false;
|
|
298
|
+
if (!this.readRegex(delimiter)) return false;
|
|
299
|
+
if (!this.readLiteral(delimiter)) return false;
|
|
300
|
+
while (SUBSTITUTION_FLAGS.test(this.peek() ?? "")) this.position++;
|
|
301
|
+
return true;
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
/** `y/source/target/`, whose operands are character lists, not regexes. */
|
|
305
|
+
private readTransliteration(): boolean {
|
|
306
|
+
const delimiter = this.takeDelimiter();
|
|
307
|
+
if (delimiter === null) return false;
|
|
308
|
+
return this.readLiteral(delimiter) && this.readLiteral(delimiter);
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
/**
|
|
312
|
+
* A label, running to `;` or a newline.
|
|
313
|
+
*
|
|
314
|
+
* GNU ends a label there; BSD runs it to the newline, so it sees fewer
|
|
315
|
+
* commands than this reading does, never more. A `}` stays in the label,
|
|
316
|
+
* which leaves its block open and the proof unfinished — the reading on
|
|
317
|
+
* which the two agree least.
|
|
318
|
+
*/
|
|
319
|
+
private readLabel(command: string): boolean {
|
|
320
|
+
this.skipBlanks();
|
|
321
|
+
const start = this.position;
|
|
322
|
+
while (!this.atEnd() && !this.atSeparator()) this.position++;
|
|
323
|
+
return command !== ":" || this.position > start;
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
private closeBlock(): boolean {
|
|
327
|
+
this.openBlocks--;
|
|
328
|
+
return this.openBlocks >= 0;
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
/** A `}` directly after a command ends it, as a separator would. */
|
|
332
|
+
private closesBlock(): boolean {
|
|
333
|
+
return this.peek() === "}";
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
private skipComment(): boolean {
|
|
337
|
+
while (!this.atEnd() && this.peek() !== "\n") this.position++;
|
|
338
|
+
return true;
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
/**
|
|
342
|
+
* The delimiter a regex or `s`/`y` command opens with, or `null` when it is
|
|
343
|
+
* one this grammar does not accept.
|
|
344
|
+
*
|
|
345
|
+
* A letter, digit, backslash, blank, or newline delimiter is legal to GNU in
|
|
346
|
+
* places and a trap everywhere: `\n` and `\d` stop meaning what they say.
|
|
347
|
+
*/
|
|
348
|
+
private takeDelimiter(): string | null {
|
|
349
|
+
const delimiter = this.peek();
|
|
350
|
+
if (delimiter === undefined || !/[!-/:-@[-`{-~]/.test(delimiter)) {
|
|
351
|
+
return null;
|
|
352
|
+
}
|
|
353
|
+
if (delimiter === "\\") return null;
|
|
354
|
+
this.position++;
|
|
355
|
+
return delimiter;
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
/**
|
|
359
|
+
* A regex up to its closing delimiter, bracket expressions included.
|
|
360
|
+
*
|
|
361
|
+
* A delimiter inside a bracket expression ends the proof: BSD reads `[/]`
|
|
362
|
+
* as a bracket and GNU ends the regex at its `/`, so the two disagree about
|
|
363
|
+
* where every later section starts.
|
|
364
|
+
*/
|
|
365
|
+
private readRegex(delimiter: string): boolean {
|
|
366
|
+
while (!this.atEnd()) {
|
|
367
|
+
const next = this.peek();
|
|
368
|
+
if (next === "\n") return false;
|
|
369
|
+
if (next === delimiter) {
|
|
370
|
+
this.position++;
|
|
371
|
+
return true;
|
|
372
|
+
}
|
|
373
|
+
if (next === "\\") {
|
|
374
|
+
this.position += 2;
|
|
375
|
+
continue;
|
|
376
|
+
}
|
|
377
|
+
if (next === "[") {
|
|
378
|
+
if (!this.readBracket(delimiter)) return false;
|
|
379
|
+
continue;
|
|
380
|
+
}
|
|
381
|
+
this.position++;
|
|
382
|
+
}
|
|
383
|
+
return false;
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
/** `[…]`, with `]` literal first and `[:class:]` / `[.x.]` / `[=x=]` inside. */
|
|
387
|
+
private readBracket(delimiter: string): boolean {
|
|
388
|
+
this.position++;
|
|
389
|
+
if (this.peek() === "^") this.position++;
|
|
390
|
+
if (this.peek() === "]") this.position++;
|
|
391
|
+
while (!this.atEnd()) {
|
|
392
|
+
const next = this.peek();
|
|
393
|
+
if (next === "]") {
|
|
394
|
+
this.position++;
|
|
395
|
+
return true;
|
|
396
|
+
}
|
|
397
|
+
if (next === delimiter || next === "\n") return false;
|
|
398
|
+
if (next === "[" && this.opensBracketClass()) {
|
|
399
|
+
if (!this.skipBracketClass(delimiter)) return false;
|
|
400
|
+
continue;
|
|
401
|
+
}
|
|
402
|
+
this.position++;
|
|
403
|
+
}
|
|
404
|
+
return false;
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
private opensBracketClass(): boolean {
|
|
408
|
+
const marker = this.source[this.position + 1];
|
|
409
|
+
return marker === ":" || marker === "." || marker === "=";
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
private skipBracketClass(delimiter: string): boolean {
|
|
413
|
+
const marker = this.source[this.position + 1];
|
|
414
|
+
const close = this.source.indexOf(`${marker}]`, this.position + 2);
|
|
415
|
+
if (close === -1) return false;
|
|
416
|
+
const body = this.source.slice(this.position + 2, close);
|
|
417
|
+
if (body.includes(delimiter)) return false;
|
|
418
|
+
this.position = close + 2;
|
|
419
|
+
return true;
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
/**
|
|
423
|
+
* A replacement or `y` operand up to its closing delimiter.
|
|
424
|
+
*
|
|
425
|
+
* Brackets mean nothing here, so `s/x/[]/` is the literal `[]`.
|
|
426
|
+
*/
|
|
427
|
+
private readLiteral(delimiter: string): boolean {
|
|
428
|
+
while (!this.atEnd()) {
|
|
429
|
+
const next = this.peek();
|
|
430
|
+
if (next === "\n") return false;
|
|
431
|
+
if (next === delimiter) {
|
|
432
|
+
this.position++;
|
|
433
|
+
return true;
|
|
434
|
+
}
|
|
435
|
+
this.position += next === "\\" ? 2 : 1;
|
|
436
|
+
}
|
|
437
|
+
return false;
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
private skipSeparators(): void {
|
|
441
|
+
while (this.atSeparator() || this.isBlank(this.peek())) this.position++;
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
private skipBlanks(): void {
|
|
445
|
+
while (this.isBlank(this.peek())) this.position++;
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
/** Advance over digits; true when there was at least one. */
|
|
449
|
+
private skipDigits(): boolean {
|
|
450
|
+
const start = this.position;
|
|
451
|
+
while (/[0-9]/.test(this.peek() ?? "")) this.position++;
|
|
452
|
+
return this.position > start;
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
private atSeparator(): boolean {
|
|
456
|
+
const next = this.peek();
|
|
457
|
+
return next === ";" || next === "\n";
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
private isBlank(character: string | undefined): boolean {
|
|
461
|
+
return character === " " || character === "\t";
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
private atEnd(): boolean {
|
|
465
|
+
return this.position >= this.source.length;
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
private peek(): string | undefined {
|
|
469
|
+
return this.source[this.position];
|
|
470
|
+
}
|
|
471
|
+
}
|
|
@@ -4,12 +4,15 @@ import { proveCommandEffect } from "./command-effects";
|
|
|
4
4
|
import { EXECUTION_HOST_TYPES, forEachExecutionIn } from "./nested-execution";
|
|
5
5
|
import {
|
|
6
6
|
ARG_NODE_TYPES,
|
|
7
|
+
type ArgWord,
|
|
7
8
|
hasComputedPart,
|
|
9
|
+
readArgWord,
|
|
8
10
|
resolveNodeText,
|
|
9
11
|
SKIP_SUBTREE_TYPES,
|
|
10
12
|
} from "./node-text";
|
|
11
13
|
import type { TSNode } from "./parser";
|
|
12
14
|
import {
|
|
15
|
+
REDIRECT_NODE_TYPES,
|
|
13
16
|
redirectEffectForDestination,
|
|
14
17
|
redirectTargetIndex,
|
|
15
18
|
} from "./redirect-analysis";
|
|
@@ -310,15 +313,20 @@ export function extractCommandWord(node: TSNode): string | undefined {
|
|
|
310
313
|
*
|
|
311
314
|
* Reads the argument nodes directly rather than the collected tokens, because
|
|
312
315
|
* a guard fires on an *option* (`find -delete`) and no collector emits one.
|
|
316
|
+
*
|
|
317
|
+
* Every named child but the prefix and a hosted redirect is a word, including
|
|
318
|
+
* a bare `$opt` or `$(cmd)` outside {@link ARG_NODE_TYPES}: such a word
|
|
319
|
+
* reaches the program as an argument too, and a proof that never saw it could
|
|
320
|
+
* not know it might spell `-i`.
|
|
313
321
|
*/
|
|
314
|
-
function commandArgumentWords(node: TSNode):
|
|
315
|
-
const words:
|
|
322
|
+
function commandArgumentWords(node: TSNode): ArgWord[] {
|
|
323
|
+
const words: ArgWord[] = [];
|
|
316
324
|
for (let i = 0; i < node.childCount; i++) {
|
|
317
325
|
const child = node.child(i);
|
|
318
|
-
if (!child) continue;
|
|
326
|
+
if (!child?.isNamed) continue;
|
|
319
327
|
if (COMMAND_PREFIX_TYPES.has(child.type)) continue;
|
|
320
|
-
if (
|
|
321
|
-
words.push(
|
|
328
|
+
if (REDIRECT_NODE_TYPES.has(child.type)) continue;
|
|
329
|
+
words.push(readArgWord(child));
|
|
322
330
|
}
|
|
323
331
|
return words;
|
|
324
332
|
}
|
|
@@ -10,9 +10,16 @@
|
|
|
10
10
|
*/
|
|
11
11
|
|
|
12
12
|
import { proveCommandEffect } from "./command-effects";
|
|
13
|
+
import type { ArgWord } from "./node-text";
|
|
13
14
|
|
|
14
|
-
/**
|
|
15
|
-
|
|
15
|
+
/**
|
|
16
|
+
* One word of a command unit: its source text and its offset into the unit's
|
|
17
|
+
* text, beside the value the program receives once the shell removes quotes.
|
|
18
|
+
*
|
|
19
|
+
* The source text is what unwrapping and slicing read; the value is what a
|
|
20
|
+
* capability proof reads, since `'-o'` reaches `sort` as `-o`.
|
|
21
|
+
*/
|
|
22
|
+
export interface CommandWord extends ArgWord {
|
|
16
23
|
readonly text: string;
|
|
17
24
|
readonly offset: number;
|
|
18
25
|
}
|
|
@@ -172,8 +179,7 @@ export function isTransparentWrapper(
|
|
|
172
179
|
if (unwrapped.kind === "opaque" || unwrapped.layers === 0) return false;
|
|
173
180
|
|
|
174
181
|
const head = unwrapped.words.at(0)?.text ?? "";
|
|
175
|
-
|
|
176
|
-
return proveCommandEffect(head, args).effect === "read";
|
|
182
|
+
return proveCommandEffect(head, unwrapped.words.slice(1)).effect === "read";
|
|
177
183
|
}
|
|
178
184
|
|
|
179
185
|
// ── Unwrapping ───────────────────────────────────────────────────────────────
|
|
@@ -354,7 +360,7 @@ function rebase(
|
|
|
354
360
|
const origin = words[start].offset;
|
|
355
361
|
return words
|
|
356
362
|
.slice(start, end)
|
|
357
|
-
.map((word) => ({
|
|
363
|
+
.map((word) => ({ ...word, offset: word.offset - origin }));
|
|
358
364
|
}
|
|
359
365
|
|
|
360
366
|
/** True for a `NAME=value` environment prefix. */
|
|
@@ -1,23 +0,0 @@
|
|
|
1
|
-
import { BashProgram } from "#src/access-intent/bash/program";
|
|
2
|
-
import type { PathNormalizer } from "#src/path/path-normalizer";
|
|
3
|
-
|
|
4
|
-
/**
|
|
5
|
-
* Extract paths from a bash command that resolve outside CWD.
|
|
6
|
-
*
|
|
7
|
-
* Thin facade over {@link BashProgram.externalAccesses}; parses the command
|
|
8
|
-
* through the injected {@link PathNormalizer} (platform + cwd baked in) and
|
|
9
|
-
* returns the cd-aware external paths in their lexical (as-typed) string form.
|
|
10
|
-
* See `BashProgram` for the parsing and resolution semantics.
|
|
11
|
-
*
|
|
12
|
-
* Returns `string[]` (not `AccessPath[]`) so the large projection-correctness
|
|
13
|
-
* test suite in `bash-external-directory.test.ts` can assert path values
|
|
14
|
-
* without migrating to the `AccessPath` accessors.
|
|
15
|
-
*/
|
|
16
|
-
export async function extractExternalPathsFromBashCommand(
|
|
17
|
-
command: string,
|
|
18
|
-
normalizer: PathNormalizer,
|
|
19
|
-
): Promise<string[]> {
|
|
20
|
-
return (await BashProgram.parse(command, normalizer))
|
|
21
|
-
.externalAccesses()
|
|
22
|
-
.map(({ path }) => path.value());
|
|
23
|
-
}
|