@gotgenes/pi-permission-system 20.2.0 → 20.3.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.
package/CHANGELOG.md CHANGED
@@ -5,6 +5,19 @@ 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
+ ## [20.3.0](https://github.com/gotgenes/pi-packages/compare/pi-permission-system-v20.2.0...pi-permission-system-v20.3.0) (2026-07-09)
9
+
10
+
11
+ ### Features
12
+
13
+ * **pi-permission-system:** forward the child's session-approval suggestion ([fa04d8c](https://github.com/gotgenes/pi-packages/commit/fa04d8ca3485ca3f6e29bbe8d53146f5eeb6672c))
14
+ * **pi-permission-system:** offer whole-session scope on forwarded approvals ([bd2be07](https://github.com/gotgenes/pi-packages/commit/bd2be0799923ed809cd0939047486fdc1b2a477f))
15
+
16
+
17
+ ### Documentation
18
+
19
+ * **pi-permission-system:** record forwarded grant-scope selection (Phase 9 Step 4) ([a3d1fca](https://github.com/gotgenes/pi-packages/commit/a3d1fca183dc5e4d6fb15ec6ec78199f5d092bc3))
20
+
8
21
  ## [20.2.0](https://github.com/gotgenes/pi-packages/compare/pi-permission-system-v20.1.0...pi-permission-system-v20.2.0) (2026-07-09)
9
22
 
10
23
 
@@ -17,6 +17,7 @@ The integration enables:
17
17
  2. **Per-agent policy enforcement** - the permission system's `before_agent_start` handler resolves the agent name from the `<active_agent>` system-prompt tag and applies per-agent `permission:` frontmatter overrides.
18
18
  3. **`ask`-state forwarding** - when a child triggers an `ask` permission, the request forwards to the parent session's UI through the existing polling mechanism.
19
19
  The parent approves or denies, and the child resumes.
20
+ When the parent approves "for this session," it chooses a scope: **this subagent only** (the least-privilege default) records the grant on the requesting child, while **the whole session** records it on the serving parent so the parent and all its subagents resolve it without re-prompting.
20
21
 
21
22
  No configuration is required - the integration is automatic when both extensions are installed.
22
23
  When `@gotgenes/pi-permission-system` is not installed, `@gotgenes/pi-subagents` emits its lifecycle events with no subscriber - a harmless no-op.
@@ -25,6 +26,7 @@ When `@gotgenes/pi-permission-system` is not installed, `@gotgenes/pi-subagents`
25
26
 
26
27
  When a delegated or routed subagent runs without direct UI access, `ask` permissions can still be enforced by forwarding the confirmation request through Pi session directories.
27
28
  The main interactive session polls for forwarded requests, shows the confirmation prompt, writes the response, and the subagent resumes once that decision is available.
29
+ A parent `allow`/`deny` rule governs a child's escalation directly (the serving node resolves it as recorded authority before prompting), and a "whole session" grant recorded on the parent auto-approves later forwards of the same pattern.
28
30
 
29
31
  This keeps `ask` policies usable even when the original permission check happens inside a non-UI execution context.
30
32
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gotgenes/pi-permission-system",
3
- "version": "20.2.0",
3
+ "version": "20.3.0",
4
4
  "description": "Permission enforcement extension for the Pi coding agent.",
5
5
  "type": "module",
6
6
  "exports": {
@@ -22,6 +22,7 @@ import type { PermissionPromptDecision } from "#src/permission-dialog";
22
22
  import {
23
23
  type ForwardedPermissionRequest,
24
24
  type ForwardedPromptDisplay,
25
+ type ForwardedSessionApproval,
25
26
  PERMISSION_FORWARDING_POLL_INTERVAL_MS,
26
27
  PERMISSION_FORWARDING_TIMEOUT_MS,
27
28
  type PermissionForwardingLocation,
@@ -98,11 +99,16 @@ export class ParentAuthorizer implements Authorizer {
98
99
  details: PromptPermissionDetails,
99
100
  ): Promise<PermissionPromptDecision> {
100
101
  const uiPrompt = buildUiPrompt(details);
101
- return this.waitForForwardedApproval(this.ctx, details.message, {
102
- source: uiPrompt.source,
103
- surface: uiPrompt.surface,
104
- value: uiPrompt.value,
105
- });
102
+ return this.waitForForwardedApproval(
103
+ this.ctx,
104
+ details.message,
105
+ {
106
+ source: uiPrompt.source,
107
+ surface: uiPrompt.surface,
108
+ value: uiPrompt.value,
109
+ },
110
+ details.sessionApproval,
111
+ );
106
112
  }
107
113
 
108
114
  // ── Private methods ────────────────────────────────────────────────────
@@ -111,6 +117,7 @@ export class ParentAuthorizer implements Authorizer {
111
117
  ctx: ForwarderContext,
112
118
  message: string,
113
119
  forwarded?: ForwardedPromptDisplay,
120
+ sessionApproval?: ForwardedSessionApproval,
114
121
  ): Promise<PermissionPromptDecision> {
115
122
  const requesterSessionId = getSessionId(ctx);
116
123
  const targetSessionId = resolvePermissionForwardingTargetSessionId({
@@ -156,6 +163,7 @@ export class ParentAuthorizer implements Authorizer {
156
163
  requesterSessionId,
157
164
  targetSessionId,
158
165
  forwarded,
166
+ sessionApproval,
159
167
  );
160
168
  const requestPath = join(location.requestsDir, `${request.id}.json`);
161
169
  const responsePath = join(location.responsesDir, `${request.id}.json`);
@@ -194,6 +202,7 @@ export class ParentAuthorizer implements Authorizer {
194
202
  requesterSessionId: string,
195
203
  targetSessionId: string,
196
204
  forwarded?: ForwardedPromptDisplay,
205
+ sessionApproval?: ForwardedSessionApproval,
197
206
  ): ForwardedPermissionRequest {
198
207
  const requestId = `${Date.now()}-${Math.random().toString(36).slice(2, 10)}-${process.pid}`;
199
208
  const requesterAgentName =
@@ -214,6 +223,7 @@ export class ParentAuthorizer implements Authorizer {
214
223
  value: forwarded.value,
215
224
  }
216
225
  : {}),
226
+ ...(sessionApproval ? { sessionApproval } : {}),
217
227
  };
218
228
  }
219
229
 
@@ -10,6 +10,8 @@ import {
10
10
  isForwardedPermissionRequestForSession,
11
11
  type PermissionForwardingLocation,
12
12
  } from "#src/permission-forwarding";
13
+ import { SessionApproval } from "#src/session-approval";
14
+ import type { SessionApprovalRecorder } from "#src/session-approval-recorder";
13
15
  import type { DebugReviewLogger } from "#src/session-logger";
14
16
  import type { SubagentSessionRegistry } from "#src/subagent-registry";
15
17
  import type { PermissionCheckResult } from "#src/types";
@@ -61,6 +63,11 @@ export interface ForwardedRequestServerDeps {
61
63
  policy: ServingPolicy;
62
64
  /** Escalation seam to the serving session's selected `Authorizer` on `ask`. */
63
65
  escalator: AskEscalator;
66
+ /**
67
+ * The serving session's `SessionRules`. Records a whole-session grant when a
68
+ * human approves a forwarded request for the entire serving session.
69
+ */
70
+ recorder: SessionApprovalRecorder;
64
71
  /** In-process subagent registry, read only by the one-hop canary. */
65
72
  registry?: SubagentSessionRegistry;
66
73
  }
@@ -115,6 +122,11 @@ function buildForwardedAskDetails(
115
122
  requesterAgentName: request.requesterAgentName || null,
116
123
  requesterSessionId: request.requesterSessionId || null,
117
124
  },
125
+ // Carries the child's suggestion so LocalUserAuthorizer can offer the
126
+ // whole-session grant scope; absent for a legacy/version-skew request.
127
+ ...(request.sessionApproval
128
+ ? { sessionApproval: request.sessionApproval }
129
+ : {}),
118
130
  };
119
131
  }
120
132
 
@@ -132,6 +144,7 @@ export class ForwardedRequestServer implements InboxProcessor {
132
144
  private readonly logger: DebugReviewLogger;
133
145
  private readonly policy: ServingPolicy;
134
146
  private readonly escalator: AskEscalator;
147
+ private readonly recorder: SessionApprovalRecorder;
135
148
  private readonly registry: SubagentSessionRegistry | undefined;
136
149
 
137
150
  constructor(deps: ForwardedRequestServerDeps) {
@@ -139,6 +152,7 @@ export class ForwardedRequestServer implements InboxProcessor {
139
152
  this.logger = deps.logger;
140
153
  this.policy = deps.policy;
141
154
  this.escalator = deps.escalator;
155
+ this.recorder = deps.recorder;
142
156
  this.registry = deps.registry;
143
157
  }
144
158
 
@@ -237,10 +251,44 @@ export class ForwardedRequestServer implements InboxProcessor {
237
251
  location,
238
252
  requestPath,
239
253
  currentSessionId,
240
- decision,
254
+ this.applyGrantScope(request, decision, forwardedPermissionLogDetails),
241
255
  );
242
256
  }
243
257
 
258
+ /**
259
+ * Apply the human's grant-scope choice on a forwarded approval.
260
+ *
261
+ * A whole-session grant (`approved_for_serving_session`) records the child's
262
+ * suggested pattern into this serving node's `SessionRules` — the single
263
+ * source of truth for the scope — and is then translated to a plain
264
+ * `approved` so the child records nothing (its next identical action
265
+ * re-forwards and resolves as recorded authority). Every other decision
266
+ * passes through unchanged (`approved_for_session` → the child records).
267
+ */
268
+ private applyGrantScope(
269
+ request: ForwardedPermissionRequest,
270
+ decision: PermissionPromptDecision,
271
+ logDetails: Record<string, unknown>,
272
+ ): PermissionPromptDecision {
273
+ if (decision.state !== "approved_for_serving_session") {
274
+ return decision;
275
+ }
276
+ if (request.sessionApproval) {
277
+ this.recorder.recordSessionApproval(
278
+ SessionApproval.multiple(
279
+ request.sessionApproval.surface,
280
+ request.sessionApproval.patterns,
281
+ ),
282
+ );
283
+ this.logger.review("forwarded_permission.session_recorded", {
284
+ ...logDetails,
285
+ surface: request.sessionApproval.surface,
286
+ patterns: request.sessionApproval.patterns,
287
+ });
288
+ }
289
+ return { approved: true, state: "approved" };
290
+ }
291
+
244
292
  /**
245
293
  * Persist the served decision: write the response file the child polls for,
246
294
  * log the outcome, and delete the drained request. The symmetric "respond"
@@ -15,6 +15,7 @@ import {
15
15
  createPermissionForwardingLocation,
16
16
  type ForwardedPermissionRequest,
17
17
  type ForwardedPermissionResponse,
18
+ type ForwardedSessionApproval,
18
19
  type PermissionForwardingLocation,
19
20
  } from "#src/permission-forwarding";
20
21
  import type { DebugReviewLogger } from "#src/session-logger";
@@ -41,6 +42,31 @@ function asNullableDisplayString(value: unknown): string | null | undefined {
41
42
  return undefined;
42
43
  }
43
44
 
45
+ /**
46
+ * Narrow an unknown value to a `ForwardedSessionApproval`, or `undefined`.
47
+ *
48
+ * Tolerant read: the child's session-approval suggestion is optional (absent
49
+ * on an older child) and only accepted when well-formed — a non-empty surface
50
+ * and an all-string patterns array.
51
+ */
52
+ function asForwardedSessionApproval(
53
+ value: unknown,
54
+ ): ForwardedSessionApproval | undefined {
55
+ if (typeof value !== "object" || value === null) {
56
+ return undefined;
57
+ }
58
+ const candidate = value as Partial<ForwardedSessionApproval>;
59
+ if (
60
+ typeof candidate.surface !== "string" ||
61
+ candidate.surface.length === 0 ||
62
+ !Array.isArray(candidate.patterns) ||
63
+ !candidate.patterns.every((pattern) => typeof pattern === "string")
64
+ ) {
65
+ return undefined;
66
+ }
67
+ return { surface: candidate.surface, patterns: [...candidate.patterns] };
68
+ }
69
+
44
70
  export function formatUnknownErrorMessage(error: unknown): string {
45
71
  if (error instanceof Error && error.message) {
46
72
  return error.message;
@@ -323,6 +349,7 @@ export function readForwardedPermissionRequest(
323
349
  source: asUiPromptSource(parsed.source),
324
350
  surface: asNullableDisplayString(parsed.surface),
325
351
  value: asNullableDisplayString(parsed.value),
352
+ sessionApproval: asForwardedSessionApproval(parsed.sessionApproval),
326
353
  };
327
354
  } catch (error) {
328
355
  logPermissionForwardingWarning(
@@ -1,6 +1,8 @@
1
+ import { buildForwardedScopeLabels } from "#src/pattern-suggest";
1
2
  import type {
2
3
  PermissionDecisionUi,
3
4
  PermissionPromptDecision,
5
+ RequestPermissionOptions,
4
6
  requestPermissionDecisionFromUi,
5
7
  } from "#src/permission-dialog";
6
8
  import {
@@ -45,7 +47,30 @@ export class LocalUserAuthorizer implements Authorizer {
45
47
  ? "Permission Required (Subagent)"
46
48
  : "Permission Required",
47
49
  details.message,
48
- details.sessionLabel ? { sessionLabel: details.sessionLabel } : undefined,
50
+ buildRequestOptions(details),
49
51
  );
50
52
  }
51
53
  }
54
+
55
+ /**
56
+ * A forwarded ask carrying a session-approval suggestion offers the scope
57
+ * choice (subagent vs whole session); any other ask keeps its single
58
+ * "for this session" option (custom label when the gate supplied one).
59
+ */
60
+ function buildRequestOptions(
61
+ details: PromptPermissionDetails,
62
+ ): RequestPermissionOptions | undefined {
63
+ const pattern = details.sessionApproval?.patterns[0];
64
+ if (details.forwarding && details.sessionApproval && pattern) {
65
+ return {
66
+ sessionScope: buildForwardedScopeLabels(
67
+ details.forwarding.requesterAgentName,
68
+ details.sessionApproval.surface,
69
+ pattern,
70
+ ),
71
+ };
72
+ }
73
+ return details.sessionLabel
74
+ ? { sessionLabel: details.sessionLabel }
75
+ : undefined;
76
+ }
@@ -1,4 +1,5 @@
1
1
  import type { PermissionPromptDecision } from "#src/permission-dialog";
2
+ import type { ForwardedSessionApproval } from "#src/permission-forwarding";
2
3
  import type { ReviewLogger } from "#src/session-logger";
3
4
  import type { Authorizer } from "./authorizer";
4
5
 
@@ -38,6 +39,13 @@ export interface PromptPermissionDetails {
38
39
  value?: string | null;
39
40
  /** Present iff this ask was forwarded from a subagent; drives the non-degraded broadcast + "(Subagent)" title. */
40
41
  forwarding?: ForwardedAskProvenance;
42
+ /**
43
+ * The session-approval suggestion for this ask. On the child's escalation it
44
+ * rides into the forwarded request; on the serving node it lets the dialog
45
+ * offer a whole-session grant scope. Absent when the gate computed no
46
+ * suggestion.
47
+ */
48
+ sessionApproval?: ForwardedSessionApproval;
41
49
  }
42
50
 
43
51
  /**
@@ -147,6 +147,9 @@ export class GateRunner {
147
147
  const decision = await this.prompter.escalate({
148
148
  requestId: toolCallId,
149
149
  ...descriptor.promptDetails,
150
+ ...(descriptor.sessionApproval
151
+ ? { sessionApproval: descriptor.sessionApproval.toForwardedData() }
152
+ : {}),
150
153
  });
151
154
  autoApproved = decision.autoApproved === true;
152
155
  confirmationUnavailable = decision.confirmationUnavailable === true;
package/src/index.ts CHANGED
@@ -132,6 +132,10 @@ export default function piPermissionSystemExtension(pi: ExtensionAPI): void {
132
132
  logger,
133
133
  policy: servingPolicy,
134
134
  escalator: authorizerSelection,
135
+ // Records a whole-session grant into the same SessionRules the resolver and
136
+ // gate runner read, so a serving-scope grant governs the parent and future
137
+ // forwarded resolutions.
138
+ recorder: sessionRules,
135
139
  registry: subagentRegistry,
136
140
  });
137
141
 
@@ -62,6 +62,35 @@ export function suggestMcpPattern(target: string): string {
62
62
  return "*";
63
63
  }
64
64
 
65
+ /** Scope labels for the forwarded-approval two-step scope select. */
66
+ export interface ForwardedScopeLabels {
67
+ /** Least-privilege default: record on the requesting subagent only. */
68
+ subagentLabel: string;
69
+ /** Record on the serving node — covers the parent and all subagents. */
70
+ servingSessionLabel: string;
71
+ }
72
+
73
+ /**
74
+ * Build the two scope labels shown when a human grants a forwarded request
75
+ * "for this session."
76
+ *
77
+ * The subagent option names the requester (least privilege); the whole-session
78
+ * option restates the surface + pattern being granted session-wide.
79
+ */
80
+ export function buildForwardedScopeLabels(
81
+ agentName: string | null,
82
+ surface: string,
83
+ pattern: string,
84
+ ): ForwardedScopeLabels {
85
+ const subagentLabel = agentName
86
+ ? `This subagent ('${agentName}') only`
87
+ : "This subagent only";
88
+ return {
89
+ subagentLabel,
90
+ servingSessionLabel: `The whole session — allow ${surface} "${pattern}" for parent and all subagents`,
91
+ };
92
+ }
93
+
65
94
  /** Surface-aware human-readable labels for the session-approval option. */
66
95
  function buildLabel(pattern: string, surface: string): string {
67
96
  switch (surface) {
@@ -1,6 +1,7 @@
1
1
  export type PermissionDecisionState =
2
2
  | "approved"
3
3
  | "approved_for_session"
4
+ | "approved_for_serving_session"
4
5
  | "denied"
5
6
  | "denied_with_reason";
6
7
 
@@ -67,6 +68,7 @@ export function isPermissionDecisionState(
67
68
  return (
68
69
  value === "approved" ||
69
70
  value === "approved_for_session" ||
71
+ value === "approved_for_serving_session" ||
70
72
  value === "denied" ||
71
73
  value === "denied_with_reason"
72
74
  );
@@ -75,6 +77,15 @@ export function isPermissionDecisionState(
75
77
  export interface RequestPermissionOptions {
76
78
  /** Override the "for this session" option label (e.g. to show the suggested pattern). */
77
79
  sessionLabel?: string;
80
+ /**
81
+ * Forwarded asks only: when set, choosing the "for this session" option opens
82
+ * a second select asking whether the grant applies to the requesting subagent
83
+ * only (the least-privilege default) or the whole serving session.
84
+ */
85
+ sessionScope?: {
86
+ subagentLabel: string;
87
+ servingSessionLabel: string;
88
+ };
78
89
  }
79
90
 
80
91
  export async function requestPermissionDecisionFromUi(
@@ -103,6 +114,21 @@ export async function requestPermissionDecisionFromUi(
103
114
  }
104
115
 
105
116
  if (selected === sessionOption) {
117
+ if (options?.sessionScope) {
118
+ const scope = await ui.select(`${title}\nApply this session grant to:`, [
119
+ options.sessionScope.subagentLabel,
120
+ options.sessionScope.servingSessionLabel,
121
+ ]);
122
+ return {
123
+ approved: true,
124
+ // A cancelled scope select (undefined) falls back to the
125
+ // least-privilege subagent scope.
126
+ state:
127
+ scope === options.sessionScope.servingSessionLabel
128
+ ? "approved_for_serving_session"
129
+ : "approved_for_session",
130
+ };
131
+ }
106
132
  return {
107
133
  approved: true,
108
134
  state: "approved_for_session",
@@ -53,6 +53,20 @@ export interface ForwardedPromptDisplay {
53
53
  value: string | null;
54
54
  }
55
55
 
56
+ /**
57
+ * The child's session-approval suggestion, relayed to the serving node so a
58
+ * human who grants "the whole session" records the same pattern the child
59
+ * would have recorded locally.
60
+ *
61
+ * A plain data shape (not the `SessionApproval` value object) so it serializes
62
+ * onto the forwarded request; the serving node rebuilds a `SessionApproval`
63
+ * from it via `SessionApproval.multiple`.
64
+ */
65
+ export interface ForwardedSessionApproval {
66
+ surface: string;
67
+ patterns: readonly string[];
68
+ }
69
+
56
70
  export type ForwardedPermissionRequest = {
57
71
  id: string;
58
72
  createdAt: number;
@@ -69,6 +83,13 @@ export type ForwardedPermissionRequest = {
69
83
  source?: PermissionUiPromptSource;
70
84
  surface?: string | null;
71
85
  value?: string | null;
86
+ /**
87
+ * The child's session-approval suggestion. Present when the child computed a
88
+ * "for this session" pattern for the ask; lets the serving node record a
89
+ * whole-session grant. Optional for version-skew tolerance (an older child
90
+ * omits it, and the serving dialog then offers no scope choice).
91
+ */
92
+ sessionApproval?: ForwardedSessionApproval;
72
93
  };
73
94
 
74
95
  export type ForwardedPermissionResponse = {
@@ -1,3 +1,5 @@
1
+ import type { ForwardedSessionApproval } from "./permission-forwarding";
2
+
1
3
  /**
2
4
  * Value object for a session-scoped approval: one surface, one-or-more patterns.
3
5
  *
@@ -40,4 +42,13 @@ export class SessionApproval {
40
42
  if (pattern === undefined) return undefined;
41
43
  return { surface: this.surface, pattern };
42
44
  }
45
+
46
+ /**
47
+ * Plain data shape for relaying this approval on a forwarded request, so the
48
+ * serving node can record the same pattern(s) as a whole-session grant.
49
+ * Returns a defensive copy of the patterns.
50
+ */
51
+ toForwardedData(): ForwardedSessionApproval {
52
+ return { surface: this.surface, patterns: [...this.patterns] };
53
+ }
43
54
  }