@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,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
|
|
92
|
-
* names no file
|
|
93
|
-
*
|
|
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
|
-
*
|
|
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"
|
|
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).
|
|
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
|
-
*
|
|
214
|
-
*
|
|
215
|
-
*
|
|
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 (
|
|
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
|
-
*
|
|
231
|
-
*
|
|
232
|
-
*
|
|
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
|
-
*
|
|
142
|
-
*
|
|
143
|
-
*
|
|
144
|
-
*
|
|
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"
|
|
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
|
-
|
|
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
|
-
|
|
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
|
+
}
|