@gotgenes/pi-permission-system 40.0.0 → 40.0.2

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,20 @@ 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.0.2](https://github.com/gotgenes/pi-packages/compare/pi-permission-system-v40.0.1...pi-permission-system-v40.0.2) (2026-10-06)
9
+
10
+
11
+ ### Bug Fixes
12
+
13
+ * **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)
14
+
15
+ ## [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)
16
+
17
+
18
+ ### Bug Fixes
19
+
20
+ * **pi-permission-system:** a /dev/null redirect keeps the core-reader wrapper exemption ([25d8455](https://github.com/gotgenes/pi-packages/commit/25d84557eac5e09974ba1ffcb5b81fccf25e0f34))
21
+
8
22
  ## [40.0.0](https://github.com/gotgenes/pi-packages/compare/pi-permission-system-v39.1.2...pi-permission-system-v40.0.0) (2026-10-06)
9
23
 
10
24
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gotgenes/pi-permission-system",
3
- "version": "40.0.0",
3
+ "version": "40.0.2",
4
4
  "description": "Permission enforcement extension for the Pi coding agent.",
5
5
  "type": "module",
6
6
  "exports": {
@@ -1,4 +1,5 @@
1
1
  import { type TokenEffect, UNPROVEN_EFFECT } from "#src/access-intent/effect";
2
+ import { isDiscardDevice } from "#src/path/safe-system-paths";
2
3
  import { redirectDestinationEffect } from "./command-effects";
3
4
  import { parseUnresolvedAt } from "./parse-health";
4
5
  import type { TSNode } from "./parser";
@@ -88,9 +89,15 @@ export function redirectEffectForDestination(
88
89
  * are the operator and a descriptor, so the loop finds nothing to refuse on and
89
90
  * clears the exemption for a form nobody understood (#814).
90
91
  *
91
- * Past that, only two things clear it: a descriptor duplication (`2>&1`), which
92
- * names no file, and an operator that proves a read — reading a file alongside
93
- * a pure reader leaves it a pure reader.
92
+ * Past that, only three things clear it: a descriptor duplication (`2>&1`),
93
+ * which names no file; a destination spelled exactly `/dev/null`, whose writes
94
+ * touch no file (ADR 0013 §11 withholds the exemption only for a *real* output
95
+ * redirect, #951); and an operator that proves a read — reading a file
96
+ * alongside a pure reader leaves it a pure reader.
97
+ *
98
+ * The device check reads the node's raw text, so a quoted or computed spelling
99
+ * stays unproven, and it answers only this refusal: the token collector's proof
100
+ * still attributes the device a write, which `path_write` rules match.
94
101
  */
95
102
  export function redirectMayWriteFile(redirect: TSNode): boolean {
96
103
  if (parseUnresolvedAt(redirect)) return true;
@@ -100,6 +107,8 @@ export function redirectMayWriteFile(redirect: TSNode): boolean {
100
107
  if (!child?.isNamed) continue;
101
108
  // A source or duplicated descriptor (`2`, `&1`) names no file.
102
109
  if (DESCRIPTOR_NODE_TYPES.has(child.type)) continue;
110
+ // A write to the discard device touches no file.
111
+ if (isDiscardDevice(child.text)) continue;
103
112
  if (redirectEffectForDestination(redirect, child)?.effect !== "read") {
104
113
  return true;
105
114
  }
@@ -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",
@@ -1,9 +1,12 @@
1
+ /** The device a write discards: nothing written to it reaches any file. */
2
+ const DISCARD_DEVICE = "/dev/null";
3
+
1
4
  /**
2
5
  * Paths that are universally safe and should never trigger external-directory checks.
3
6
  * These are OS device files: read returns EOF or process streams, write discards or goes to process streams.
4
7
  */
5
8
  export const SAFE_SYSTEM_PATHS: ReadonlySet<string> = new Set([
6
- "/dev/null",
9
+ DISCARD_DEVICE,
7
10
  "/dev/stdin",
8
11
  "/dev/stdout",
9
12
  "/dev/stderr",
@@ -16,3 +19,16 @@ export const SAFE_SYSTEM_PATHS: ReadonlySet<string> = new Set([
16
19
  export function isSafeSystemPath(normalizedPath: string): boolean {
17
20
  return SAFE_SYSTEM_PATHS.has(normalizedPath);
18
21
  }
22
+
23
+ /**
24
+ * Returns true only for the discard device, `/dev/null`, so a write to `path`
25
+ * touches no file.
26
+ *
27
+ * Narrower than {@link isSafeSystemPath}: on Linux, `/dev/std{in,out,err}`
28
+ * link to `/proc/self/fd/N`, and opening one for writing reopens the
29
+ * descriptor's underlying file with `O_TRUNC` — `cat < f > /dev/stdin`
30
+ * truncates `f`.
31
+ */
32
+ export function isDiscardDevice(path: string): boolean {
33
+ return path === DISCARD_DEVICE;
34
+ }