@gotgenes/pi-permission-system 18.2.0 → 19.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.
@@ -0,0 +1,285 @@
1
+ import { join } from "node:path";
2
+ import {
3
+ type ForwarderContext,
4
+ getSessionId,
5
+ } from "#src/authority/forwarder-context";
6
+ import type { ConfigReader } from "#src/config-store";
7
+ import { isYoloModeEnabled } from "#src/extension-config";
8
+ import type {
9
+ PermissionDecisionUi,
10
+ PermissionPromptDecision,
11
+ RequestPermissionOptions,
12
+ } from "#src/permission-dialog";
13
+ import {
14
+ emitUiPromptEvent,
15
+ type PermissionEventBus,
16
+ } from "#src/permission-events";
17
+ import {
18
+ type ForwardedPermissionRequest,
19
+ type ForwardedPermissionResponse,
20
+ isForwardedPermissionRequestForSession,
21
+ type PermissionForwardingLocation,
22
+ } from "#src/permission-forwarding";
23
+ import { buildForwardedUiPrompt } from "#src/permission-ui-prompt";
24
+ import type { DebugReviewLogger } from "#src/session-logger";
25
+
26
+ import {
27
+ cleanupPermissionForwardingLocationIfEmpty,
28
+ ensureDirectoryExists,
29
+ getExistingPermissionForwardingLocation,
30
+ listRequestFiles,
31
+ logPermissionForwardingError,
32
+ logPermissionForwardingWarning,
33
+ readForwardedPermissionRequest,
34
+ safeDeleteFile,
35
+ writeJsonFileAtomic,
36
+ } from "./forwarding-io";
37
+
38
+ /**
39
+ * Narrow seam describing what `ForwardingManager` needs from the server: a
40
+ * single method that drains this session's forwarded-permission inbox.
41
+ *
42
+ * Depending on the interface (not the concrete `ForwardedRequestServer`)
43
+ * keeps the manager's unit tests free of casts — they inject a plain
44
+ * `{ processInbox: vi.fn() }` mock.
45
+ */
46
+ export interface InboxProcessor {
47
+ processInbox(ctx: ForwarderContext): Promise<void>;
48
+ }
49
+
50
+ /** Constructor config for `ForwardedRequestServer`. */
51
+ export interface ForwardedRequestServerDeps {
52
+ forwardingDir: string;
53
+ logger: DebugReviewLogger;
54
+ /** Event bus used for UI prompt broadcasts. */
55
+ events?: PermissionEventBus;
56
+ requestPermissionDecisionFromUi: (
57
+ ui: PermissionDecisionUi,
58
+ title: string,
59
+ message: string,
60
+ options?: RequestPermissionOptions,
61
+ ) => Promise<PermissionPromptDecision>;
62
+ /** Read current config for the retained forwarded-inbox yolo auto-approve check. */
63
+ config: ConfigReader;
64
+ }
65
+
66
+ // ── Module-private helpers ────────────────────────────────────────────────
67
+
68
+ function formatForwardedPermissionPrompt(
69
+ request: ForwardedPermissionRequest,
70
+ ): string {
71
+ const agentName = request.requesterAgentName || "unknown";
72
+ const sessionId = request.requesterSessionId || "unknown";
73
+ return [
74
+ `Subagent '${agentName}' requested permission.`,
75
+ `Session ID: ${sessionId}`,
76
+ "",
77
+ request.message,
78
+ ].join("\n");
79
+ }
80
+
81
+ // ── ForwardedRequestServer ────────────────────────────────────────────────
82
+
83
+ /**
84
+ * Owner of the serving-down role of the forwarded-permission behavior:
85
+ * draining this session's forwarded-permission inbox and answering each
86
+ * request (auto-approve under yolo, or prompt via the injected UI).
87
+ */
88
+ export class ForwardedRequestServer implements InboxProcessor {
89
+ private readonly forwardingDir: string;
90
+ private readonly events: PermissionEventBus | undefined;
91
+ private readonly logger: DebugReviewLogger;
92
+ private readonly requestPermissionDecisionFromUi: (
93
+ ui: PermissionDecisionUi,
94
+ title: string,
95
+ message: string,
96
+ options?: RequestPermissionOptions,
97
+ ) => Promise<PermissionPromptDecision>;
98
+ private readonly config: ConfigReader;
99
+
100
+ constructor(deps: ForwardedRequestServerDeps) {
101
+ this.forwardingDir = deps.forwardingDir;
102
+ this.events = deps.events;
103
+ this.logger = deps.logger;
104
+ this.requestPermissionDecisionFromUi = deps.requestPermissionDecisionFromUi;
105
+ this.config = deps.config;
106
+ }
107
+
108
+ /** Drain and respond to this session's forwarded-permission inbox. */
109
+ async processInbox(ctx: ForwarderContext): Promise<void> {
110
+ if (!ctx.hasUI) {
111
+ return;
112
+ }
113
+
114
+ const currentSessionId = getSessionId(ctx);
115
+ const location = getExistingPermissionForwardingLocation(
116
+ this.forwardingDir,
117
+ currentSessionId,
118
+ );
119
+ if (!location) {
120
+ return;
121
+ }
122
+
123
+ const requestFiles = listRequestFiles(this.logger, location.requestsDir);
124
+ if (requestFiles.length === 0) {
125
+ return;
126
+ }
127
+
128
+ // Defensively recreate responses/ before writing any response — a
129
+ // concurrent cleanup pass may have removed it between the requestsDir
130
+ // existence check above and the write inside processSingleForwardedRequest
131
+ // (the ENOENT write loop reported in issue #398).
132
+ if (
133
+ !ensureDirectoryExists(
134
+ this.logger,
135
+ location.responsesDir,
136
+ "permission forwarding responses",
137
+ )
138
+ ) {
139
+ return;
140
+ }
141
+
142
+ for (const fileName of requestFiles) {
143
+ const requestPath = join(location.requestsDir, fileName);
144
+ const request = readForwardedPermissionRequest(this.logger, requestPath);
145
+ if (!request) {
146
+ safeDeleteFile(
147
+ this.logger,
148
+ requestPath,
149
+ `${location.label} forwarded permission request`,
150
+ );
151
+ continue;
152
+ }
153
+
154
+ await this.processSingleForwardedRequest(
155
+ ctx,
156
+ request,
157
+ location,
158
+ requestPath,
159
+ currentSessionId,
160
+ );
161
+ }
162
+
163
+ cleanupPermissionForwardingLocationIfEmpty(this.logger, location);
164
+ }
165
+
166
+ // ── Private methods ────────────────────────────────────────────────────
167
+
168
+ private async processSingleForwardedRequest(
169
+ ctx: ForwarderContext,
170
+ request: ForwardedPermissionRequest,
171
+ location: PermissionForwardingLocation,
172
+ requestPath: string,
173
+ currentSessionId: string,
174
+ ): Promise<void> {
175
+ if (!isForwardedPermissionRequestForSession(request, currentSessionId)) {
176
+ logPermissionForwardingWarning(
177
+ this.logger,
178
+ `Ignoring forwarded permission request '${request.id}' because it targets session '${request.targetSessionId}' instead of '${currentSessionId}'`,
179
+ );
180
+ safeDeleteFile(
181
+ this.logger,
182
+ requestPath,
183
+ `${location.label} forwarded permission request`,
184
+ );
185
+ return;
186
+ }
187
+
188
+ const forwardedPermissionLogDetails = {
189
+ requestId: request.id,
190
+ source: location.label,
191
+ requesterAgentName: request.requesterAgentName,
192
+ requesterSessionId: request.requesterSessionId,
193
+ targetSessionId: request.targetSessionId,
194
+ requestPath,
195
+ };
196
+
197
+ let decision: PermissionPromptDecision = {
198
+ approved: false,
199
+ state: "denied",
200
+ };
201
+ // Last yolo check outside the composed ruleset: dissolves when
202
+ // processInbox is refactored onto evaluate() + Authorizer selection in
203
+ // the Phase 9 spine work.
204
+ if (isYoloModeEnabled(this.config.current())) {
205
+ this.logger.review(
206
+ "forwarded_permission.auto_approved",
207
+ forwardedPermissionLogDetails,
208
+ );
209
+ decision = { approved: true, state: "approved" };
210
+ } else {
211
+ this.logger.review(
212
+ "forwarded_permission.prompted",
213
+ forwardedPermissionLogDetails,
214
+ );
215
+ try {
216
+ const forwardedMessage = formatForwardedPermissionPrompt(request);
217
+ if (this.events) {
218
+ emitUiPromptEvent(
219
+ this.events,
220
+ buildForwardedUiPrompt({
221
+ requestId: request.id,
222
+ message: forwardedMessage,
223
+ requesterAgentName: request.requesterAgentName || null,
224
+ requesterSessionId: request.requesterSessionId || null,
225
+ source: request.source ?? null,
226
+ surface: request.surface ?? null,
227
+ value: request.value ?? null,
228
+ }),
229
+ );
230
+ }
231
+ decision = await this.requestPermissionDecisionFromUi(
232
+ ctx.ui,
233
+ "Permission Required (Subagent)",
234
+ forwardedMessage,
235
+ );
236
+ } catch (error) {
237
+ logPermissionForwardingError(
238
+ this.logger,
239
+ "Failed to show forwarded permission confirmation dialog",
240
+ error,
241
+ );
242
+ decision = { approved: false, state: "denied" };
243
+ }
244
+ }
245
+
246
+ const responsePath = join(location.responsesDir, `${request.id}.json`);
247
+ this.logger.review(
248
+ decision.approved
249
+ ? "forwarded_permission.approved"
250
+ : "forwarded_permission.denied",
251
+ {
252
+ requestId: request.id,
253
+ source: location.label,
254
+ requesterAgentName: request.requesterAgentName,
255
+ requesterSessionId: request.requesterSessionId,
256
+ targetSessionId: request.targetSessionId,
257
+ responsePath,
258
+ resolution: decision.state,
259
+ denialReason: decision.denialReason ?? null,
260
+ },
261
+ );
262
+ try {
263
+ writeJsonFileAtomic(this.logger, responsePath, {
264
+ approved: decision.approved,
265
+ state: decision.state,
266
+ denialReason: decision.denialReason,
267
+ responderSessionId: currentSessionId,
268
+ respondedAt: Date.now(),
269
+ } satisfies ForwardedPermissionResponse);
270
+ } catch (error) {
271
+ logPermissionForwardingError(
272
+ this.logger,
273
+ `Failed to write ${location.label} forwarded permission response '${responsePath}'`,
274
+ error,
275
+ );
276
+ return;
277
+ }
278
+
279
+ safeDeleteFile(
280
+ this.logger,
281
+ requestPath,
282
+ `${location.label} forwarded permission request`,
283
+ );
284
+ }
285
+ }
@@ -0,0 +1,32 @@
1
+ import type { SessionEntryView } from "#src/active-agent";
2
+ import type { PermissionDecisionUi } from "#src/permission-dialog";
3
+
4
+ /**
5
+ * Narrow context the forwarding subsystem reads: the UI gate (`hasUI`), the
6
+ * dialog UI surface, and the three session-manager readers `getSessionId`
7
+ * and the `active-agent` helpers use.
8
+ *
9
+ * A full `ExtensionContext` satisfies this structurally, so production
10
+ * callers pass `ctx` unchanged.
11
+ */
12
+ export interface ForwarderContext {
13
+ hasUI: boolean;
14
+ ui: PermissionDecisionUi;
15
+ sessionManager: {
16
+ getSessionId(): string;
17
+ getSessionDir(): string;
18
+ getEntries(): readonly SessionEntryView[];
19
+ };
20
+ }
21
+
22
+ /** Reads the current session id off `ctx`, falling back to `"unknown"`. */
23
+ export function getSessionId(ctx: ForwarderContext): string {
24
+ try {
25
+ const sessionId = ctx.sessionManager.getSessionId();
26
+ if (typeof sessionId === "string" && sessionId.trim()) {
27
+ return sessionId.trim();
28
+ }
29
+ } catch {}
30
+
31
+ return "unknown";
32
+ }
@@ -1,7 +1,7 @@
1
1
  import { posix as posixPath, win32 as winPath } from "node:path";
2
2
 
3
- import { SUBAGENT_ENV_HINT_KEYS } from "./permission-forwarding";
4
- import type { SubagentSessionRegistry } from "./subagent-registry";
3
+ import { SUBAGENT_ENV_HINT_KEYS } from "#src/permission-forwarding";
4
+ import type { SubagentSessionRegistry } from "#src/subagent-registry";
5
5
 
6
6
  /**
7
7
  * Narrow context for subagent detection — the only session-manager readers
@@ -0,0 +1,65 @@
1
+ import {
2
+ isRegisteredSubagentChild,
3
+ isSubagentExecutionContext,
4
+ type SubagentDetectionContext,
5
+ } from "#src/authority/subagent-context";
6
+ import type { SubagentSessionRegistry } from "#src/subagent-registry";
7
+
8
+ /**
9
+ * Narrow seam for the ask-path consumers: "is the current session a subagent?"
10
+ *
11
+ * `PromptingGateway`, `ForwardingManager`, and `ApprovalEscalator` depend on
12
+ * this single-method view so their unit tests inject a one-field fake without
13
+ * casts. It is the Authorizer-selection predicate the Phase 9 spine consumes.
14
+ */
15
+ export interface SubagentDetector {
16
+ isSubagent(ctx: SubagentDetectionContext): boolean;
17
+ }
18
+
19
+ /**
20
+ * Narrow seam for the service-publication guard (#302): "is the current
21
+ * session a registered in-process child?"
22
+ *
23
+ * `PermissionServiceLifecycle` depends on this single-method view so a
24
+ * registered child never publishes over its parent's process-global slot.
25
+ */
26
+ export interface RegisteredChildDetector {
27
+ isRegisteredChild(ctx: SubagentDetectionContext): boolean;
28
+ }
29
+
30
+ /** Composition-root inputs for {@link SubagentDetection}. */
31
+ export interface SubagentDetectionDeps {
32
+ subagentSessionsDir: string;
33
+ platform: NodeJS.Platform;
34
+ registry?: SubagentSessionRegistry;
35
+ }
36
+
37
+ /**
38
+ * Single owner of subagent detection.
39
+ *
40
+ * Constructed once in the composition root with the detection inputs
41
+ * (`subagentSessionsDir`, `platform`, `registry`) and shared across every
42
+ * consumer, replacing the dep triple those consumers previously threaded
43
+ * individually. Delegates to the pure detection functions in
44
+ * {@link ./subagent-context}, holding only the deps.
45
+ */
46
+ export class SubagentDetection
47
+ implements SubagentDetector, RegisteredChildDetector
48
+ {
49
+ constructor(private readonly deps: SubagentDetectionDeps) {}
50
+
51
+ isSubagent(ctx: SubagentDetectionContext): boolean {
52
+ return isSubagentExecutionContext(
53
+ ctx,
54
+ this.deps.subagentSessionsDir,
55
+ this.deps.platform,
56
+ this.deps.registry,
57
+ );
58
+ }
59
+
60
+ isRegisteredChild(ctx: SubagentDetectionContext): boolean {
61
+ return this.deps.registry
62
+ ? isRegisteredSubagentChild(ctx, this.deps.registry)
63
+ : false;
64
+ }
65
+ }
@@ -1,5 +1,6 @@
1
1
  import { existsSync, readFileSync } from "node:fs";
2
2
  import { normalize } from "node:path";
3
+ import type { ZodError } from "zod";
3
4
  import {
4
5
  getGlobalConfigPath,
5
6
  getLegacyExtensionConfigPath,
@@ -7,32 +8,19 @@ import {
7
8
  getLegacyProjectPolicyPath,
8
9
  getProjectConfigPath,
9
10
  } from "./config-paths";
11
+ import {
12
+ type UnifiedPermissionConfig,
13
+ unifiedConfigSchema,
14
+ } from "./config-schema";
10
15
  import { mergeFlatPermissions } from "./permission-merge";
11
16
  import type { FlatPermissionConfig, PatternValue } from "./types";
12
- import {
13
- isDenyWithReason,
14
- isPermissionState,
15
- normalizeOptionalPositiveInt,
16
- normalizeOptionalStringArray,
17
- toRecord,
18
- } from "./value-guards";
17
+ import { isDenyWithReason, isPermissionState } from "./value-guards";
19
18
 
20
- /**
21
- * Unified config shape combining runtime knobs and flat permission policy.
22
- * All fields are optional so partial configs (project-only, global-only) work.
23
- */
24
- export interface UnifiedPermissionConfig {
25
- // Runtime knobs
26
- debugLog?: boolean;
27
- permissionReviewLog?: boolean;
28
- yoloMode?: boolean;
29
- toolInputPreviewMaxLength?: number;
30
- toolTextSummaryMaxLength?: number;
31
- piInfrastructureReadPaths?: string[];
32
-
33
- // Flat permission policy
34
- permission?: FlatPermissionConfig;
35
- }
19
+ // The unified config shape is derived from the zod schema (config-schema.ts,
20
+ // the single source of truth) and re-exported so existing importers keep their
21
+ // import path. All fields are optional so partial configs merge before
22
+ // defaults are applied downstream.
23
+ export type { UnifiedPermissionConfig };
36
24
 
37
25
  export interface UnifiedConfigLoadResult {
38
26
  config: UnifiedPermissionConfig;
@@ -118,20 +106,13 @@ function consumeString(input: string, start: number): ScanSegment {
118
106
  return { output, nextIndex: i };
119
107
  }
120
108
 
121
- function normalizeOptionalBoolean(value: unknown): boolean | undefined {
122
- if (typeof value === "boolean") {
123
- return value;
124
- }
125
- return undefined;
126
- }
127
-
128
109
  /**
129
110
  * Normalize a raw `permission` value from parsed JSON into a FlatPermissionConfig.
130
111
  * Accepts PermissionState strings and DenyWithReason objects inside pattern
131
112
  * maps. Drops non-object top-level values, invalid PermissionState strings, and
132
113
  * invalid action values inside object maps.
133
114
  */
134
- function normalizeFlatPermissionValue(
115
+ export function normalizeFlatPermissionValue(
135
116
  value: unknown,
136
117
  ): FlatPermissionConfig | undefined {
137
118
  if (!value || typeof value !== "object" || Array.isArray(value)) {
@@ -172,52 +153,39 @@ function normalizeFlatPermissionValue(
172
153
  }
173
154
 
174
155
  /**
175
- * Normalize raw parsed JSON into the unified config shape.
156
+ * Validate raw parsed JSON against the config schema (the single source of
157
+ * truth in `config-schema.ts`).
158
+ *
159
+ * On success the typed config is returned. On failure the whole scope config is
160
+ * rejected — fail-closed: an empty config contributes no rules, so missing
161
+ * surfaces fall through to the universal `ask` default rather than `allow` —
162
+ * and every schema violation is reported as a clear, actionable issue.
176
163
  */
177
- export function normalizeUnifiedConfig(raw: unknown): {
178
- config: UnifiedPermissionConfig;
179
- issues: string[];
180
- } {
181
- const record = toRecord(raw);
182
- const issues: string[] = [];
183
- const config: UnifiedPermissionConfig = {};
184
-
185
- // Runtime knobs
186
- const debugLog = normalizeOptionalBoolean(record.debugLog);
187
- if (debugLog !== undefined) config.debugLog = debugLog;
188
-
189
- const permissionReviewLog = normalizeOptionalBoolean(
190
- record.permissionReviewLog,
191
- );
192
- if (permissionReviewLog !== undefined)
193
- config.permissionReviewLog = permissionReviewLog;
194
-
195
- const yoloMode = normalizeOptionalBoolean(record.yoloMode);
196
- if (yoloMode !== undefined) config.yoloMode = yoloMode;
197
-
198
- const toolInputPreviewMaxLength = normalizeOptionalPositiveInt(
199
- record.toolInputPreviewMaxLength,
200
- );
201
- if (toolInputPreviewMaxLength !== undefined)
202
- config.toolInputPreviewMaxLength = toolInputPreviewMaxLength;
203
-
204
- const toolTextSummaryMaxLength = normalizeOptionalPositiveInt(
205
- record.toolTextSummaryMaxLength,
206
- );
207
- if (toolTextSummaryMaxLength !== undefined)
208
- config.toolTextSummaryMaxLength = toolTextSummaryMaxLength;
209
-
210
- const piInfrastructureReadPaths = normalizeOptionalStringArray(
211
- record.piInfrastructureReadPaths,
212
- );
213
- if (piInfrastructureReadPaths !== undefined)
214
- config.piInfrastructureReadPaths = piInfrastructureReadPaths;
215
-
216
- // Flat permission policy
217
- const permission = normalizeFlatPermissionValue(record.permission);
218
- if (permission !== undefined) config.permission = permission;
164
+ export function validateUnifiedConfig(
165
+ parsed: unknown,
166
+ ): UnifiedConfigLoadResult {
167
+ const result = unifiedConfigSchema.safeParse(parsed);
168
+ if (result.success) {
169
+ return { config: result.data, issues: [] };
170
+ }
171
+ return { config: {}, issues: formatConfigIssues(result.error) };
172
+ }
219
173
 
220
- return { config, issues };
174
+ /** Render each schema violation as a clear, path-qualified message. */
175
+ function formatConfigIssues(error: ZodError): string[] {
176
+ const messages: string[] = [];
177
+ for (const issue of error.issues) {
178
+ if (issue.code === "unrecognized_keys") {
179
+ for (const key of issue.keys) {
180
+ messages.push(`Unrecognized config key '${key}'.`);
181
+ }
182
+ continue;
183
+ }
184
+ const location =
185
+ issue.path.length > 0 ? issue.path.map(String).join(".") : "(root)";
186
+ messages.push(`Invalid config value at '${location}': ${issue.message}`);
187
+ }
188
+ return messages;
221
189
  }
222
190
 
223
191
  /**
@@ -320,7 +288,8 @@ export function loadAndMergeConfigs(
320
288
  `Move it to '${newGlobalPath}':\n` +
321
289
  ` mv '${legacyGlobalPolicyPath}' '${newGlobalPath}'`,
322
290
  );
323
- allIssues.push(...legacy.issues);
291
+ // Legacy files are migrated away; the move-it guidance above is the
292
+ // actionable signal, so strict-validation issues for them are suppressed.
324
293
  merged = mergeUnifiedConfigs(merged, legacy.config);
325
294
  }
326
295
 
@@ -337,7 +306,7 @@ export function loadAndMergeConfigs(
337
306
  `Move runtime settings to '${newGlobalPath}':\n` +
338
307
  ` mv '${legacyExtConfigPath}' '${newGlobalPath}'`,
339
308
  );
340
- allIssues.push(...legacy.issues);
309
+ // See above: legacy-file validation issues are suppressed.
341
310
  merged = mergeUnifiedConfigs(merged, legacy.config);
342
311
  }
343
312
 
@@ -355,7 +324,7 @@ export function loadAndMergeConfigs(
355
324
  `Move it to '${newProjectPath}':\n` +
356
325
  ` mv '${legacyProjectPolicyPath}' '${newProjectPath}'`,
357
326
  );
358
- allIssues.push(...legacy.issues);
327
+ // See above: legacy-file validation issues are suppressed.
359
328
  merged = mergeUnifiedConfigs(merged, legacy.config);
360
329
  }
361
330
 
@@ -421,7 +390,7 @@ export function loadUnifiedConfig(path: string): UnifiedConfigLoadResult {
421
390
  try {
422
391
  const raw = readFileSync(path, "utf-8");
423
392
  const parsed = JSON.parse(stripJsonComments(raw)) as unknown;
424
- return normalizeUnifiedConfig(parsed);
393
+ return validateUnifiedConfig(parsed);
425
394
  } catch (error) {
426
395
  const message = error instanceof Error ? error.message : String(error);
427
396
  return {