@gotgenes/pi-permission-system 40.0.1 → 40.1.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
+ ## [40.1.0](https://github.com/gotgenes/pi-packages/compare/pi-permission-system-v40.0.2...pi-permission-system-v40.1.0) (2026-10-08)
9
+
10
+
11
+ ### Features
12
+
13
+ * **pi-permission-system:** sudo -n true and other shell no-ops behind a wrapper resolve by their own rule ([a0889ee](https://github.com/gotgenes/pi-packages/commit/a0889ee49c3ea217bb534339c12dc6c470020429)), closes [#1039](https://github.com/gotgenes/pi-packages/issues/1039)
14
+
15
+ ### Documentation
16
+
17
+ * **pi-permission-system:** document shell no-ops in the pure-reader core ([#1039](https://github.com/gotgenes/pi-packages/issues/1039)) ([de296e0](https://github.com/gotgenes/pi-packages/commit/de296e0306a022b19fc5a8584e7c74841a35e69f))
18
+
19
+ ## [40.0.2](https://github.com/gotgenes/pi-packages/compare/pi-permission-system-v40.0.1...pi-permission-system-v40.0.2) (2026-10-06)
20
+
21
+
22
+ ### Bug Fixes
23
+
24
+ * **pi-permission-system:** a session grant on one bash unit no longer approves the chain's other asking units ([3fffff8](https://github.com/gotgenes/pi-packages/commit/3fffff8158da81094d43a62673c8388ccb1ddf61)), closes [#1033](https://github.com/gotgenes/pi-packages/issues/1033)
25
+
8
26
  ## [40.0.1](https://github.com/gotgenes/pi-packages/compare/pi-permission-system-v40.0.0...pi-permission-system-v40.0.1) (2026-10-06)
9
27
 
10
28
 
@@ -1009,7 +1009,7 @@ A path token owned by one of them consults the `_read` surface alone:
1009
1009
 
1010
1010
  <!-- BEGIN PURE_READER_CORE -->
1011
1011
 
1012
- `awk`, `basename`, `cat`, `cd`, `diff`, `dirname`, `echo`, `egrep`, `fd`, `fgrep`, `find`, `grep`, `head`, `ls`, `pwd`, `realpath`, `rg`, `sed`, `sort`, `stat`, `tail`, `wc`, `which`
1012
+ `:`, `awk`, `basename`, `cat`, `cd`, `diff`, `dirname`, `echo`, `egrep`, `false`, `fd`, `fgrep`, `find`, `grep`, `head`, `ls`, `pwd`, `realpath`, `rg`, `sed`, `sort`, `stat`, `tail`, `true`, `wc`, `which`
1013
1013
 
1014
1014
  <!-- END PURE_READER_CORE -->
1015
1015
 
@@ -1079,7 +1079,7 @@ All four of these must hold, and each is a way the floor's reason could still ap
1079
1079
  4. The enclosing statement provably writes no file through a redirect.
1080
1080
  A destination the parse cannot resolve — `> $OUT`, `> $(mktemp)` — counts against the exemption rather than for it.
1081
1081
 
1082
- 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`, and `find . -exec sh -c '…' \;` still prompt.
1082
+ 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.
1083
1083
 
1084
1084
  Three things this does **not** change:
1085
1085
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gotgenes/pi-permission-system",
3
- "version": "40.0.1",
3
+ "version": "40.1.0",
4
4
  "description": "Permission enforcement extension for the Pi coding agent.",
5
5
  "type": "module",
6
6
  "exports": {
@@ -155,6 +155,11 @@ function coreAdmissions(): readonly CoreAdmission[] {
155
155
  reason:
156
156
  "No filesystem write: `echo` writes to stdout (a redirect destination is the syntax proof's job, not `echo`'s); `cd` reads a directory to enter it",
157
157
  },
158
+ {
159
+ words: ["true", "false", ":"],
160
+ reason:
161
+ "No-ops: ignore their operands and touch no file; GNU's `--help`/`--version` print to stdout only",
162
+ },
158
163
  {
159
164
  words: ["find", "fd", "sort"],
160
165
  reason:
@@ -123,9 +123,8 @@ export function resolveBashCommandCheck(
123
123
  *
124
124
  * The winner names one command, but a forwarded ask's answer approves the
125
125
  * whole tool call, so the serving node has to judge every unit the child could
126
- * not resolve, each with its own floor (#1030). A unit the child's rules
127
- * allowed stays home, and so does one the session already granted: the user
128
- * approved exactly that command.
126
+ * not resolve, each with its own floor (#1030). A unit the child's rules or
127
+ * the session allowed stays home.
129
128
  */
130
129
  function withAskingUnits(
131
130
  winner: PermissionCheckResult,
@@ -133,7 +132,7 @@ function withAskingUnits(
133
132
  ): PermissionCheckResult {
134
133
  if (winner.state !== "ask") return winner;
135
134
  const askingUnits = results
136
- .filter((result) => result.state === "ask" && result.source !== "session")
135
+ .filter((result) => result.state === "ask")
137
136
  .map((result) => ({
138
137
  command: result.command ?? "",
139
138
  ...(result.floor === undefined ? {} : { floor: result.floor }),
@@ -149,17 +148,14 @@ function withAskingUnits(
149
148
  * `git push x && sudo rm y` a `git push *: ask` rule wins the tie and the
150
149
  * wrapper's floor would otherwise go unrecorded. A forwarded ask is judged on
151
150
  * the winner's value alone, so the floor has to ride the result for the
152
- * serving node to keep it (#1029). A unit the session already granted is left
153
- * out: the user approved exactly that command.
151
+ * serving node to keep it (#1029).
154
152
  */
155
153
  function withChainFloor(
156
154
  winner: PermissionCheckResult,
157
155
  results: readonly PermissionCheckResult[],
158
156
  ): PermissionCheckResult {
159
157
  if (winner.state !== "ask" || winner.floor !== undefined) return winner;
160
- const floored = results.find(
161
- (result) => result.floor !== undefined && result.source !== "session",
162
- );
158
+ const floored = results.find((result) => result.floor !== undefined);
163
159
  return floored ? { ...winner, floor: floored.floor } : winner;
164
160
  }
165
161
 
@@ -181,7 +177,7 @@ function resolveCommandUnit(
181
177
  resolver,
182
178
  );
183
179
  const floored =
184
- cmd.wrapperKind && base.state === "allow"
180
+ cmd.wrapperKind && base.state === "allow" && !isSessionGrant(base)
185
181
  ? resolveWrapperUnit(cmd, cmd.wrapperKind, base, agentName, resolver)
186
182
  : base;
187
183
  const unparsed = floorUnparsedUnit(cmd, command, floored);
@@ -210,26 +206,40 @@ function resolveCommandUnit(
210
206
  * decides instead — and a wrapper unit already floored to `ask` keeps its own,
211
207
  * more specific sentinel.
212
208
  *
213
- * The result is built by spreading `resolved`, so a `source: "session"` grant
214
- * survives to `GateRunner`'s session fast path, which tests the source before
215
- * the state. A grant the user gave for this exact command still holds.
209
+ * A session grant is never floored: the user approved this exact command, and
210
+ * an `ask` carrying it would tie with a sibling unit's real ask and could
211
+ * approve the whole chain through `GateRunner`'s session fast path (#1033).
216
212
  */
217
213
  function floorUnparsedUnit(
218
214
  cmd: BashCommand,
219
215
  command: string,
220
216
  resolved: PermissionCheckResult,
221
217
  ): PermissionCheckResult {
222
- if (!cmd.parseUnresolved || resolved.state !== "allow") return resolved;
218
+ if (
219
+ !cmd.parseUnresolved ||
220
+ resolved.state !== "allow" ||
221
+ isSessionGrant(resolved)
222
+ ) {
223
+ return resolved;
224
+ }
223
225
  return { ...floorToAsk(resolved, UNPARSED_SUBTREE_SENTINEL), command };
224
226
  }
225
227
 
228
+ /**
229
+ * True when the session layer decided the check. `SessionRules` records only
230
+ * `allow`s, so this is a grant the user gave for the command it matched.
231
+ */
232
+ function isSessionGrant(check: PermissionCheckResult): boolean {
233
+ return check.source === "session";
234
+ }
235
+
226
236
  /**
227
237
  * Clamp a resolved check up to a synthetic `ask` naming the floor that raised
228
238
  * it.
229
239
  *
230
- * Spreads `resolved`, so a `source: "session"` grant survives to `GateRunner`'s
231
- * session fast path. Drops `matchedSpelling`: it names what the replaced rule
232
- * matched, and beside the sentinel it would name a match that did not decide.
240
+ * Never handed a session grant: both floors leave one unfloored. Drops
241
+ * `matchedSpelling`: it names what the replaced rule matched, and beside the
242
+ * sentinel it would name a match that did not decide.
233
243
  */
234
244
  function floorToAsk(
235
245
  resolved: PermissionCheckResult,
@@ -138,13 +138,10 @@ export function preResolvedCheckOf(
138
138
  * a semantic one, and it is why the same treatment is *not* extended to `ask`
139
139
  * (#915): two asking gates ask two different questions.
140
140
  *
141
- * Subordinate to {@link GateRunner.runDescriptor}'s own precedence, which
142
- * tests `source === "session"` before the deny/ask/allow gate is reached — a
143
- * session-sourced check is allowed there, so it is not pre-emptive here.
144
- * `SessionRules` records only allows, so that combination is unreachable
145
- * today; the clause is kept so the predicate is correct on its own terms
146
- * rather than by way of a distant invariant, and it errs toward today's
147
- * behavior by declining to pre-empt.
141
+ * A session-sourced `deny` counts too: {@link GateRunner.runDescriptor}'s
142
+ * session fast path takes only a session `allow`, so any other session-sourced
143
+ * check reaches the deny/ask/allow gate and a `deny` blocks there. `SessionRules`
144
+ * records only allows, so that combination is unreachable today.
148
145
  *
149
146
  * Yolo needs no clause: `resolveYoloGrant` matches an `allow` of origin
150
147
  * `yolo` and an `ask`, never a `deny`.
@@ -154,7 +151,7 @@ export function isUnconditionalDeny(gate: GateResult): boolean {
154
151
  return false;
155
152
  }
156
153
  const check = preResolvedCheckOf(gate);
157
- return check !== null && check.state === "deny" && check.source !== "session";
154
+ return check !== null && check.state === "deny";
158
155
  }
159
156
 
160
157
  /**
@@ -116,8 +116,10 @@ export class GateRunner {
116
116
  // context holds what every resolution of this gate shares, and who decided
117
117
  // is by definition not shared (#726).
118
118
 
119
- // 2. Session-hit fast path
120
- if (check.source === "session") {
119
+ // 2. Session-hit fast path: a session grant is an allow. `SessionRules`
120
+ // records nothing else, so a session-sourced ask or deny means something
121
+ // clamped a grant, and it takes the gate below instead of riding the grant.
122
+ if (check.source === "session" && check.state === "allow") {
121
123
  this.reporter.writeReviewLog("permission_request.session_approved", {
122
124
  ...logContext,
123
125
  resolution: "session_approved",