@gotgenes/pi-permission-system 28.0.0 → 28.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,15 @@ 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
+ ## [28.0.1](https://github.com/gotgenes/pi-packages/compare/pi-permission-system-v28.0.0...pi-permission-system-v28.0.1) (2026-08-30)
9
+
10
+
11
+ ### Bug Fixes
12
+
13
+ * **pi-permission-system:** carry a serving session's deny reason to the requesting agent ([b2010d7](https://github.com/gotgenes/pi-packages/commit/b2010d7bfbf766592b4ae0b98f16a612f96ff706)), closes [#844](https://github.com/gotgenes/pi-packages/issues/844)
14
+ * **pi-permission-system:** name the rule that refused a forwarded call instead of blaming the user ([3d83641](https://github.com/gotgenes/pi-packages/commit/3d83641ad9bf6b045a86a4fb2879a7761c0aefc3)), closes [#844](https://github.com/gotgenes/pi-packages/issues/844)
15
+ * **pi-permission-system:** tell the agent when the permission authority failed to answer ([8f70380](https://github.com/gotgenes/pi-packages/commit/8f70380005733546fa2df1d8694c50985332de19)), closes [#844](https://github.com/gotgenes/pi-packages/issues/844)
16
+
8
17
  ## [28.0.0](https://github.com/gotgenes/pi-packages/compare/pi-permission-system-v27.1.3...pi-permission-system-v28.0.0) (2026-08-30)
9
18
 
10
19
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gotgenes/pi-permission-system",
3
- "version": "28.0.0",
3
+ "version": "28.0.1",
4
4
  "description": "Permission enforcement extension for the Pi coding agent.",
5
5
  "type": "module",
6
6
  "exports": {
@@ -5,7 +5,10 @@ import {
5
5
  type ForwarderContext,
6
6
  getSessionId,
7
7
  } from "#src/authority/forwarder-context";
8
- import type { PermissionPromptDecision } from "#src/authority/permission-dialog";
8
+ import {
9
+ createDeniedPermissionDecision,
10
+ type PermissionPromptDecision,
11
+ } from "#src/authority/permission-dialog";
9
12
  import {
10
13
  type ForwardedAccessFacts,
11
14
  type ForwardedAccessIntent,
@@ -456,9 +459,12 @@ export class ForwardedRequestServer implements InboxProcessor {
456
459
  : "forwarded_permission.auto_denied",
457
460
  { ...logDetails, decidedBy },
458
461
  );
462
+ // A deny-with-reason rule's text is the operator's own explanation, and
463
+ // the requesting session relays it to its agent — so it travels with the
464
+ // verdict rather than stopping at the node that holds the config (#844).
459
465
  return approved
460
466
  ? { approved: true, state: "approved", decidedBy }
461
- : { approved: false, state: "denied", decidedBy };
467
+ : { ...createDeniedPermissionDecision(check.reason), decidedBy };
462
468
  }
463
469
 
464
470
  this.logger.review("forwarded_permission.prompted", logDetails);
@@ -14,6 +14,7 @@ import {
14
14
  findEvidence,
15
15
  type PromptPayload,
16
16
  } from "#src/presentation/prompt-payload";
17
+ import type { BashCommandContext } from "#src/types";
17
18
 
18
19
  /**
19
20
  * The agent-facing render of a refused permission ask (ADR 0011 §7).
@@ -68,6 +69,9 @@ export function renderRefusal(
68
69
  budget: AgentRenderBudget = DEFAULT_RENDER_BUDGET,
69
70
  ): string {
70
71
  const decider = effectiveDecider(decidedBy);
72
+ // The hop the unwrap discarded: *where* the decision was made. Kept as a
73
+ // bare fact rather than the responder's identity, which stays undisclosed.
74
+ const decidedElsewhere = decidedBy.kind === "forwarded";
71
75
  switch (decider.kind) {
72
76
  case "authorizer":
73
77
  return renderAuthorizerDenial(
@@ -78,14 +82,26 @@ export function renderRefusal(
78
82
  );
79
83
  case "unavailable":
80
84
  return renderUnavailableDenial(payload, denialReason, budget);
81
- // A `rule` here is a rule in the *serving* session, whose pattern and
82
- // origin are not on this payload — `matchedPattern` is the pattern that
83
- // raised this session's own ask, so naming a rule would name the wrong
84
- // one. That render, and the `gate_error` one beside it, are #844. The
85
- // remaining kinds never refuse: they only ever allow.
86
- case "user":
87
85
  case "rule":
86
+ return renderEscalatedPolicyDenial(
87
+ payload,
88
+ { pattern: decider.pattern, decidedElsewhere },
89
+ denialReason,
90
+ budget,
91
+ );
88
92
  case "gate_error":
93
+ return renderGateErrorDenial(payload, {
94
+ reason: decider.reason,
95
+ decidedElsewhere,
96
+ });
97
+ // Three different reasons share this arm. `user` is the render's true
98
+ // subject: a human refused, and the sentence says so. The three below it
99
+ // never refuse at all — a session grant, an infrastructure read, and yolo
100
+ // only ever allow — so they are unreachable here and listed only to keep
101
+ // the switch exhaustive. A `forwarded` reaching this far named no inner
102
+ // decider (an older responder), so the hop is all that is known, and the
103
+ // user render is the fail-soft answer rather than the accurate one.
104
+ case "user":
89
105
  case "session_approval":
90
106
  case "infrastructure_read":
91
107
  case "yolo":
@@ -101,7 +117,7 @@ export function renderPolicyDenial(
101
117
  budget: AgentRenderBudget = DEFAULT_RENDER_BUDGET,
102
118
  ): string {
103
119
  return tagged(
104
- `Denied by policy: ${identification(payload, budget, "")}${boundaryClause(payload)}${provenanceClause(payload)}.`,
120
+ `Denied by policy: ${identification(payload, budget, "", askRuleClause(payload))}${boundaryClause(payload)}${provenanceClause(payload)}.`,
105
121
  ruleReason,
106
122
  );
107
123
  }
@@ -113,7 +129,7 @@ export function renderUserDenial(
113
129
  budget: AgentRenderBudget = DEFAULT_RENDER_BUDGET,
114
130
  ): string {
115
131
  return tagged(
116
- `The user denied this ${identification(payload, budget, "call")}${boundaryClause(payload)}${provenanceClause(payload)}.`,
132
+ `The user denied this ${identification(payload, budget, "call", askRuleClause(payload))}${boundaryClause(payload)}${provenanceClause(payload)}.`,
117
133
  denialReason,
118
134
  );
119
135
  }
@@ -133,11 +149,76 @@ export function renderAuthorizerDenial(
133
149
  budget: AgentRenderBudget = DEFAULT_RENDER_BUDGET,
134
150
  ): string {
135
151
  return tagged(
136
- `The '${linkName}' authorizer denied this ${identification(payload, budget, "call")}${boundaryClause(payload)}${provenanceClause(payload)}.`,
152
+ `The '${linkName}' authorizer denied this ${identification(payload, budget, "call", askRuleClause(payload))}${boundaryClause(payload)}${provenanceClause(payload)}.`,
153
+ denialReason,
154
+ );
155
+ }
156
+
157
+ /** The rule that refused an escalated ask, and where it sat. */
158
+ export interface EscalatedRule {
159
+ /** The pattern the deciding node's rule matched; `null` when none was recorded. */
160
+ readonly pattern: string | null;
161
+ /** Whether that node was reached through a forwarding hop. */
162
+ readonly decidedElsewhere: boolean;
163
+ }
164
+
165
+ /**
166
+ * The agent-facing render of a policy denial an escalation came back with.
167
+ *
168
+ * Distinct from {@link renderPolicyDenial} in which rule it names. That one
169
+ * renders the rule on this session's payload, which is the rule that decided
170
+ * when recorded authority answered locally. Here the ask was escalated, so the
171
+ * payload's rule is the one that raised the *ask* and the rule that *denied*
172
+ * lives on the decision — naming the payload's would name the wrong rule while
173
+ * looking correct (ADR 0011 §10, #844).
174
+ */
175
+ export function renderEscalatedPolicyDenial(
176
+ payload: PromptPayload,
177
+ rule: EscalatedRule,
178
+ denialReason: string | null,
179
+ budget: AgentRenderBudget = DEFAULT_RENDER_BUDGET,
180
+ ): string {
181
+ const decidedRule = ruleClause(rule.pattern, payload.request.commandContext);
182
+ return tagged(
183
+ `A policy rule${servingClause(rule.decidedElsewhere)} denied this ${identification(payload, budget, "call", decidedRule)}${boundaryClause(payload)}${provenanceClause(payload)}.`,
137
184
  denialReason,
138
185
  );
139
186
  }
140
187
 
188
+ /** The escalation failure that blocked an ask fail-closed, and where it happened. */
189
+ export interface GateFailure {
190
+ /** The error text stamped on the decision, which carries the detail here. */
191
+ readonly reason: string;
192
+ /** Whether the failing authority was reached through a forwarding hop. */
193
+ readonly decidedElsewhere: boolean;
194
+ }
195
+
196
+ /**
197
+ * The agent-facing render of an escalation that threw rather than ruling.
198
+ *
199
+ * Nobody denied this call — the authority that would have answered broke, and
200
+ * the boundary blocked rather than allowed. Saying so is what distinguishes it
201
+ * from a verdict the agent should respect: a failure is worth surfacing to the
202
+ * operator, where a denial is worth working around (#844).
203
+ *
204
+ * The reason comes from the decision's own stamp rather than a denial reason,
205
+ * because that is the field the failing site writes and the field the review
206
+ * log records — a second source would be a second story about one failure.
207
+ *
208
+ * Like {@link renderUnavailableDenial}, it omits the escaped boundary: the
209
+ * ask never reached a rule, so no retry shape would change the outcome.
210
+ */
211
+ export function renderGateErrorDenial(
212
+ payload: PromptPayload,
213
+ failure: GateFailure,
214
+ budget: AgentRenderBudget = DEFAULT_RENDER_BUDGET,
215
+ ): string {
216
+ return tagged(
217
+ `The permission authority${servingClause(failure.decidedElsewhere)} failed to answer this ${identification(payload, budget, "call", askRuleClause(payload))}, so it was blocked (fail-closed).`,
218
+ failure.reason,
219
+ );
220
+ }
221
+
141
222
  /** The agent-facing render when no live authority could answer the ask. */
142
223
  export function renderUnavailableDenial(
143
224
  payload: PromptPayload,
@@ -145,13 +226,24 @@ export function renderUnavailableDenial(
145
226
  budget: AgentRenderBudget = DEFAULT_RENDER_BUDGET,
146
227
  ): string {
147
228
  return tagged(
148
- `This ${identification(payload, budget, "call")} requires approval, but no interactive UI is available.`,
229
+ `This ${identification(payload, budget, "call", askRuleClause(payload))} requires approval, but no interactive UI is available.`,
149
230
  denialReason,
150
231
  );
151
232
  }
152
233
 
153
234
  // ── Sentence assembly ──────────────────────────────────────────────────────
154
235
 
236
+ /**
237
+ * Where the deciding authority sat, when it was not this session.
238
+ *
239
+ * Says that another session decided and never which one: the responder's
240
+ * identity answers a question the requesting agent cannot act on, and §6 keeps
241
+ * it off the render (ADR 0011 §10).
242
+ */
243
+ function servingClause(decidedElsewhere: boolean): string {
244
+ return decidedElsewhere ? " in the session serving this request" : "";
245
+ }
246
+
155
247
  function tagged(sentence: string, reason: string | null): string {
156
248
  return `${EXTENSION_TAG} ${sentence}${reasonClause(reason)}`;
157
249
  }
@@ -164,11 +256,16 @@ function tagged(sentence: string, reason: string | null): string {
164
256
  * `callWord` is the noun the verdict needs after the surface — a user or
165
257
  * unavailable verdict refuses a *call*, while a policy deny refuses the
166
258
  * surface itself.
259
+ *
260
+ * `ruleText` is supplied rather than read off the payload, because an escalated
261
+ * refusal names the rule that *decided* it, which is not always this session's
262
+ * own (#844).
167
263
  */
168
264
  function identification(
169
265
  payload: PromptPayload,
170
266
  budget: AgentRenderBudget,
171
267
  callWord: string,
268
+ ruleText: string,
172
269
  ): string {
173
270
  return [
174
271
  `'${payload.request.surface}'`,
@@ -177,7 +274,7 @@ function identification(
177
274
  toolClause(payload),
178
275
  agentClause(payload),
179
276
  flaggedClause(payload, budget),
180
- ruleClause(payload),
277
+ ruleText,
181
278
  ]
182
279
  .filter((clause) => clause !== "")
183
280
  .join(" ");
@@ -244,12 +341,29 @@ function resolvedAlias(payload: PromptPayload, element: string): string {
244
341
  return resolved ? ` (resolves to '${resolved}')` : "";
245
342
  }
246
343
 
247
- /** The rule that fired, with the nested context that makes it intelligible. */
248
- function ruleClause(payload: PromptPayload): string {
249
- const { matchedPattern, commandContext } = payload.request;
344
+ /** The rule that raised this session's own ask. */
345
+ function askRuleClause(payload: PromptPayload): string {
346
+ return ruleClause(
347
+ payload.request.matchedPattern,
348
+ payload.request.commandContext,
349
+ );
350
+ }
351
+
352
+ /**
353
+ * The rule that fired, with the nested context that makes it intelligible.
354
+ *
355
+ * The pattern is a parameter rather than a payload read: an escalated refusal
356
+ * renders the rule that decided it, which for a forwarded ask lives on the
357
+ * response's decider and never on this session's payload. The command context
358
+ * is a fact about the call either way, so it always comes from the payload.
359
+ */
360
+ function ruleClause(
361
+ pattern: string | null,
362
+ commandContext: BashCommandContext | null,
363
+ ): string {
250
364
  const parts: string[] = [];
251
- if (matchedPattern !== null) {
252
- parts.push(`rule '${matchedPattern}'`);
365
+ if (pattern !== null) {
366
+ parts.push(`rule '${pattern}'`);
253
367
  }
254
368
  const context = describeBashCommandContext(commandContext);
255
369
  if (context !== undefined) {