@gotgenes/pi-permission-system 40.2.0 → 40.2.1

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,17 @@ All notable changes to this project will be documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [40.2.1](https://github.com/gotgenes/pi-packages/compare/pi-permission-system-v40.2.0...pi-permission-system-v40.2.1) (2026-10-14)
9
+
10
+
11
+ ### Bug Fixes
12
+
13
+ * **pi-permission-system:** a computed word in a wrapper's options keeps the indirection floor ([754a5fa](https://github.com/gotgenes/pi-packages/commit/754a5fadcab0aeac765ecb8cf423b1fb55e8d4d5))
14
+
15
+ ### Documentation
16
+
17
+ * **pi-permission-system:** one literal-word rule for both wrapper floor exemptions ([334318a](https://github.com/gotgenes/pi-packages/commit/334318a0a918557e92741d442cba7728b66cc92e))
18
+
8
19
  ## [40.2.0](https://github.com/gotgenes/pi-packages/compare/pi-permission-system-v40.1.4...pi-permission-system-v40.2.0) (2026-10-12)
9
20
 
10
21
 
@@ -1094,15 +1094,18 @@ The floor guards unknowability of *scope*, and scope stays the path surfaces' jo
1094
1094
  Such a unit is therefore **not** floored.
1095
1095
  It resolves by the inner command's own `bash` rules instead, and the review log records `floorExemption: "core-reader"` beside the rule that decided, so an allow the floor would once have prompted for is auditable to the reason that let it through.
1096
1096
 
1097
- All four of these must hold, and each is a way the floor's reason could still apply:
1097
+ All five of these must hold, and each is a way the floor's reason could still apply:
1098
1098
 
1099
1099
  1. The unit is an indirection wrapper.
1100
1100
  An `sh -c`/`eval` payload is not one — see below.
1101
1101
  2. The command it runs can be established without passing through an inline shell.
1102
1102
  `watch` and `parallel` hand their whole command line to a shell, and `rush` and `rust-parallel` build theirs from a template, so a unit with any of the four as a layer never qualifies: `watch cat x \; rm y` runs `rm`.
1103
- 3. That command **proves** a read: a bare-basename core word with no option that withdraws the claim.
1103
+ 3. Every word the wrapper itself takes (an option, its value, an operand, a `NAME=value` word) is written literally.
1104
+ The shell splits or expands a computed word before the wrapper runs, so `sudo -u $U cat x` with `U="root rm y"` runs `rm y cat x`; quoting does not exempt it, since `"$@"` can vanish and a quoted variable can name an array.
1105
+ A computed word of the command it runs is fine: `sudo -u root cat $X` still qualifies.
1106
+ 4. That command **proves** a read: a bare-basename core word with no option that withdraws the claim.
1104
1107
  So `xargs sort -o /tmp/x` and `xargs find . -delete` stay floored, and so does `xargs ./grep foo`.
1105
- 4. The enclosing statement provably writes no file through a redirect.
1108
+ 5. The enclosing statement provably writes no file through a redirect.
1106
1109
  A destination the parse cannot resolve — `> $OUT`, `> $(mktemp)` — counts against the exemption rather than for it.
1107
1110
 
1108
1111
  So `xargs grep -l foo`, `xargs wc -l`, `xargs sed -n 1p`, `find . -name '*.ts' -exec cat {} +`, and the no-op probe `sudo -n true` stop prompting under a matching `bash` allow, while `xargs rm`, `xargs sed -i`, and `find . -exec sh -c '…' \;` still prompt.
@@ -1115,7 +1118,7 @@ Three things this does **not** change:
1115
1118
  Under `bash: {"*": "allow", "grep *": "deny"}`, `xargs grep foo` is denied rather than merely prompted.
1116
1119
  - Path gating is untouched.
1117
1120
  The exemption decides the `bash` surface only, and every path token the command projects still goes through `path` and `external_directory` with the direction its command proved.
1118
- Clause 4 is what keeps that from being a weaker promise than it sounds: a redirect destination the parse cannot resolve (`> $OUT`, `> $(mktemp)`) is not projected onto those surfaces either, so the wrapper keeps its floor rather than relying on a gate that would not see the write.
1121
+ Clause 5 is what keeps that from being a weaker promise than it sounds: a redirect destination the parse cannot resolve (`> $OUT`, `> $(mktemp)`) is not projected onto those surfaces either, so the wrapper keeps its floor rather than relying on a gate that would not see the write.
1119
1122
 
1120
1123
  ##### A wrapper that only modifies execution
1121
1124
 
@@ -1127,7 +1130,7 @@ All four of these must hold, and each keeps the decision about the command that
1127
1130
 
1128
1131
  1. **Every** wrapper layer is one of the five.
1129
1132
  `time sudo rm -rf x` and `sudo time pnpm test` stay floored, as do `nohup` (it may write `nohup.out`) and `flock` (it creates its lock file).
1130
- 2. Every option on each layer is one that wrapper is known to take, spelled in full, and every option, value, and operand is written literally (an environment assignment, which the shell does not split, may be computed).
1133
+ 2. Every option on each layer is one that wrapper is known to take, spelled in full, and every option, value, operand, and `NAME=value` word is written literally.
1131
1134
  A word the shell rewrites (`timeout $D …`, `timeout {5,sudo} …`) may become several, one of them a wrapper, so it keeps the floor.
1132
1135
  So `timeout --sig KILL 5 …` (an abbreviation) and `nice -5 …` stay floored, and so do `time`'s file-writing `-o`, `--output`, and `-a`.
1133
1136
  The admitted options are `time -p`/`-l`/`-h`/`-f`; `timeout -s`/`-k`/`-f`/`-p`/`-v` and their long forms; `nice -n`/`--adjustment`; and `stdbuf -i`/`-o`/`-e` and their long forms.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gotgenes/pi-permission-system",
3
- "version": "40.2.0",
3
+ "version": "40.2.1",
4
4
  "description": "Permission enforcement extension for the Pi coding agent.",
5
5
  "type": "module",
6
6
  "exports": {
@@ -158,7 +158,7 @@ export function executedUnitOf(
158
158
  * alike. Argument-independence is the core's admission bar, so there are no
159
159
  * arguments that make `grep` write a file.
160
160
  *
161
- * Four things must hold for it, and each is a way the reason could still hold:
161
+ * Five things must hold for it, and each is a way the reason could still hold:
162
162
  *
163
163
  * 1. The unit is an indirection wrapper — an ordinary command has no floor.
164
164
  * 2. Unwrapping reached the inner command without passing through an opaque
@@ -168,16 +168,21 @@ export function executedUnitOf(
168
168
  * line to a shell or builds it from a template
169
169
  * ({@link OPAQUE_COMMAND_LINE_WRAPPERS}) is that payload in another shape:
170
170
  * `watch cat x \; rm y` runs `rm`, so no layer may be one.
171
- * 3. The inner command *proves* a read — bare-basename core word, retraction
171
+ * 3. Every word a peeled layer passes its wrapper is literal: each option,
172
+ * value, operand, and `NAME=value` word. The shell splits or expands a
173
+ * computed one before the wrapper runs, so one of the words it becomes may
174
+ * be the command that really runs — `sudo -u $U cat x` with
175
+ * `U="root rm y"` runs `rm y cat x` as root.
176
+ * 4. The inner command *proves* a read — bare-basename core word, retraction
172
177
  * guards applied, so `xargs sort -o /tmp/x` and `xargs find . -delete` are
173
178
  * not transparent.
174
- * 4. The unit writes no file through a redirect, which the caller reads off
179
+ * 5. The unit writes no file through a redirect, which the caller reads off
175
180
  * the parse tree and this module never sees.
176
181
  *
177
182
  * `"execution-modifier"`: every wrapper layer only changes *how* the visible
178
183
  * inner command runs, so the unit resolves by that command's own rule whatever
179
- * it does (see {@link onlyModifiesExecution}). The first two conditions above
180
- * hold for it too; the core-reader reason is preferred when both apply.
184
+ * it does (see {@link onlyModifiesExecution}). The first three conditions
185
+ * above hold for it too; the core-reader reason is preferred when both apply.
181
186
  */
182
187
  export function floorExemptionOf(
183
188
  words: readonly CommandWord[],
@@ -191,7 +196,8 @@ export function floorExemptionOf(
191
196
  if (
192
197
  unwrapped.kind === "opaque" ||
193
198
  unwrapped.peeled.length === 0 ||
194
- unwrapped.peeled.some(runsOpaqueCommandLine)
199
+ unwrapped.peeled.some(runsOpaqueCommandLine) ||
200
+ unwrapped.peeled.some(carriesComputedWord)
195
201
  ) {
196
202
  return undefined;
197
203
  }
@@ -211,6 +217,17 @@ function runsOpaqueCommandLine(layer: readonly CommandWord[]): boolean {
211
217
  return name !== undefined && OPAQUE_COMMAND_LINE_WRAPPERS.has(name);
212
218
  }
213
219
 
220
+ /**
221
+ * True when the shell may rewrite a word a peeled layer passes its wrapper.
222
+ *
223
+ * The wrapper name is skipped: it is matched by its text against a fixed
224
+ * vocabulary, so a computed spelling never classifies as a wrapper, and the
225
+ * word adapter reads a `command_name` node as computed whatever it spells.
226
+ */
227
+ function carriesComputedWord(layer: readonly CommandWord[]): boolean {
228
+ return layer.slice(1).some((word) => word.computed);
229
+ }
230
+
214
231
  /**
215
232
  * True when every peeled layer only changes *how* the inner command runs, and
216
233
  * that command is a literal name the gate can resolve by its own rule.
@@ -230,9 +247,9 @@ function runsOpaqueCommandLine(layer: readonly CommandWord[]): boolean {
230
247
  * 2. Every option in every layer is on that modifier's allowlist. The real
231
248
  * tools accept abbreviations (`timeout --sig KILL 5 rm`) the inner-command
232
249
  * search does not know, and one of them misplaces where the command starts.
233
- * Each option, value, and operand must also be literal: the shell splits
234
- * `timeout $D pnpm` or expands `timeout {5,sudo} rm` into extra words before
235
- * the modifier runs, and one of those may be a wrapper or the real command.
250
+ * That every word is also literal is {@link floorExemptionOf}'s condition,
251
+ * shared with the core-reader reason: the shell splits `timeout $D pnpm` or
252
+ * expands `timeout {5,sudo} rm` into extra words before the modifier runs.
236
253
  * 3. The peel ended at an ordinary command, not a wrapper it could not see
237
254
  * past.
238
255
  * 4. The inner head is a literal command name. The grammar has no `time`
@@ -267,6 +284,9 @@ function reachedLiteralCommand(inner: readonly CommandWord[]): boolean {
267
284
  *
268
285
  * Walks the words with the same value-taking table {@link innerCommandIndex}
269
286
  * skips by, so an admitted option's value can never be taken for the command.
287
+ * It reads option shapes only: each caller refuses a computed word before it
288
+ * gets here ({@link floorExemptionOf}'s literal-layer condition, and
289
+ * {@link sessionCommandIndex}'s refusal of any token with shell syntax).
270
290
  */
271
291
  function isAdmittedModifierLayer(layer: readonly CommandWord[]): boolean {
272
292
  const name = wrapperName(layer);
@@ -278,15 +298,11 @@ function isAdmittedModifierLayer(layer: readonly CommandWord[]): boolean {
278
298
  for (let index = 1; index < layer.length; index++) {
279
299
  const word = layer[index].text;
280
300
  if (isEnvironmentAssignment(word)) continue;
281
- // A word the shell rewrites (`$D`, `{5,sudo}`, `*`) may become several, one
282
- // of them a wrapper or the real command, so only a literal word is admitted.
283
- if (layer[index].computed) return false;
284
301
  if (word === "--") continue;
285
302
  if (!word.startsWith("-")) continue;
286
303
  if (flags.has(word)) continue;
287
304
  if (valueTaking.has(word)) {
288
305
  index++;
289
- if (layer[index]?.computed) return false;
290
306
  continue;
291
307
  }
292
308
  if (!hasAttachedValue(word, valueTaking)) return false;