@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 +11 -0
- package/docs/configuration.md +8 -5
- package/package.json +1 -1
- package/src/access-intent/bash/wrapper-analysis.ts +29 -13
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
|
|
package/docs/configuration.md
CHANGED
|
@@ -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
|
|
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.
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
@@ -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
|
-
*
|
|
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.
|
|
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
|
-
*
|
|
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
|
|
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
|
-
*
|
|
234
|
-
*
|
|
235
|
-
*
|
|
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;
|