@gotgenes/pi-permission-system 35.0.3 → 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,24 @@ 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
+
8
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)
9
27
 
10
28
 
@@ -896,6 +896,10 @@ 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
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gotgenes/pi-permission-system",
3
- "version": "35.0.3",
3
+ "version": "36.0.0",
4
4
  "description": "Permission enforcement extension for the Pi coding agent.",
5
5
  "type": "module",
6
6
  "exports": {
@@ -16,7 +16,7 @@ import { sedWithdrawsReadClaim } from "./sed-invocation";
16
16
  * without reading the host's path language.
17
17
  *
18
18
  * A guarded word's claim is withdrawn when an argument names one of its
19
- * 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
20
20
  * may still only read, so the fail-closed base case is the honest answer and
21
21
  * `retracted` is the blame line that says why.
22
22
  *
@@ -206,17 +206,27 @@ interface RetractionGuard {
206
206
  */
207
207
  type ClaimWithdrawal = (argWords: readonly ArgWord[]) => boolean;
208
208
 
209
- /** A withdrawal decided by option spellings alone: any argument naming one. */
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
+ */
210
216
  function optionGuard(guard: RetractionGuard): ClaimWithdrawal {
211
217
  return (argWords) =>
212
- argWords.some(({ value }) => retractsClaim(value, guard));
218
+ argWords.some((word) =>
219
+ word.computed ? word.mayLeadWithDash : retractsClaim(word.value, guard),
220
+ );
213
221
  }
214
222
 
215
223
  /**
216
224
  * The guarded words and what withdraws each one's claim.
217
225
  *
218
- * `find`, `fd`, and `sort` are guarded by option spellings alone, chosen
219
- * because their write options spell identically in GNU and BSD. `sed` needs a
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
220
230
  * proof over its script too, since a `w` command writes whatever its options
221
231
  * say, and `awk` a proof over its program, since `print >` does the same — so
222
232
  * each owns a predicate of its own. `find`'s options are single-dash long
@@ -44,16 +44,143 @@ export interface ArgWord {
44
44
  * expansion, an ANSI-C string — which {@link resolveNodeText} passes through as written.
45
45
  */
46
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;
47
54
  }
48
55
 
49
56
  /** Read an argument node into the word the program receives. */
50
57
  export function readArgWord(node: TSNode): ArgWord {
58
+ const value = resolveNodeText(node);
59
+ const computed = hasComputedPart(node) || !isSpelledExactly(node);
51
60
  return {
52
- value: resolveNodeText(node),
53
- computed: hasComputedPart(node) || !isSpelledExactly(node),
61
+ value,
62
+ computed,
63
+ mayLeadWithDash: computed
64
+ ? mayExpandToDashWord(node)
65
+ : value.startsWith("-"),
54
66
  };
55
67
  }
56
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
+
57
184
  /**
58
185
  * Whether {@link resolveNodeText} returns exactly the string the shell passes.
59
186
  *
@@ -72,6 +199,9 @@ function isSpelledExactly(node: TSNode): boolean {
72
199
  case "expansion":
73
200
  return resolvePlainVariableExpansion(node) !== null;
74
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":
75
205
  return childrenSpelledExactly(node);
76
206
  case "concatenation":
77
207
  // The grammar splits `{-i,-n}` into plain words, so the expansion is