@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`.
@@ -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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gotgenes/pi-permission-system",
3
- "version": "32.0.0",
3
+ "version": "32.0.1",
4
4
  "description": "Permission enforcement extension for the Pi coding agent.",
5
5
  "type": "module",
6
6
  "exports": {
@@ -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 — pre-check, pre-resolved, or via resolver
93
- let check: PermissionCheckResult;
94
- if (descriptor.preCheck) {
95
- check = descriptor.preCheck;
96
- } else if (descriptor.preResolved) {
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 { GateResult } from "./descriptor";
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 that returns the first block outcome, or allow
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
- const outcome = await runner.run(await produce(), tcc.agentName);
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
  }