@gotgenes/pi-permission-system 27.1.3 → 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,36 @@ 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
+
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)
18
+
19
+
20
+ ### ⚠ BREAKING CHANGES
21
+
22
+ * **pi-permission-system:** `PermissionDecisionResolution` gains `authorizer_allowed` and `authorizer_denied`, and existing decisions change which resolution they report. An ask decided by a registered `authorizerChain` link now reports `authorizer_allowed` / `authorizer_denied` instead of `user_approved` / `user_denied`, and a subagent's ask answered by its parent reports what decided inside that parent — `policy_allow` / `policy_deny` for a rule, `authorizer_*` for a link — instead of attributing it to the user. Consumers switching exhaustively over `resolution` must handle the two new values; a consumer counting `user_denied` as human interactions now gets the accurate count rather than one inflated by machine decisions.
23
+
24
+ ### Features
25
+
26
+ * **pi-permission-system:** broadcast the decider that actually resolved a permission ask ([1b92386](https://github.com/gotgenes/pi-packages/commit/1b923863a00284c7bd6ca9bef5307ad83db0677d)), closes [#772](https://github.com/gotgenes/pi-packages/issues/772)
27
+
28
+
29
+ ### Bug Fixes
30
+
31
+ * **pi-permission-system:** tell the agent which authorizer refused its call ([412189b](https://github.com/gotgenes/pi-packages/commit/412189b4e32db3ca419d030c1b2195532e8ebbd4)), closes [#772](https://github.com/gotgenes/pi-packages/issues/772)
32
+
33
+
34
+ ### Documentation
35
+
36
+ * **pi-permission-system:** record decision attribution and mark Phase 14 Step 5 complete ([b31f5d5](https://github.com/gotgenes/pi-packages/commit/b31f5d56d61727ab8bd146c2dfe83ce85aa7eef5)), closes [#772](https://github.com/gotgenes/pi-packages/issues/772)
37
+
8
38
  ## [27.1.3](https://github.com/gotgenes/pi-packages/compare/pi-permission-system-v27.1.2...pi-permission-system-v27.1.3) (2026-08-29)
9
39
 
10
40
 
package/dist/public.d.ts CHANGED
@@ -274,6 +274,10 @@ interface PermissionUiPromptEvent {
274
274
  }
275
275
  /** How a permission decision was reached. */
276
276
  type PermissionDecisionResolution = "policy_allow" | "policy_deny" | "session_approved" | "infrastructure_auto_allowed" | "user_approved" | "user_approved_for_session" | "user_denied" | "auto_approved" | "confirmation_unavailable"
277
+ /** A registered `authorizerChain` link granted the ask; no human was asked. */
278
+ | "authorizer_allowed"
279
+ /** A registered `authorizerChain` link refused the ask; no human was asked. */
280
+ | "authorizer_denied"
277
281
  /** The gate threw, or an escalation failed, and the request was blocked. */
278
282
  | "gate_error";
279
283
  /** Payload emitted on `permissions:decision`. */
@@ -420,6 +420,8 @@ A session serving another session's forwarded request emits one too, on its own
420
420
  That is what makes a forwarded prompt clearable: the ask is gated in the requesting session — a different process for an out-of-process subagent — so without it the serving session broadcasts a `permissions:ui_prompt` whose outcome never appears.
421
421
  A forwarded request the serving session's own policy allows or denies is answered without a prompt and broadcasts nothing, matching the UI-prompt channel.
422
422
  A served decision carries a non-null `forwarding` context; the requesting session still emits its own decision when the answer comes back.
423
+ That requesting-side decision is attributed to whatever decided **inside** the responding session: a rule there reports `policy_allow` / `policy_deny`, a chain link reports `authorizer_allowed` / `authorizer_denied`, and a human there reports `user_approved` / `user_denied`.
424
+ The `resolution` names what decided, never where.
423
425
 
424
426
  The `requestId` is the same id the request's review-log entries carry, and the same one `permissions:ui_prompt` carried if the request reached a prompt — so a prompt and its outcome are joinable, as are two concurrent prompts for the same command.
425
427
  A request that reaches a prompt is answered by exactly one terminal event on that prompt's own bus, including when the dialog itself fails.
@@ -460,6 +462,8 @@ pi.events.on("permissions:decision", (raw) => {
460
462
  | `user_approved` | User approved once via dialog |
461
463
  | `user_approved_for_session` | User approved for the rest of the session |
462
464
  | `user_denied` | User denied via dialog |
465
+ | `authorizer_allowed` | A registered `authorizerChain` link granted the ask — no human asked |
466
+ | `authorizer_denied` | A registered `authorizerChain` link refused the ask — no human asked |
463
467
  | `auto_approved` | Yolo mode — approved automatically without dialog |
464
468
  | `confirmation_unavailable` | State was `ask` but no UI was available — blocked |
465
469
  | `gate_error` | The gate threw, or an escalation failed — blocked, fail-closed |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gotgenes/pi-permission-system",
3
- "version": "27.1.3",
3
+ "version": "28.0.1",
4
4
  "description": "Permission enforcement extension for the Pi coding agent.",
5
5
  "type": "module",
6
6
  "exports": {
@@ -0,0 +1,69 @@
1
+ import {
2
+ type DecisionSource,
3
+ effectiveDecider,
4
+ } from "#src/authority/decision-source";
5
+ import type { PermissionDecisionResolution } from "#src/permission-events";
6
+
7
+ /** What became of the request, as the gate that ran it observed. */
8
+ export interface DecisionOutcome {
9
+ /** Whether the request was ultimately allowed. */
10
+ approved: boolean;
11
+ /** Whether the approval was scoped to the rest of the session. */
12
+ forSession: boolean;
13
+ }
14
+
15
+ /**
16
+ * Name how a decision was reached, from the decider stamped on it.
17
+ *
18
+ * The one place a {@link DecisionSource} becomes a
19
+ * {@link PermissionDecisionResolution}, shared by the local gate runner and the
20
+ * serving node so the two records of one request cannot disagree. Every arm
21
+ * reads the stamp the deciding site wrote (#726) rather than re-deriving the
22
+ * decider from the outcome — the derivation that reported an `authorizerChain`
23
+ * link's verdict, and a serving session's own policy, as the operator's answer
24
+ * (#772).
25
+ *
26
+ * `outcome` supplies what the decider does not record: whether the request was
27
+ * allowed, and whether the human scoped their grant to the session. A
28
+ * `{ kind: "user" }` record names the surface answered on, never the scope.
29
+ *
30
+ * The switch is exhaustive with no `default`, so a new `DecisionSource` variant
31
+ * is a compile error here rather than a silent `user_approved`.
32
+ */
33
+ export function resolutionFor(
34
+ decidedBy: DecisionSource,
35
+ outcome: DecisionOutcome,
36
+ ): PermissionDecisionResolution {
37
+ const decider = effectiveDecider(decidedBy);
38
+ switch (decider.kind) {
39
+ case "rule":
40
+ return outcome.approved ? "policy_allow" : "policy_deny";
41
+ case "session_approval":
42
+ return "session_approved";
43
+ case "infrastructure_read":
44
+ return "infrastructure_auto_allowed";
45
+ case "yolo":
46
+ return "auto_approved";
47
+ case "authorizer":
48
+ return outcome.approved ? "authorizer_allowed" : "authorizer_denied";
49
+ case "unavailable":
50
+ return "confirmation_unavailable";
51
+ case "gate_error":
52
+ return "gate_error";
53
+ case "user":
54
+ // A `forwarded` frame reaches here only when the responder named no
55
+ // decider — an older parent — so the hop is the only fact available and
56
+ // today's attribution is the fail-soft answer.
57
+ case "forwarded":
58
+ return userResolution(outcome);
59
+ }
60
+ }
61
+
62
+ function userResolution(
63
+ outcome: DecisionOutcome,
64
+ ): PermissionDecisionResolution {
65
+ if (!outcome.approved) {
66
+ return "user_denied";
67
+ }
68
+ return outcome.forSession ? "user_approved_for_session" : "user_approved";
69
+ }
@@ -88,6 +88,35 @@ export function asDecisionSource(value: unknown): DecisionSource | undefined {
88
88
  return narrowSource(value, MAX_DECISION_SOURCE_DEPTH);
89
89
  }
90
90
 
91
+ /**
92
+ * The decider a `forwarded` hop is standing in for: the innermost
93
+ * non-`forwarded` source, or the hop itself when the responder named none.
94
+ *
95
+ * `forwarded` answers *where* a decision was made; every other variant answers
96
+ * *what* made it. A reader asking the second question — how to name the
97
+ * resolution, which refusal to render — wants the inner record, or it reports
98
+ * the parent's policy as the operator's own answer.
99
+ *
100
+ * `responderSessionId` is deliberately dropped: the session that answered is a
101
+ * separate fact, carried on the record itself and (for a served decision) on
102
+ * the bus event's `forwarding` context.
103
+ *
104
+ * Bounded by {@link MAX_DECISION_SOURCE_DEPTH} like its sibling guard. A value
105
+ * read off disk is already bounded there, and a locally-built one nests once
106
+ * per hop, so the bound is insurance rather than a working limit; reaching it
107
+ * yields the deepest frame seen, which reads as "decided elsewhere".
108
+ */
109
+ export function effectiveDecider(source: DecisionSource): DecisionSource {
110
+ let decider = source;
111
+ for (let hop = 0; hop < MAX_DECISION_SOURCE_DEPTH; hop++) {
112
+ if (decider.kind !== "forwarded" || decider.decision === null) {
113
+ return decider;
114
+ }
115
+ decider = decider.decision;
116
+ }
117
+ return decider;
118
+ }
119
+
91
120
  function narrowSource(
92
121
  value: unknown,
93
122
  depthBudget: number,
@@ -1,10 +1,14 @@
1
1
  import { join } from "node:path";
2
+ import { resolutionFor } from "#src/authority/decision-resolution";
2
3
  import type { DecisionSource } from "#src/authority/decision-source";
3
4
  import {
4
5
  type ForwarderContext,
5
6
  getSessionId,
6
7
  } from "#src/authority/forwarder-context";
7
- import type { PermissionPromptDecision } from "#src/authority/permission-dialog";
8
+ import {
9
+ createDeniedPermissionDecision,
10
+ type PermissionPromptDecision,
11
+ } from "#src/authority/permission-dialog";
8
12
  import {
9
13
  type ForwardedAccessFacts,
10
14
  type ForwardedAccessIntent,
@@ -15,10 +19,7 @@ import {
15
19
  } from "#src/authority/permission-forwarding";
16
20
  import type { SubagentSessionRegistry } from "#src/authority/subagent-registry";
17
21
  import type { DecisionBroadcaster } from "#src/decision-reporter";
18
- import type {
19
- PermissionDecisionEvent,
20
- PermissionDecisionResolution,
21
- } from "#src/permission-events";
22
+ import type { PermissionDecisionEvent } from "#src/permission-events";
22
23
  import { buildForwardedAskPayload } from "#src/presentation/forwarded-ask-payload";
23
24
  import { SessionApproval } from "#src/session-approval";
24
25
  import type { SessionApprovalRecorder } from "#src/session-approval-recorder";
@@ -183,41 +184,22 @@ function buildServedDecisionEvent(
183
184
  value: details.value ?? facts.value,
184
185
  agentName: details.agentName,
185
186
  result: decision.approved ? "allow" : "deny",
186
- resolution: servedResolution(decision),
187
+ resolution: resolutionFor(decision.decidedBy, {
188
+ approved: decision.approved,
189
+ // The grant scope is reported as the human chose it. `applyGrantScope`
190
+ // rewrites a whole-serving-session grant to a plain approval on the
191
+ // wire, but that translation is about what the *child* records, not
192
+ // about what was allowed here.
193
+ forSession:
194
+ decision.state === "approved_for_session" ||
195
+ decision.state === "approved_for_serving_session",
196
+ }),
187
197
  origin: null,
188
198
  matchedPattern: null,
189
199
  forwarding: details.forwarding ?? null,
190
200
  };
191
201
  }
192
202
 
193
- /**
194
- * Name how a served ask resolved, reading the decision's own stamp rather than
195
- * re-deriving it from the outcome: the site that decided already recorded what
196
- * it was (#726).
197
- *
198
- * The grant scope is reported as the human chose it. {@link applyGrantScope}
199
- * rewrites a whole-serving-session grant to a plain approval on the wire, but
200
- * that translation is about what the *child* records, not about what was
201
- * allowed here.
202
- */
203
- function servedResolution(
204
- decision: PermissionPromptDecision,
205
- ): PermissionDecisionResolution {
206
- if (decision.decidedBy.kind === "gate_error") {
207
- return "gate_error";
208
- }
209
- if (decision.confirmationUnavailable) {
210
- return "confirmation_unavailable";
211
- }
212
- if (!decision.approved) {
213
- return "user_denied";
214
- }
215
- return decision.state === "approved_for_session" ||
216
- decision.state === "approved_for_serving_session"
217
- ? "user_approved_for_session"
218
- : "user_approved";
219
- }
220
-
221
203
  // ── ForwardedRequestServer ────────────────────────────────────────────────
222
204
 
223
205
  /**
@@ -477,9 +459,12 @@ export class ForwardedRequestServer implements InboxProcessor {
477
459
  : "forwarded_permission.auto_denied",
478
460
  { ...logDetails, decidedBy },
479
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).
480
465
  return approved
481
466
  ? { approved: true, state: "approved", decidedBy }
482
- : { approved: false, state: "denied", decidedBy };
467
+ : { ...createDeniedPermissionDecision(check.reason), decidedBy };
483
468
  }
484
469
 
485
470
  this.logger.review("forwarded_permission.prompted", logDetails);
@@ -11,21 +11,16 @@ export type PermissionPromptDecision = {
11
11
  approved: boolean;
12
12
  state: PermissionDecisionState;
13
13
  denialReason?: string;
14
- /**
15
- * True when the decision was made automatically by yolo mode rather than
16
- * by an interactive user prompt. Used by handlers to emit "auto_approved"
17
- * rather than "user_approved" in the permissions:decision broadcast.
18
- */
19
- autoApproved?: true;
20
14
  /**
21
15
  * True when no human ever ruled on this ask: either no live authority was
22
16
  * reachable at all (`DenyingAuthorizer`, a no-UI non-subagent session) or the
23
17
  * forwarding path gave up before reaching one (`ParentAuthorizer` — target
24
18
  * unresolvable, request undeliverable, target not serving, or no answer
25
- * within the timeout). Consumed by deriveResolution (the decision-event
26
- * resolution), the gate (block reason), and PermissionPrompter (review-entry
27
- * resolution) to emit "confirmation_unavailable" rather than a plain user
28
- * denial — a user who was never asked denied nothing (#719).
19
+ * within the timeout). Consumed by the gate (block reason) and
20
+ * `PermissionPrompter` (review-entry resolution) to report
21
+ * "confirmation_unavailable" rather than a plain user denial — a user who
22
+ * was never asked denied nothing (#719). The decision-event resolution
23
+ * reads the `unavailable` decider below instead (#772).
29
24
  */
30
25
  confirmationUnavailable?: true;
31
26
  /**
@@ -86,33 +86,6 @@ export function buildDecisionEvent(
86
86
  };
87
87
  }
88
88
 
89
- /**
90
- * Map the gate outcome back to a PermissionDecisionResolution.
91
- *
92
- * @param state - The permission state passed to the gate.
93
- * @param action - The gate's resulting action ("allow" | "block").
94
- * @param hasSession - True when the gate result carries a sessionApproval
95
- * (indicates the user chose "for this session").
96
- * @param confirmationUnavailable - True when the denial came from the
97
- * DenyingAuthorizer (no live authority was reachable).
98
- */
99
- export function deriveResolution(
100
- state: "allow" | "deny" | "ask",
101
- action: "allow" | "block",
102
- hasSession: boolean,
103
- confirmationUnavailable: boolean,
104
- autoApproved = false,
105
- ): PermissionDecisionResolution {
106
- if (state === "allow") return autoApproved ? "auto_approved" : "policy_allow";
107
- if (state === "deny") return "policy_deny";
108
- // state === "ask"
109
- if (action === "allow") {
110
- if (autoApproved) return "auto_approved";
111
- return hasSession ? "user_approved_for_session" : "user_approved";
112
- }
113
- return confirmationUnavailable ? "confirmation_unavailable" : "user_denied";
114
- }
115
-
116
89
  /**
117
90
  * The standing yolo grant covering a gate's resolved check, or `null` when
118
91
  * yolo does not answer it.
@@ -1,4 +1,6 @@
1
1
  import type { AskEscalator } from "#src/authority/authorizer-selection";
2
+ import { resolutionFor } from "#src/authority/decision-resolution";
3
+ import type { DecisionSource } from "#src/authority/decision-source";
2
4
  import type { PermissionPromptDecision } from "#src/authority/permission-dialog";
3
5
  import type { DecisionReporter } from "#src/decision-reporter";
4
6
  import { applyPermissionGate } from "#src/permission-gate";
@@ -6,8 +8,7 @@ import { createPermissionRequestId } from "#src/permission-request-id";
6
8
  import type { ScopedPermissionResolver } from "#src/permission-resolver";
7
9
  import {
8
10
  renderPolicyDenial,
9
- renderUnavailableDenial,
10
- renderUserDenial,
11
+ renderRefusal,
11
12
  } from "#src/presentation/agent-renderer";
12
13
  import { renderReviewLogFacts } from "#src/presentation/review-log-renderer";
13
14
  import type { SessionApprovalRecorder } from "#src/session-approval-recorder";
@@ -18,11 +19,7 @@ import type {
18
19
  GateResult,
19
20
  } from "./descriptor";
20
21
  import { isGateBypass } from "./descriptor";
21
- import {
22
- buildDecisionEvent,
23
- deriveResolution,
24
- resolveYoloGrant,
25
- } from "./helpers";
22
+ import { buildDecisionEvent, resolveYoloGrant } from "./helpers";
26
23
  import type { GateOutcome } from "./types";
27
24
 
28
25
  // ── GateRunner class ───────────────────────────────────────────────────────
@@ -160,12 +157,17 @@ export class GateRunner {
160
157
  // single auto_approved review entry + decision event so log parity holds.
161
158
  const yoloGrant = resolveYoloGrant(check, this.isYoloEnabled());
162
159
  if (yoloGrant) {
160
+ // The pattern that raised the ask, sentinel included: "yolo allowed it"
161
+ // alone does not say why it was asked in the first place. One record for
162
+ // both the review entry and the broadcast, so they cannot disagree.
163
+ const decidedByYolo: DecisionSource = {
164
+ kind: "yolo",
165
+ pattern: check.matchedPattern ?? null,
166
+ };
163
167
  this.reporter.writeReviewLog("permission_request.auto_approved", {
164
168
  ...logContext,
165
169
  resolution: "auto_approved",
166
- // The pattern that raised the ask, sentinel included: "yolo allowed
167
- // it" alone does not say why it was asked in the first place.
168
- decidedBy: { kind: "yolo", pattern: check.matchedPattern ?? null },
170
+ decidedBy: decidedByYolo,
169
171
  });
170
172
  this.emitDecision(
171
173
  requestId,
@@ -174,7 +176,7 @@ export class GateRunner {
174
176
  yoloGrant,
175
177
  agentName,
176
178
  "allow",
177
- deriveResolution(yoloGrant.state, "allow", false, false, true),
179
+ resolutionFor(decidedByYolo, { approved: true, forSession: false }),
178
180
  ),
179
181
  );
180
182
  return { action: "allow" };
@@ -189,14 +191,22 @@ export class GateRunner {
189
191
  const { payload } = descriptor;
190
192
  const messages = {
191
193
  denyReason: renderPolicyDenial(payload, check.reason ?? null),
192
- unavailableReason: (decision: PermissionPromptDecision) =>
193
- renderUnavailableDenial(payload, decision.denialReason ?? null),
194
- userDeniedReason: (decision: PermissionPromptDecision) =>
195
- renderUserDenial(payload, decision.denialReason ?? null),
194
+ refusedReason: (decision: PermissionPromptDecision) =>
195
+ renderRefusal(
196
+ payload,
197
+ decision.decidedBy,
198
+ decision.denialReason ?? null,
199
+ ),
196
200
  };
197
201
 
198
- let autoApproved = false;
199
- let confirmationUnavailable = false;
202
+ // The rule that resolved this gate, and the decider for every arm that
203
+ // never escalates: `allow` and `deny` are recorded authority answering.
204
+ const decidedByRule: DecisionSource = {
205
+ kind: "rule",
206
+ surface: descriptor.surface,
207
+ pattern: check.matchedPattern ?? null,
208
+ origin: check.origin,
209
+ };
200
210
  const gateResult = await applyPermissionGate({
201
211
  state: check.state,
202
212
  sessionApproval: descriptor.sessionApproval?.toGateApproval(),
@@ -209,19 +219,12 @@ export class GateRunner {
209
219
  ? { sessionApproval: descriptor.sessionApproval.toForwardedData() }
210
220
  : {}),
211
221
  });
212
- autoApproved = decision.autoApproved === true;
213
- confirmationUnavailable = decision.confirmationUnavailable === true;
214
222
  return decision;
215
223
  },
216
224
  writeLog: (event, details) =>
217
225
  this.reporter.writeReviewLog(event, details),
218
226
  logContext,
219
- decidedByRule: {
220
- kind: "rule",
221
- surface: descriptor.surface,
222
- pattern: check.matchedPattern ?? null,
223
- origin: check.origin,
224
- },
227
+ decidedByRule,
225
228
  messages,
226
229
  });
227
230
 
@@ -237,13 +240,10 @@ export class GateRunner {
237
240
  check,
238
241
  agentName,
239
242
  gateResult.action === "allow" ? "allow" : "deny",
240
- deriveResolution(
241
- check.state,
242
- gateResult.action,
243
- hasSessionApproval,
244
- confirmationUnavailable,
245
- autoApproved,
246
- ),
243
+ resolutionFor(gateResult.decidedBy, {
244
+ approved: gateResult.action === "allow",
245
+ forSession: hasSessionApproval,
246
+ }),
247
247
  ),
248
248
  );
249
249
 
@@ -139,6 +139,10 @@ export type PermissionDecisionResolution =
139
139
  | "user_denied"
140
140
  | "auto_approved"
141
141
  | "confirmation_unavailable"
142
+ /** A registered `authorizerChain` link granted the ask; no human was asked. */
143
+ | "authorizer_allowed"
144
+ /** A registered `authorizerChain` link refused the ask; no human was asked. */
145
+ | "authorizer_denied"
142
146
  /** The gate threw, or an escalation failed, and the request was blocked. */
143
147
  | "gate_error";
144
148
 
@@ -1,10 +1,21 @@
1
1
  import type { DecisionSource } from "#src/authority/decision-source";
2
2
  import type { PermissionPromptDecision } from "#src/authority/permission-dialog";
3
3
 
4
- /** Result of applying the permission gate. */
4
+ /**
5
+ * Result of applying the permission gate.
6
+ *
7
+ * Both arms name what decided. The gate is the one place that knows whether
8
+ * recorded authority answered or an escalation did, so it reports the decider
9
+ * rather than leaving the caller to reconstruct it from a captured decision
10
+ * (#772).
11
+ */
5
12
  export type PermissionGateResult =
6
- | { action: "allow"; sessionApproval?: { surface: string; pattern: string } }
7
- | { action: "block"; reason: string };
13
+ | {
14
+ action: "allow";
15
+ decidedBy: DecisionSource;
16
+ sessionApproval?: { surface: string; pattern: string };
17
+ }
18
+ | { action: "block"; decidedBy: DecisionSource; reason: string };
8
19
 
9
20
  /** Everything the gate needs — no direct dependency on ExtensionContext. */
10
21
  export interface PermissionGateParams {
@@ -32,7 +43,8 @@ export interface PermissionGateParams {
32
43
  logContext: Record<string, unknown>;
33
44
 
34
45
  /**
35
- * The rule that resolved this gate, for the deny arm's review entry.
46
+ * The rule that resolved this gate the decider for both arms that never
47
+ * escalate, and the deny arm's review entry.
36
48
  *
37
49
  * A sibling of `logContext` rather than a member of it: the context holds
38
50
  * what every resolution of this gate shares, and the decider is by
@@ -42,9 +54,16 @@ export interface PermissionGateParams {
42
54
 
43
55
  /** Message strings/factories for each outcome. */
44
56
  messages: {
57
+ /** What the agent is told when recorded authority denied the request. */
45
58
  denyReason: string;
46
- unavailableReason: (decision: PermissionPromptDecision) => string;
47
- userDeniedReason: (decision: PermissionPromptDecision) => string;
59
+ /**
60
+ * What the agent is told when an escalation refused it.
61
+ *
62
+ * One factory rather than one per outcome: which sentence a refusal earns
63
+ * follows from the decision's own decider, and that dispatch belongs with
64
+ * the renderers rather than here (#772).
65
+ */
66
+ refusedReason: (decision: PermissionPromptDecision) => string;
48
67
  };
49
68
  }
50
69
 
@@ -64,26 +83,34 @@ export async function applyPermissionGate(
64
83
  resolution: "policy_denied",
65
84
  decidedBy: params.decidedByRule,
66
85
  });
67
- return { action: "block", reason: messages.denyReason };
86
+ return {
87
+ action: "block",
88
+ decidedBy: params.decidedByRule,
89
+ reason: messages.denyReason,
90
+ };
68
91
  }
69
92
 
70
93
  if (state === "ask") {
71
94
  const decision = await promptForApproval();
95
+ const decidedBy = decision.decidedBy;
72
96
  if (!decision.approved) {
73
97
  // The gate writes no review entry for an ask denial — the prompter
74
- // brackets it (waiting/denied). The block reason distinguishes an
75
- // absent-authority denial (confirmationUnavailable) from a user denial.
98
+ // brackets it (waiting/denied).
76
99
  return {
77
100
  action: "block",
78
- reason: decision.confirmationUnavailable
79
- ? messages.unavailableReason(decision)
80
- : messages.userDeniedReason(decision),
101
+ decidedBy,
102
+ reason: messages.refusedReason(decision),
81
103
  };
82
104
  }
83
105
  if (decision.state === "approved_for_session" && params.sessionApproval) {
84
- return { action: "allow", sessionApproval: params.sessionApproval };
106
+ return {
107
+ action: "allow",
108
+ decidedBy,
109
+ sessionApproval: params.sessionApproval,
110
+ };
85
111
  }
112
+ return { action: "allow", decidedBy };
86
113
  }
87
114
 
88
- return { action: "allow" };
115
+ return { action: "allow", decidedBy: params.decidedByRule };
89
116
  }
@@ -1,3 +1,7 @@
1
+ import {
2
+ type DecisionSource,
3
+ effectiveDecider,
4
+ } from "#src/authority/decision-source";
1
5
  import { EXTENSION_ID } from "#src/extension-config";
2
6
  import { DEFAULT_RENDER_BUDGET } from "#src/presentation/dialog-renderer";
3
7
  import {
@@ -10,6 +14,7 @@ import {
10
14
  findEvidence,
11
15
  type PromptPayload,
12
16
  } from "#src/presentation/prompt-payload";
17
+ import type { BashCommandContext } from "#src/types";
13
18
 
14
19
  /**
15
20
  * The agent-facing render of a refused permission ask (ADR 0011 §7).
@@ -41,6 +46,70 @@ export interface AgentRenderBudget {
41
46
  readonly fieldMaxWidth: number;
42
47
  }
43
48
 
49
+ /**
50
+ * The agent-facing render a refused ask earns, chosen by what refused it.
51
+ *
52
+ * The single dispatch point for the refusal renderers below, so which sentence
53
+ * the agent gets is decided once from the decider stamped on the decision
54
+ * (#726) rather than re-derived from a marker at each caller. Being told a
55
+ * policy extension refused the call, rather than the operator, is what lets an
56
+ * agent read a link's corrective reason as policy instead of as the user's
57
+ * instruction (ADR 0011 §7).
58
+ *
59
+ * A `forwarded` decision is dispatched on the decider inside the responding
60
+ * session: the hop says *where*, and the agent is told *what*.
61
+ *
62
+ * Exhaustive with no `default`, so a new {@link DecisionSource} variant is a
63
+ * compile error here rather than a silent user attribution.
64
+ */
65
+ export function renderRefusal(
66
+ payload: PromptPayload,
67
+ decidedBy: DecisionSource,
68
+ denialReason: string | null,
69
+ budget: AgentRenderBudget = DEFAULT_RENDER_BUDGET,
70
+ ): string {
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";
75
+ switch (decider.kind) {
76
+ case "authorizer":
77
+ return renderAuthorizerDenial(
78
+ payload,
79
+ decider.name,
80
+ denialReason,
81
+ budget,
82
+ );
83
+ case "unavailable":
84
+ return renderUnavailableDenial(payload, denialReason, budget);
85
+ case "rule":
86
+ return renderEscalatedPolicyDenial(
87
+ payload,
88
+ { pattern: decider.pattern, decidedElsewhere },
89
+ denialReason,
90
+ budget,
91
+ );
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":
105
+ case "session_approval":
106
+ case "infrastructure_read":
107
+ case "yolo":
108
+ case "forwarded":
109
+ return renderUserDenial(payload, denialReason, budget);
110
+ }
111
+ }
112
+
44
113
  /** The agent-facing render of a policy deny. */
45
114
  export function renderPolicyDenial(
46
115
  payload: PromptPayload,
@@ -48,7 +117,7 @@ export function renderPolicyDenial(
48
117
  budget: AgentRenderBudget = DEFAULT_RENDER_BUDGET,
49
118
  ): string {
50
119
  return tagged(
51
- `Denied by policy: ${identification(payload, budget, "")}${boundaryClause(payload)}${provenanceClause(payload)}.`,
120
+ `Denied by policy: ${identification(payload, budget, "", askRuleClause(payload))}${boundaryClause(payload)}${provenanceClause(payload)}.`,
52
121
  ruleReason,
53
122
  );
54
123
  }
@@ -60,11 +129,96 @@ export function renderUserDenial(
60
129
  budget: AgentRenderBudget = DEFAULT_RENDER_BUDGET,
61
130
  ): string {
62
131
  return tagged(
63
- `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)}.`,
133
+ denialReason,
134
+ );
135
+ }
136
+
137
+ /**
138
+ * The agent-facing render of a registered `authorizerChain` link's refusal.
139
+ *
140
+ * Names the link, because "a policy extension the operator configured refused
141
+ * this" and "the operator refused this" are different facts and only one of
142
+ * them was true (#772). The name is operator configuration rather than agent
143
+ * input, so it is not capped — the same treatment the matched rule gets.
144
+ */
145
+ export function renderAuthorizerDenial(
146
+ payload: PromptPayload,
147
+ linkName: string,
148
+ denialReason: string | null,
149
+ budget: AgentRenderBudget = DEFAULT_RENDER_BUDGET,
150
+ ): string {
151
+ return tagged(
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)}.`,
64
184
  denialReason,
65
185
  );
66
186
  }
67
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
+
68
222
  /** The agent-facing render when no live authority could answer the ask. */
69
223
  export function renderUnavailableDenial(
70
224
  payload: PromptPayload,
@@ -72,13 +226,24 @@ export function renderUnavailableDenial(
72
226
  budget: AgentRenderBudget = DEFAULT_RENDER_BUDGET,
73
227
  ): string {
74
228
  return tagged(
75
- `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.`,
76
230
  denialReason,
77
231
  );
78
232
  }
79
233
 
80
234
  // ── Sentence assembly ──────────────────────────────────────────────────────
81
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
+
82
247
  function tagged(sentence: string, reason: string | null): string {
83
248
  return `${EXTENSION_TAG} ${sentence}${reasonClause(reason)}`;
84
249
  }
@@ -91,11 +256,16 @@ function tagged(sentence: string, reason: string | null): string {
91
256
  * `callWord` is the noun the verdict needs after the surface — a user or
92
257
  * unavailable verdict refuses a *call*, while a policy deny refuses the
93
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).
94
263
  */
95
264
  function identification(
96
265
  payload: PromptPayload,
97
266
  budget: AgentRenderBudget,
98
267
  callWord: string,
268
+ ruleText: string,
99
269
  ): string {
100
270
  return [
101
271
  `'${payload.request.surface}'`,
@@ -104,7 +274,7 @@ function identification(
104
274
  toolClause(payload),
105
275
  agentClause(payload),
106
276
  flaggedClause(payload, budget),
107
- ruleClause(payload),
277
+ ruleText,
108
278
  ]
109
279
  .filter((clause) => clause !== "")
110
280
  .join(" ");
@@ -171,12 +341,29 @@ function resolvedAlias(payload: PromptPayload, element: string): string {
171
341
  return resolved ? ` (resolves to '${resolved}')` : "";
172
342
  }
173
343
 
174
- /** The rule that fired, with the nested context that makes it intelligible. */
175
- function ruleClause(payload: PromptPayload): string {
176
- 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 {
177
364
  const parts: string[] = [];
178
- if (matchedPattern !== null) {
179
- parts.push(`rule '${matchedPattern}'`);
365
+ if (pattern !== null) {
366
+ parts.push(`rule '${pattern}'`);
180
367
  }
181
368
  const context = describeBashCommandContext(commandContext);
182
369
  if (context !== undefined) {