@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 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
 
@@ -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
- `awk` and `sed` are excluded because their program text and `-i` flag can write; `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.
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gotgenes/pi-permission-system",
3
- "version": "35.0.2",
3
+ "version": "36.0.0",
4
4
  "description": "Permission enforcement extension for the Pi coding agent.",
5
5
  "type": "module",
6
6
  "exports": {
@@ -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 string[],
28
+ argWords: readonly ArgWord[],
26
29
  ): TokenEffect {
27
30
  if (!isBareCoreWord(headWord)) return UNPROVEN_EFFECT;
28
- const guard = RETRACTION_GUARDS.get(headWord);
29
- if (guard && argWords.some((word) => retractsClaim(word, guard))) {
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 frozen v1 pure-reader core: 21 command words that are read-only for any
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
- * | `awk`, `gawk`, `nawk` | The program text can `print > "file"` — not stable under args |
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
- * The three guarded words and what withdraws each one's claim.
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
- * All three were chosen because their write options spell identically in GNU
195
- * and BSD — which is exactly why `sed` is excluded outright rather than
196
- * guarded. `find`'s options are single-dash long words that never cluster, so
197
- * they match as exact words; `sort`'s only short option containing `o` is `-o`
198
- * itself, so the cluster rule cannot over-retract there.
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, RetractionGuard> = new Map([
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) => ({ text: node.text, offset: node.startIndex })),
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): string[] {
315
- const words: string[] = [];
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 (!ARG_NODE_TYPES.has(child.type)) continue;
321
- words.push(resolveNodeText(child));
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
- /** One word of a command unit: its text, and its offset into the unit's text. */
15
- export interface CommandWord {
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
- const args = unwrapped.words.slice(1).map((word) => word.text);
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) => ({ text: word.text, offset: word.offset - origin }));
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
- }