@gotgenes/pi-permission-system 32.0.0 → 32.0.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
|
+
## [32.0.1](https://github.com/gotgenes/pi-packages/compare/pi-permission-system-v32.0.0...pi-permission-system-v32.0.1) (2026-09-11)
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
### Bug Fixes
|
|
12
|
+
|
|
13
|
+
* **pi-permission-system:** deny a forbidden command without prompting first ([b840196](https://github.com/gotgenes/pi-packages/commit/b8401962a9eeb012317771fc204c3fde8aa8a9a2)), closes [#899](https://github.com/gotgenes/pi-packages/issues/899)
|
|
14
|
+
|
|
15
|
+
### Documentation
|
|
16
|
+
|
|
17
|
+
* **pi-permission-system:** document that a deny on any layer needs no prompt ([e526e95](https://github.com/gotgenes/pi-packages/commit/e526e950ab6d957e6b5bc4d6f7740bcce2e35dca)), closes [#899](https://github.com/gotgenes/pi-packages/issues/899)
|
|
18
|
+
|
|
8
19
|
## [32.0.0](https://github.com/gotgenes/pi-packages/compare/pi-permission-system-v31.1.4...pi-permission-system-v32.0.0) (2026-09-11)
|
|
9
20
|
|
|
10
21
|
|
package/README.md
CHANGED
|
@@ -97,6 +97,7 @@ The trailing `*` is greedy and crosses subdirectory boundaries, so it allows eve
|
|
|
97
97
|
|
|
98
98
|
Four layers compose with most-restrictive-wins: `path` (cross-cutting) → `external_directory` (CWD boundary) → per-tool patterns → `bash` command patterns.
|
|
99
99
|
Because `ask` is more restrictive than `allow`, a `path` allow cannot loosen an `external_directory: ask` boundary — allow outside-CWD directories on `external_directory`.
|
|
100
|
+
And because `deny` is more restrictive than `ask`, a `deny` on any layer refuses the call without prompting, naming the rule that decided.
|
|
100
101
|
See [docs/configuration.md](docs/configuration.md) for the full recipe.
|
|
101
102
|
|
|
102
103
|
Both path surfaces also carry a **direction**, so you can permit reading somewhere without permitting writing there: `path_read`, `path_write`, `external_directory_read`, and `external_directory_write`.
|
package/docs/configuration.md
CHANGED
|
@@ -559,6 +559,10 @@ Use `path` to **deny** sensitive files everywhere (`.env`, `~/.ssh/*`); use `ext
|
|
|
559
559
|
Because the layers compose with most-restrictive-wins, a `path` allow cannot loosen an `external_directory: ask` boundary — `ask` is more restrictive than `allow`, so the prompt still fires.
|
|
560
560
|
Adding `"~/.cargo/registry": "allow"` to the `path` surface therefore does **not** stop the outside-CWD prompt; put the rule on `external_directory` instead (see below).
|
|
561
561
|
|
|
562
|
+
The same ordering runs the other way at the top of the scale.
|
|
563
|
+
`deny` is more restrictive than `ask`, so a `deny` on any layer refuses the call **without prompting**, whichever layer carries the rule.
|
|
564
|
+
A `bash: {"find / *": "deny"}` rule therefore suppresses the outside-CWD prompt that `find /` would otherwise raise, and the refusal names the `bash` rule that decided rather than the boundary that asked.
|
|
565
|
+
|
|
562
566
|
Configs without a `path` key behave identically to before — the gate does not fire.
|
|
563
567
|
When no `path` key is present, the universal fallback (`permission["*"]`) applies: `"*": "allow"` keeps the gate transparent, while `"*": "deny"` would deny all file access via every surface including `path`.
|
|
564
568
|
|
package/package.json
CHANGED
|
@@ -97,6 +97,81 @@ export interface GateBypass {
|
|
|
97
97
|
/** Union of possible gate function return values. */
|
|
98
98
|
export type GateResult = GateDescriptor | GateBypass | null;
|
|
99
99
|
|
|
100
|
+
// ── Resolved-state readers ─────────────────────────────────────────────────
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* The permission check a descriptor already carries, or `null` when it
|
|
104
|
+
* resolves nothing of its own.
|
|
105
|
+
*
|
|
106
|
+
* Every tool-call gate resolves its own state before the runner sees it —
|
|
107
|
+
* five of the six stamp a full `preCheck`, and the skill-read gate stamps the
|
|
108
|
+
* `preResolved` state it read off the matched skill entry. This is the one
|
|
109
|
+
* place that precedence is expressed, so the runner and the pre-emption
|
|
110
|
+
* predicate cannot answer it differently.
|
|
111
|
+
*
|
|
112
|
+
* A `null` answer is not "allow": it means the caller must resolve the
|
|
113
|
+
* descriptor itself.
|
|
114
|
+
*/
|
|
115
|
+
export function preResolvedCheckOf(
|
|
116
|
+
descriptor: GateDescriptor,
|
|
117
|
+
): PermissionCheckResult | null {
|
|
118
|
+
if (descriptor.preCheck) {
|
|
119
|
+
return descriptor.preCheck;
|
|
120
|
+
}
|
|
121
|
+
if (descriptor.preResolved) {
|
|
122
|
+
return {
|
|
123
|
+
state: descriptor.preResolved.state,
|
|
124
|
+
toolName: descriptor.surface,
|
|
125
|
+
source: "tool",
|
|
126
|
+
origin: "builtin",
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
return null;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Whether this gate blocks without escalating, whatever the other gates say.
|
|
134
|
+
*
|
|
135
|
+
* A `deny` is absorbing: wherever it sits in the pipeline's order, the call is
|
|
136
|
+
* refused, so no other gate's answer — and no human's — can change the
|
|
137
|
+
* outcome. That is what makes running it first an ordering change rather than
|
|
138
|
+
* a semantic one, and it is why the same treatment is *not* extended to `ask`
|
|
139
|
+
* (#915): two asking gates ask two different questions.
|
|
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.
|
|
148
|
+
*
|
|
149
|
+
* Yolo needs no clause: `resolveYoloGrant` matches an `allow` of origin
|
|
150
|
+
* `yolo` and an `ask`, never a `deny`.
|
|
151
|
+
*/
|
|
152
|
+
export function isUnconditionalDeny(gate: GateResult): boolean {
|
|
153
|
+
if (!isGateDescriptor(gate)) {
|
|
154
|
+
return false;
|
|
155
|
+
}
|
|
156
|
+
const check = preResolvedCheckOf(gate);
|
|
157
|
+
return check !== null && check.state === "deny" && check.source !== "session";
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* The gates in run order, with any unconditional deny moved to the front.
|
|
162
|
+
*
|
|
163
|
+
* A stable partition, so two denying gates keep their relative order (the
|
|
164
|
+
* earlier one still decides, exactly as before) and the remainder keeps its
|
|
165
|
+
* own. With no deny present the array is returned unchanged.
|
|
166
|
+
*/
|
|
167
|
+
export function orderDenyFirst(gates: GateResult[]): GateResult[] {
|
|
168
|
+
const denying = gates.filter((gate) => isUnconditionalDeny(gate));
|
|
169
|
+
if (denying.length === 0) {
|
|
170
|
+
return gates;
|
|
171
|
+
}
|
|
172
|
+
return [...denying, ...gates.filter((gate) => !isUnconditionalDeny(gate))];
|
|
173
|
+
}
|
|
174
|
+
|
|
100
175
|
// ── Type guard helpers ─────────────────────────────────────────────────────
|
|
101
176
|
|
|
102
177
|
/** Check whether a GateResult is a GateBypass (early allow). */
|
|
@@ -12,13 +12,12 @@ import {
|
|
|
12
12
|
} from "#src/presentation/agent-renderer";
|
|
13
13
|
import { renderReviewLogFacts } from "#src/presentation/review-log-renderer";
|
|
14
14
|
import type { SessionApprovalRecorder } from "#src/session/session-approval-recorder";
|
|
15
|
-
import type { PermissionCheckResult } from "#src/types";
|
|
16
15
|
import type {
|
|
17
16
|
DecisionEventFacts,
|
|
18
17
|
GateDescriptor,
|
|
19
18
|
GateResult,
|
|
20
19
|
} from "./descriptor";
|
|
21
|
-
import { isGateBypass } from "./descriptor";
|
|
20
|
+
import { isGateBypass, preResolvedCheckOf } from "./descriptor";
|
|
22
21
|
import { buildDecisionEvent, resolveYoloGrant } from "./helpers";
|
|
23
22
|
import type { GateOutcome } from "./types";
|
|
24
23
|
|
|
@@ -89,25 +88,16 @@ export class GateRunner {
|
|
|
89
88
|
agentName: string | null,
|
|
90
89
|
requestId: string,
|
|
91
90
|
): Promise<GateOutcome> {
|
|
92
|
-
// 1. Resolve permission state —
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
check = {
|
|
98
|
-
state: descriptor.preResolved.state,
|
|
99
|
-
toolName: descriptor.surface,
|
|
100
|
-
source: "tool",
|
|
101
|
-
origin: "builtin",
|
|
102
|
-
};
|
|
103
|
-
} else {
|
|
104
|
-
check = this.resolver.resolve({
|
|
91
|
+
// 1. Resolve permission state — what the descriptor already carries, or
|
|
92
|
+
// via the resolver when it carries nothing.
|
|
93
|
+
const check =
|
|
94
|
+
preResolvedCheckOf(descriptor) ??
|
|
95
|
+
this.resolver.resolve({
|
|
105
96
|
kind: "tool",
|
|
106
97
|
surface: descriptor.surface,
|
|
107
98
|
input: descriptor.input,
|
|
108
99
|
agentName: agentName ?? undefined,
|
|
109
100
|
});
|
|
110
|
-
}
|
|
111
101
|
|
|
112
102
|
// The fields every review-log write for this gate shares, whatever the
|
|
113
103
|
// resolution — built once so a field added here reaches all of them. The
|
|
@@ -18,7 +18,7 @@ import type { PermissionCheckResult } from "#src/types";
|
|
|
18
18
|
import { resolveBashCommandCheck } from "./bash-command";
|
|
19
19
|
import { describeBashExternalDirectoryGate } from "./bash-external-directory";
|
|
20
20
|
import { describeBashPathGate } from "./bash-path";
|
|
21
|
-
import type
|
|
21
|
+
import { type GateResult, orderDenyFirst } from "./descriptor";
|
|
22
22
|
import { describeExternalDirectoryGate } from "./external-directory";
|
|
23
23
|
import { describePathGate } from "./path";
|
|
24
24
|
import type { GateRunner } from "./runner";
|
|
@@ -62,7 +62,8 @@ export interface ToolCallGateInputs {
|
|
|
62
62
|
* - `ToolPreviewFormatter` construction from `getToolPreviewLimits()`
|
|
63
63
|
* - infrastructure-dir list from `getInfrastructureReadDirs()`
|
|
64
64
|
* - all six gate producers in their prescribed order
|
|
65
|
-
* - the run loop
|
|
65
|
+
* - the run loop, which runs an unconditionally denying gate ahead of the
|
|
66
|
+
* rest and returns the first block outcome, or allow
|
|
66
67
|
*/
|
|
67
68
|
export class ToolCallGatePipeline {
|
|
68
69
|
constructor(
|
|
@@ -141,8 +142,18 @@ export class ToolCallGatePipeline {
|
|
|
141
142
|
},
|
|
142
143
|
];
|
|
143
144
|
|
|
145
|
+
// Produce every gate before running any of them, so an unconditional deny
|
|
146
|
+
// on a later gate is known before an earlier one suspends the call on an
|
|
147
|
+
// `ask` nobody's answer could change (#899). Producing is side-effect-free
|
|
148
|
+
// — all logging and event emission happens inside `runner.run` — and the
|
|
149
|
+
// loop below already produced every gate on any call it did not block.
|
|
150
|
+
const gates: GateResult[] = [];
|
|
144
151
|
for (const produce of gateProducers) {
|
|
145
|
-
|
|
152
|
+
gates.push(await produce());
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
for (const gate of orderDenyFirst(gates)) {
|
|
156
|
+
const outcome = await runner.run(gate, tcc.agentName);
|
|
146
157
|
if (outcome.action === "block") {
|
|
147
158
|
return outcome;
|
|
148
159
|
}
|