@gotgenes/pi-permission-system 26.0.0 → 26.2.0

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.
@@ -0,0 +1,235 @@
1
+ /**
2
+ * What decided a permission request, recorded at the site that decided it.
3
+ *
4
+ * The decision paths are already distinct in the code — a session hit, a yolo
5
+ * grant, an infrastructure read, a config rule, a chain link, a human at a
6
+ * dialog, an unreachable authority — and each one knows what it is at the
7
+ * moment it decides. This is that fact, carried to the record instead of being
8
+ * discarded and re-guessed from an event name downstream.
9
+ *
10
+ * Every variant is **self-contained**: it repeats the detail that made it
11
+ * decisive rather than leaning on a sibling log column. That duplicates
12
+ * `surface` and the pattern on a local review line, and it is the only shape
13
+ * that survives the forwarding hop, where the response file has no such
14
+ * columns to lean on.
15
+ */
16
+
17
+ /** Which human-facing surface the operator answered on. */
18
+ export type UserDecisionSurface = "dialog" | "select";
19
+
20
+ export type DecisionSource =
21
+ /** A human ruled, at the inline dialog or the `select`/`input` fallback. */
22
+ | { kind: "user"; via: UserDecisionSurface }
23
+ /** A registered `authorizerChain` link ruled; `name` is the configured name. */
24
+ | {
25
+ kind: "authorizer";
26
+ name: string;
27
+ verdict: "allow" | "deny";
28
+ reason: string | null;
29
+ }
30
+ /** Recorded authority: a rule in the composed ruleset matched. */
31
+ | {
32
+ kind: "rule";
33
+ surface: string;
34
+ pattern: string | null;
35
+ origin: string | null;
36
+ }
37
+ /** A session-scoped grant the operator made earlier in this session. */
38
+ | { kind: "session_approval"; surface: string; pattern: string | null }
39
+ /**
40
+ * `yoloMode`. `pattern` preserves the ask's matched rule — including a
41
+ * synthetic sentinel such as `<opaque-bash-wrapper>` — which is what makes a
42
+ * yolo grant over a synthesized ask legible.
43
+ */
44
+ | { kind: "yolo"; pattern: string | null }
45
+ /** A Pi infrastructure read, allowed by containment rather than by a rule. */
46
+ | { kind: "infrastructure_read" }
47
+ /**
48
+ * No authority ever ruled: none was reachable, or the forwarding path gave
49
+ * up before reaching one. `reason` names which path gave up.
50
+ */
51
+ | { kind: "unavailable"; reason: string }
52
+ /** A gate threw, and the boundary blocked rather than allowed. */
53
+ | { kind: "gate_error"; reason: string }
54
+ /**
55
+ * Another session decided. Recursive by design: the requesting side records
56
+ * both that the decider was elsewhere and what, within that session, decided
57
+ * — which is the distinction an audit of a forwarded ask needs.
58
+ *
59
+ * `decision` is `null` when the responder sent none (an older parent).
60
+ */
61
+ | {
62
+ kind: "forwarded";
63
+ responderSessionId: string | null;
64
+ decision: DecisionSource | null;
65
+ };
66
+
67
+ /**
68
+ * How deep a `forwarded` chain may nest before {@link asDecisionSource} gives
69
+ * up.
70
+ *
71
+ * Forwarding is depth-1 by invariant (child → root) and a relay hop makes it
72
+ * two, so this is headroom rather than a working limit. It exists because the
73
+ * value is read off disk: a recursive reader over a file another process wrote
74
+ * is a stack-overflow surface, and the fail-closed answer is to stop.
75
+ */
76
+ export const MAX_DECISION_SOURCE_DEPTH = 4;
77
+
78
+ /**
79
+ * Narrow an unknown value to a {@link DecisionSource}, or `undefined`.
80
+ *
81
+ * Lives beside its type so a new variant updates the guard next door, following
82
+ * `asPromptPayload` and `isPermissionDecisionState`. All-or-nothing: a
83
+ * malformed field — at any nesting level — yields `undefined` rather than a
84
+ * half-parsed record, because a provenance record that names a decider who did
85
+ * not decide is worse than one that names none.
86
+ */
87
+ export function asDecisionSource(value: unknown): DecisionSource | undefined {
88
+ return narrowSource(value, MAX_DECISION_SOURCE_DEPTH);
89
+ }
90
+
91
+ function narrowSource(
92
+ value: unknown,
93
+ depthBudget: number,
94
+ ): DecisionSource | undefined {
95
+ const candidate = asObject(value);
96
+ if (!candidate) return undefined;
97
+
98
+ switch (candidate.kind) {
99
+ case "user":
100
+ return narrowUser(candidate);
101
+ case "authorizer":
102
+ return narrowAuthorizer(candidate);
103
+ case "rule":
104
+ return narrowRule(candidate);
105
+ case "session_approval":
106
+ return narrowSessionApproval(candidate);
107
+ case "yolo":
108
+ return isNullableString(candidate.pattern)
109
+ ? { kind: "yolo", pattern: candidate.pattern }
110
+ : undefined;
111
+ case "infrastructure_read":
112
+ return { kind: "infrastructure_read" };
113
+ case "unavailable":
114
+ return typeof candidate.reason === "string"
115
+ ? { kind: "unavailable", reason: candidate.reason }
116
+ : undefined;
117
+ case "gate_error":
118
+ return typeof candidate.reason === "string"
119
+ ? { kind: "gate_error", reason: candidate.reason }
120
+ : undefined;
121
+ case "forwarded":
122
+ return narrowForwarded(candidate, depthBudget);
123
+ default:
124
+ return undefined;
125
+ }
126
+ }
127
+
128
+ function narrowUser(
129
+ candidate: Record<string, unknown>,
130
+ ): DecisionSource | undefined {
131
+ const via = USER_DECISION_SURFACES.find((entry) => entry === candidate.via);
132
+ return via ? { kind: "user", via } : undefined;
133
+ }
134
+
135
+ function narrowAuthorizer(
136
+ candidate: Record<string, unknown>,
137
+ ): DecisionSource | undefined {
138
+ const verdict = AUTHORIZER_VERDICTS.find(
139
+ (entry) => entry === candidate.verdict,
140
+ );
141
+ if (
142
+ !verdict ||
143
+ typeof candidate.name !== "string" ||
144
+ !isNullableString(candidate.reason)
145
+ ) {
146
+ return undefined;
147
+ }
148
+ return {
149
+ kind: "authorizer",
150
+ name: candidate.name,
151
+ verdict,
152
+ reason: candidate.reason,
153
+ };
154
+ }
155
+
156
+ function narrowRule(
157
+ candidate: Record<string, unknown>,
158
+ ): DecisionSource | undefined {
159
+ if (
160
+ typeof candidate.surface !== "string" ||
161
+ !isNullableString(candidate.pattern) ||
162
+ !isNullableString(candidate.origin)
163
+ ) {
164
+ return undefined;
165
+ }
166
+ return {
167
+ kind: "rule",
168
+ surface: candidate.surface,
169
+ pattern: candidate.pattern,
170
+ origin: candidate.origin,
171
+ };
172
+ }
173
+
174
+ function narrowSessionApproval(
175
+ candidate: Record<string, unknown>,
176
+ ): DecisionSource | undefined {
177
+ if (
178
+ typeof candidate.surface !== "string" ||
179
+ !isNullableString(candidate.pattern)
180
+ ) {
181
+ return undefined;
182
+ }
183
+ return {
184
+ kind: "session_approval",
185
+ surface: candidate.surface,
186
+ pattern: candidate.pattern,
187
+ };
188
+ }
189
+
190
+ /**
191
+ * The inner decision is narrowed against a decremented budget, so a chain
192
+ * deeper than {@link MAX_DECISION_SOURCE_DEPTH} is rejected whole rather than
193
+ * truncated — a truncated chain would silently attribute the decision to the
194
+ * last frame that fit.
195
+ */
196
+ function narrowForwarded(
197
+ candidate: Record<string, unknown>,
198
+ depthBudget: number,
199
+ ): DecisionSource | undefined {
200
+ if (depthBudget <= 0 || !isNullableString(candidate.responderSessionId)) {
201
+ return undefined;
202
+ }
203
+ if (candidate.decision === null) {
204
+ return {
205
+ kind: "forwarded",
206
+ responderSessionId: candidate.responderSessionId,
207
+ decision: null,
208
+ };
209
+ }
210
+ const decision = narrowSource(candidate.decision, depthBudget - 1);
211
+ return decision
212
+ ? {
213
+ kind: "forwarded",
214
+ responderSessionId: candidate.responderSessionId,
215
+ decision,
216
+ }
217
+ : undefined;
218
+ }
219
+
220
+ const USER_DECISION_SURFACES = [
221
+ "dialog",
222
+ "select",
223
+ ] as const satisfies readonly UserDecisionSurface[];
224
+
225
+ const AUTHORIZER_VERDICTS = ["allow", "deny"] as const;
226
+
227
+ function asObject(value: unknown): Record<string, unknown> | undefined {
228
+ return typeof value === "object" && value !== null && !Array.isArray(value)
229
+ ? (value as Record<string, unknown>)
230
+ : undefined;
231
+ }
232
+
233
+ function isNullableString(value: unknown): value is string | null {
234
+ return value === null || typeof value === "string";
235
+ }
@@ -1,6 +1,9 @@
1
1
  import type { PermissionPromptDecision } from "#src/authority/permission-dialog";
2
2
  import type { TerminalAuthorizer } from "./authorizer";
3
3
 
4
+ /** Why this authorizer denies; the provenance record's `reason` (#726). */
5
+ const NO_AUTHORITY_REASON = "No live authority was reachable for this session";
6
+
4
7
  /**
5
8
  * Least-privilege Authorizer: no authority is reachable for this session
6
9
  * (no UI, not a subagent), so every ask is denied.
@@ -15,6 +18,7 @@ export class DenyingAuthorizer implements TerminalAuthorizer {
15
18
  approved: false,
16
19
  state: "denied",
17
20
  confirmationUnavailable: true,
21
+ decidedBy: { kind: "unavailable", reason: NO_AUTHORITY_REASON },
18
22
  });
19
23
  }
20
24
  }
@@ -1,4 +1,5 @@
1
1
  import { join } from "node:path";
2
+ import type { DecisionSource } from "#src/authority/decision-source";
2
3
  import {
3
4
  type ForwarderContext,
4
5
  getSessionId,
@@ -22,6 +23,7 @@ import type { AskEscalator } from "./authorizer-selection";
22
23
  import {
23
24
  cleanupPermissionForwardingLocationIfEmpty,
24
25
  ensureDirectoryExists,
26
+ formatUnknownErrorMessage,
25
27
  getExistingPermissionForwardingLocation,
26
28
  listRequestFiles,
27
29
  logPermissionForwardingError,
@@ -276,6 +278,9 @@ export class ForwardedRequestServer implements InboxProcessor {
276
278
  * `approved` so the child records nothing (its next identical action
277
279
  * re-forwards and resolves as recorded authority). Every other decision
278
280
  * passes through unchanged (`approved_for_session` → the child records).
281
+ *
282
+ * The translation rewrites the grant's *scope*, never its decider: the human
283
+ * who chose the wider scope is still the one who decided (#726).
279
284
  */
280
285
  private applyGrantScope(
281
286
  request: ForwardedPermissionRequest,
@@ -298,7 +303,11 @@ export class ForwardedRequestServer implements InboxProcessor {
298
303
  patterns: request.sessionApproval.patterns,
299
304
  });
300
305
  }
301
- return { approved: true, state: "approved" };
306
+ return {
307
+ approved: true,
308
+ state: "approved",
309
+ decidedBy: decision.decidedBy,
310
+ };
302
311
  }
303
312
 
304
313
  /**
@@ -327,6 +336,7 @@ export class ForwardedRequestServer implements InboxProcessor {
327
336
  responsePath,
328
337
  resolution: decision.state,
329
338
  denialReason: decision.denialReason ?? null,
339
+ decidedBy: decision.decidedBy,
330
340
  },
331
341
  );
332
342
  try {
@@ -336,6 +346,9 @@ export class ForwardedRequestServer implements InboxProcessor {
336
346
  denialReason: decision.denialReason,
337
347
  responderSessionId: currentSessionId,
338
348
  respondedAt: Date.now(),
349
+ // Carried onto the wire so the requester can name what decided inside
350
+ // this session, not merely that this session answered (#726).
351
+ decidedBy: decision.decidedBy,
339
352
  } satisfies ForwardedPermissionResponse);
340
353
  } catch (error) {
341
354
  logPermissionForwardingError(
@@ -366,29 +379,48 @@ export class ForwardedRequestServer implements InboxProcessor {
366
379
  request: ForwardedPermissionRequest,
367
380
  logDetails: Record<string, unknown>,
368
381
  ): Promise<PermissionPromptDecision> {
369
- const state = request.accessIntent
370
- ? this.policy.resolve(request.accessIntent).state
371
- : "ask";
382
+ const check = request.accessIntent
383
+ ? this.policy.resolve(request.accessIntent)
384
+ : null;
372
385
 
373
- if (state === "allow") {
374
- this.logger.review("forwarded_permission.auto_approved", logDetails);
375
- return { approved: true, state: "approved" };
376
- }
377
- if (state === "deny") {
378
- this.logger.review("forwarded_permission.auto_denied", logDetails);
379
- return { approved: false, state: "denied" };
386
+ if (check && check.state !== "ask") {
387
+ // The rule is carried in full rather than left to the event name: the
388
+ // response file has no surface, pattern, or origin column for the
389
+ // requester's record to lean on.
390
+ const decidedBy: DecisionSource = {
391
+ kind: "rule",
392
+ surface: request.accessIntent?.surface ?? check.toolName,
393
+ pattern: check.matchedPattern ?? null,
394
+ origin: check.origin,
395
+ };
396
+ const approved = check.state === "allow";
397
+ this.logger.review(
398
+ approved
399
+ ? "forwarded_permission.auto_approved"
400
+ : "forwarded_permission.auto_denied",
401
+ { ...logDetails, decidedBy },
402
+ );
403
+ return approved
404
+ ? { approved: true, state: "approved", decidedBy }
405
+ : { approved: false, state: "denied", decidedBy };
380
406
  }
381
407
 
382
408
  this.logger.review("forwarded_permission.prompted", logDetails);
383
409
  try {
384
410
  return await this.escalator.escalate(buildForwardedAskDetails(request));
385
411
  } catch (error) {
412
+ const reason = formatUnknownErrorMessage(error);
386
413
  logPermissionForwardingError(
387
414
  this.logger,
388
415
  `Failed to escalate forwarded permission request '${request.id}'`,
389
416
  error,
390
417
  );
391
- return { approved: false, state: "denied" };
418
+ // Nobody denied this; the escalation broke and the node failed closed.
419
+ return {
420
+ approved: false,
421
+ state: "denied",
422
+ decidedBy: { kind: "gate_error", reason },
423
+ };
392
424
  }
393
425
  }
394
426
 
@@ -9,6 +9,7 @@ import {
9
9
  writeFileSync,
10
10
  } from "node:fs";
11
11
 
12
+ import { asDecisionSource } from "#src/authority/decision-source";
12
13
  import { isPermissionDecisionState } from "#src/authority/permission-dialog";
13
14
  import {
14
15
  createPermissionForwardingLocation,
@@ -466,6 +467,10 @@ export function readForwardedPermissionResponse(
466
467
  typeof parsed.respondedAt === "number"
467
468
  ? parsed.respondedAt
468
469
  : Date.now(),
470
+ // Tolerant like the request's `accessIntent`: an unusable provenance
471
+ // record is dropped, but the decision itself still has to reach the
472
+ // requester, so it never rejects the response.
473
+ decidedBy: asDecisionSource(parsed.decidedBy),
469
474
  };
470
475
  } catch (error) {
471
476
  logPermissionForwardingWarning(