@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
|
@@ -5,7 +5,10 @@ import {
|
|
|
5
5
|
type ForwarderContext,
|
|
6
6
|
getSessionId,
|
|
7
7
|
} from "#src/authority/forwarder-context";
|
|
8
|
-
import
|
|
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
|
-
: {
|
|
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
|
-
|
|
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
|
|
248
|
-
function
|
|
249
|
-
|
|
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 (
|
|
252
|
-
parts.push(`rule '${
|
|
365
|
+
if (pattern !== null) {
|
|
366
|
+
parts.push(`rule '${pattern}'`);
|
|
253
367
|
}
|
|
254
368
|
const context = describeBashCommandContext(commandContext);
|
|
255
369
|
if (context !== undefined) {
|