@gotgenes/pi-permission-system 10.3.0 → 10.4.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.
Files changed (35) hide show
  1. package/CHANGELOG.md +19 -0
  2. package/package.json +1 -1
  3. package/src/config-modal.ts +10 -8
  4. package/src/config-store.ts +13 -34
  5. package/src/forwarded-permissions/io.ts +16 -22
  6. package/src/forwarded-permissions/permission-forwarder.ts +16 -19
  7. package/src/gate-prompter.ts +1 -3
  8. package/src/handlers/gates/runner.ts +1 -1
  9. package/src/index.ts +68 -51
  10. package/src/permission-event-rpc.ts +19 -15
  11. package/src/permission-prompter.ts +4 -3
  12. package/src/permission-session.ts +10 -67
  13. package/src/permissions-service.ts +3 -5
  14. package/src/prompting-gateway.ts +104 -0
  15. package/src/session-logger.ts +63 -12
  16. package/test/composition-root.test.ts +85 -1
  17. package/test/config-modal.test.ts +13 -7
  18. package/test/config-store.test.ts +23 -49
  19. package/test/forwarded-permissions/io.test.ts +23 -26
  20. package/test/handlers/external-directory-integration.test.ts +45 -32
  21. package/test/handlers/external-directory-session-dedup.test.ts +36 -46
  22. package/test/handlers/gates/runner.test.ts +10 -16
  23. package/test/handlers/input-events.test.ts +19 -4
  24. package/test/handlers/input.test.ts +29 -13
  25. package/test/handlers/tool-call-events.test.ts +23 -5
  26. package/test/helpers/gate-fixtures.ts +6 -6
  27. package/test/helpers/handler-fixtures.ts +24 -39
  28. package/test/permission-event-rpc.test.ts +30 -28
  29. package/test/permission-forwarder.test.ts +6 -5
  30. package/test/permission-prompter.test.ts +28 -28
  31. package/test/permission-session.test.ts +40 -112
  32. package/test/prompting-gateway.test.ts +230 -0
  33. package/test/session-logger.test.ts +151 -64
  34. package/src/runtime.ts +0 -147
  35. package/test/runtime.test.ts +0 -303
@@ -28,19 +28,20 @@ import {
28
28
  } from "./permission-events";
29
29
  import type { PermissionManager } from "./permission-manager";
30
30
  import { buildRpcUiPrompt } from "./permission-ui-prompt";
31
- import type { Rule } from "./rule";
31
+ import type { ReviewLogger } from "./session-logger";
32
+ import type { SessionRules } from "./session-rules";
32
33
 
33
34
  /** Dependencies injected into the RPC handler registry. */
34
35
  export interface PermissionRpcDeps {
35
- /** Returns the current PermissionManager (refreshed on session start). */
36
- getPermissionManager(): Pick<PermissionManager, "checkPermission">;
37
- /** Returns the current session rules (highest-priority approvals). */
38
- getSessionRules(): Rule[];
36
+ /** The shared PermissionManager instance. */
37
+ permissionManager: Pick<PermissionManager, "checkPermission">;
38
+ /** The shared SessionRules instance. */
39
+ sessionRules: Pick<SessionRules, "getRuleset">;
39
40
  /**
40
- * Returns the current ExtensionContext, or null if no session is active.
41
+ * Narrow session view: provides runtime context.
41
42
  * Used by the prompt handler to check hasUI and access the UI dialog.
42
43
  */
43
- getRuntimeContext(): ExtensionContext | null;
44
+ session: { getRuntimeContext(): ExtensionContext | null };
44
45
  /** Show the interactive permission dialog in the parent session UI. */
45
46
  requestPermissionDecisionFromUi(
46
47
  ui: ExtensionContext["ui"],
@@ -48,8 +49,8 @@ export interface PermissionRpcDeps {
48
49
  message: string,
49
50
  options?: RequestPermissionOptions,
50
51
  ): Promise<PermissionPromptDecision>;
51
- /** Write structured entries to the permission review log. */
52
- writeReviewLog(event: string, details: Record<string, unknown>): void;
52
+ /** Write review-log entries for prompted decisions. */
53
+ logger: ReviewLogger;
53
54
  }
54
55
 
55
56
  /** Unsubscribe handles returned from registerPermissionRpcHandlers. */
@@ -107,10 +108,13 @@ function handleCheckRpc(
107
108
  }
108
109
 
109
110
  const input = buildInputForSurface(surface, value);
110
- const sessionRules = deps.getSessionRules();
111
- const result = deps
112
- .getPermissionManager()
113
- .checkPermission(surface, input, agentName ?? undefined, sessionRules);
111
+ const sessionRules = deps.sessionRules.getRuleset();
112
+ const result = deps.permissionManager.checkPermission(
113
+ surface,
114
+ input,
115
+ agentName ?? undefined,
116
+ sessionRules,
117
+ );
114
118
 
115
119
  const data: PermissionsCheckReplyData = {
116
120
  result: result.state,
@@ -141,7 +145,7 @@ async function handlePromptRpc(
141
145
 
142
146
  const replyChannel = `${PERMISSIONS_RPC_PROMPT_CHANNEL}:reply:${requestId}`;
143
147
 
144
- const ctx = deps.getRuntimeContext();
148
+ const ctx = deps.session.getRuntimeContext();
145
149
  if (!ctx?.hasUI) {
146
150
  events.emit(replyChannel, errorReply("no_ui"));
147
151
  return;
@@ -169,7 +173,7 @@ async function handlePromptRpc(
169
173
  sessionLabel ? { sessionLabel } : undefined,
170
174
  );
171
175
 
172
- deps.writeReviewLog("permission_request.rpc_prompt", {
176
+ deps.logger.review("permission_request.rpc_prompt", {
173
177
  requestId,
174
178
  surface: surface ?? null,
175
179
  value: value ?? null,
@@ -7,6 +7,7 @@ import {
7
7
  type PermissionEventBus,
8
8
  } from "./permission-events";
9
9
  import { buildDirectUiPrompt } from "./permission-ui-prompt";
10
+ import type { ReviewLogger } from "./session-logger";
10
11
  import { shouldAutoApprovePermissionState } from "./yolo-mode";
11
12
 
12
13
  export type PermissionReviewSource = "tool_call" | "skill_input" | "skill_read";
@@ -40,14 +41,14 @@ export interface PermissionPrompterApi {
40
41
  * Dependencies required by PermissionPrompter.
41
42
  *
42
43
  * Keeps the prompter's external surface narrow: callers provide config
43
- * access, review-log writing, the UI-prompt event bus, and the forwarder
44
+ * access, a review logger, the UI-prompt event bus, and the forwarder
44
45
  * that owns the UI/subagent-forwarding branching logic.
45
46
  */
46
47
  export interface PermissionPrompterDeps {
47
48
  /** Read current config for yolo-mode check (called at prompt time). */
48
49
  config: ConfigReader;
49
50
  /** Write structured entries to the permission review log. */
50
- writeReviewLog(event: string, details: Record<string, unknown>): void;
51
+ logger: ReviewLogger;
51
52
  /** Event bus used for UI prompt broadcasts. */
52
53
  events: PermissionEventBus;
53
54
  /** Resolves the permission decision: direct UI dialog or forwarded to parent. */
@@ -122,7 +123,7 @@ export class PermissionPrompter implements PermissionPrompterApi {
122
123
  denialReason?: string;
123
124
  },
124
125
  ): void {
125
- this.deps.writeReviewLog(event, {
126
+ this.deps.logger.review(event, {
126
127
  requestId: details.requestId,
127
128
  source: details.source,
128
129
  agentName: details.agentName,
@@ -10,17 +10,15 @@ import type { PermissionSystemExtensionConfig } from "./extension-config";
10
10
  import type { ExtensionPaths } from "./extension-paths";
11
11
  import type { ForwardingController } from "./forwarding-manager";
12
12
  import type { GateHandlerSession } from "./gate-handler-session";
13
- import type { GatePrompter } from "./gate-prompter";
14
- import type { PermissionPromptDecision } from "./permission-dialog";
15
13
  import type { ScopedPermissionManager } from "./permission-manager";
16
- import type { PromptPermissionDetails } from "./permission-prompter";
17
14
  import type { PermissionResolver } from "./permission-resolver";
15
+ import type { PromptingGatewayLifecycle } from "./prompting-gateway";
18
16
  import type { Rule } from "./rule";
19
17
  import type { SessionApproval } from "./session-approval";
20
18
  import type { SessionApprovalRecorder } from "./session-approval-recorder";
21
19
  import type { SessionLifecycleSession } from "./session-lifecycle-session";
22
20
  import type { SessionLogger } from "./session-logger";
23
- import { SessionRules } from "./session-rules";
21
+ import type { SessionRules } from "./session-rules";
24
22
  import type { SkillPromptEntry } from "./skill-prompt-sanitizer";
25
23
  import {
26
24
  resolveToolPreviewLimits,
@@ -28,22 +26,6 @@ import {
28
26
  } from "./tool-preview-formatter";
29
27
  import type { PermissionCheckResult, PermissionState } from "./types";
30
28
 
31
- /**
32
- * Runtime operations that `PermissionSession` delegates to but does not own.
33
- *
34
- * Injected at construction time from the composition root (`index.ts`),
35
- * where the `ExtensionRuntime` is available.
36
- */
37
- export interface PermissionSessionRuntimeDeps {
38
- /** Whether the current context can show an interactive permission prompt. */
39
- canRequestPermissionConfirmation(ctx: ExtensionContext): boolean;
40
- /** Prompt the user for a permission decision, log the outcome, and return it. */
41
- promptPermission(
42
- ctx: ExtensionContext,
43
- details: PromptPermissionDetails,
44
- ): Promise<PermissionPromptDecision>;
45
- }
46
-
47
29
  /**
48
30
  * Encapsulates all mutable session state and exposes operations instead of
49
31
  * fields.
@@ -57,19 +39,17 @@ export interface PermissionSessionRuntimeDeps {
57
39
  * - `SessionLogger` — debug + review + warn
58
40
  * - `ForwardingController` — polling lifecycle
59
41
  * - `SessionConfigStore` — owns extension config; provides refresh, log, read
60
- * - `PermissionSessionRuntimeDeps` — prompting + permission-confirmation bridge
42
+ * - `PromptingGatewayLifecycle` — prompting lifecycle forwarded via activate/deactivate
61
43
  */
62
44
  export class PermissionSession
63
45
  implements
64
46
  PermissionResolver,
65
47
  SessionApprovalRecorder,
66
- GatePrompter,
67
48
  GateHandlerSession,
68
49
  AgentPrepSession,
69
50
  SessionLifecycleSession
70
51
  {
71
52
  private context: ExtensionContext | null = null;
72
- private readonly sessionRules = new SessionRules();
73
53
  private skillEntries: SkillPromptEntry[] = [];
74
54
  private knownAgentName: string | null = null;
75
55
  private toolsCacheKey: string | null = null;
@@ -80,22 +60,25 @@ export class PermissionSession
80
60
  readonly logger: SessionLogger,
81
61
  private readonly forwarding: ForwardingController,
82
62
  private readonly permissionManager: ScopedPermissionManager,
63
+ private readonly sessionRules: SessionRules,
83
64
  private readonly configStore: SessionConfigStore,
84
- private readonly runtimeDeps: PermissionSessionRuntimeDeps,
65
+ private readonly gateway: PromptingGatewayLifecycle,
85
66
  ) {}
86
67
 
87
68
  // ── Context lifecycle ──────────────────────────────────────────────────
88
69
 
89
- /** Store the current extension context and start forwarding. */
70
+ /** Store the current extension context, start forwarding, and activate the gateway. */
90
71
  activate(ctx: ExtensionContext): void {
91
72
  this.context = ctx;
92
73
  this.forwarding.start(ctx);
74
+ this.gateway.activate(ctx);
93
75
  }
94
76
 
95
- /** Clear the context and stop forwarding. */
77
+ /** Clear the context, stop forwarding, and deactivate the gateway. */
96
78
  deactivate(): void {
97
79
  this.context = null;
98
80
  this.forwarding.stop();
81
+ this.gateway.deactivate();
99
82
  }
100
83
 
101
84
  /** Return the current runtime context, or null if not activated. */
@@ -262,7 +245,7 @@ export class PermissionSession
262
245
 
263
246
  /** Write the resolved config path set to the review and debug logs. */
264
247
  logResolvedConfigPaths(): void {
265
- this.configStore.logResolvedPaths();
248
+ this.configStore.logResolvedPaths(this.context?.cwd);
266
249
  }
267
250
 
268
251
  /** Read current extension config. */
@@ -292,44 +275,4 @@ export class PermissionSession
292
275
  getToolPreviewLimits(): ToolPreviewFormatterOptions {
293
276
  return resolveToolPreviewLimits(this.config);
294
277
  }
295
-
296
- // ── Prompting ──────────────────────────────────────────────────────────
297
-
298
- /** Whether the current context can show an interactive permission prompt. */
299
- canPrompt(ctx: ExtensionContext): boolean {
300
- return this.runtimeDeps.canRequestPermissionConfirmation(ctx);
301
- }
302
-
303
- /** Prompt the user for a permission decision, log the outcome, and return it. */
304
- prompt(
305
- ctx: ExtensionContext,
306
- details: PromptPermissionDetails,
307
- ): Promise<PermissionPromptDecision> {
308
- return this.runtimeDeps.promptPermission(ctx, details);
309
- }
310
-
311
- /**
312
- * Whether an interactive confirmation is possible using the stored context.
313
- * Returns `false` when no context is active (before `activate` is called).
314
- * Implements {@link GatePrompter}.
315
- */
316
- canConfirm(): boolean {
317
- return this.context !== null && this.canPrompt(this.context);
318
- }
319
-
320
- /**
321
- * Prompt the user for a permission decision using the stored context.
322
- * Throws if no context is active — `canConfirm()` guards this in normal use.
323
- * Implements {@link GatePrompter}.
324
- */
325
- promptPermission(
326
- details: PromptPermissionDetails,
327
- ): Promise<PermissionPromptDecision> {
328
- if (this.context === null) {
329
- return Promise.reject(
330
- new Error("promptPermission called before the session was activated"),
331
- );
332
- }
333
- return this.prompt(this.context, details);
334
- }
335
278
  }
@@ -10,11 +10,9 @@ import type {
10
10
  /**
11
11
  * In-process implementation of the cross-extension {@link PermissionsService}.
12
12
  *
13
- * Constructed once in the composition root and backed by the runtime's
14
- * permission manager and session rules. Both injected instances are stable
15
- * for the lifetime of the factory `runtime.permissionManager` is never
16
- * reassigned on the runtime object (only `PermissionSession` reassigns its
17
- * own internal copy), and `runtime.sessionRules` is `readonly`.
13
+ * Constructed once in the composition root and backed by the single shared
14
+ * `PermissionManager` and `SessionRules` instances that `PermissionSession`
15
+ * also uses so service queries and gate-path approvals see the same state.
18
16
  */
19
17
  export class LocalPermissionsService implements PermissionsService {
20
18
  constructor(
@@ -0,0 +1,104 @@
1
+ import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
2
+
3
+ import type { ConfigReader } from "./config-store";
4
+ import type { GatePrompter } from "./gate-prompter";
5
+ import type { PermissionPromptDecision } from "./permission-dialog";
6
+ import type {
7
+ PermissionPrompterApi,
8
+ PromptPermissionDetails,
9
+ } from "./permission-prompter";
10
+ import { isSubagentExecutionContext } from "./subagent-context";
11
+ import type { SubagentSessionRegistry } from "./subagent-registry";
12
+ import { canResolveAskPermissionRequest } from "./yolo-mode";
13
+
14
+ /**
15
+ * Dependencies required by PromptingGateway.
16
+ *
17
+ * All four fields are actively consumed:
18
+ * - `config` + `subagentSessionsDir` + `registry` drive `canConfirm()`.
19
+ * - `prompter` is called by `prompt()`.
20
+ */
21
+ export interface PromptingGatewayDeps {
22
+ /** Read current config for the yolo-mode branch of the can-prompt policy. */
23
+ config: ConfigReader;
24
+ /** Static path used to detect a forwarding subagent context. */
25
+ subagentSessionsDir: string;
26
+ /** Process-global registry used to detect a registered child session. */
27
+ registry?: SubagentSessionRegistry;
28
+ /** Resolves the permission decision: direct UI dialog or forwarded to parent. */
29
+ prompter: PermissionPrompterApi;
30
+ }
31
+
32
+ /**
33
+ * The lifecycle slice of the gateway that PermissionSession drives.
34
+ *
35
+ * PermissionSession calls activate/deactivate to keep the gateway's stored
36
+ * context in sync with its own — the same pattern used for ForwardingController.
37
+ */
38
+ export interface PromptingGatewayLifecycle {
39
+ activate(ctx: ExtensionContext): void;
40
+ deactivate(): void;
41
+ }
42
+
43
+ /**
44
+ * Context-owning implementation of the GatePrompter role.
45
+ *
46
+ * Owns the stored ExtensionContext and the "can we prompt?" policy
47
+ * (UI / subagent / yolo-mode), replacing the four twin methods
48
+ * that previously lived on PermissionSession.
49
+ *
50
+ * Lifecycle: PermissionSession drives activate/deactivate so the stored
51
+ * context mirrors the session context without independent call-site changes.
52
+ */
53
+ export class PromptingGateway
54
+ implements GatePrompter, PromptingGatewayLifecycle
55
+ {
56
+ private context: ExtensionContext | null = null;
57
+
58
+ constructor(private readonly deps: PromptingGatewayDeps) {}
59
+
60
+ /** Store the current extension context. */
61
+ activate(ctx: ExtensionContext): void {
62
+ this.context = ctx;
63
+ }
64
+
65
+ /** Clear the stored context. */
66
+ deactivate(): void {
67
+ this.context = null;
68
+ }
69
+
70
+ /**
71
+ * Whether an interactive permission prompt can be shown.
72
+ *
73
+ * Returns false when no context is active. Otherwise delegates to
74
+ * canResolveAskPermissionRequest, which checks hasUI, subagent status,
75
+ * and yolo-mode — relocating the policy from the index.ts closure.
76
+ */
77
+ canConfirm(): boolean {
78
+ if (this.context === null) return false;
79
+ return canResolveAskPermissionRequest({
80
+ config: this.deps.config.current(),
81
+ hasUI: this.context.hasUI,
82
+ isSubagent: isSubagentExecutionContext(
83
+ this.context,
84
+ this.deps.subagentSessionsDir,
85
+ this.deps.registry,
86
+ ),
87
+ });
88
+ }
89
+
90
+ /**
91
+ * Prompt the user for a permission decision using the stored context.
92
+ *
93
+ * Rejects if no context is active — canConfirm() guards this in normal use.
94
+ * Implements {@link GatePrompter}.
95
+ */
96
+ prompt(details: PromptPermissionDetails): Promise<PermissionPromptDecision> {
97
+ if (this.context === null) {
98
+ return Promise.reject(
99
+ new Error("prompt called before the session was activated"),
100
+ );
101
+ }
102
+ return this.deps.prompter.prompt(this.context, details);
103
+ }
104
+ }
@@ -1,4 +1,26 @@
1
- import type { ExtensionRuntime } from "./runtime";
1
+ import { join } from "node:path";
2
+ import { DEBUG_LOG_FILENAME, REVIEW_LOG_FILENAME } from "./config-paths";
3
+ import {
4
+ ensurePermissionSystemLogsDirectory,
5
+ type PermissionSystemExtensionConfig,
6
+ } from "./extension-config";
7
+ import { createPermissionSystemLogger } from "./logging";
8
+
9
+ /**
10
+ * Narrowest logging seam — consumers that only write review-log entries.
11
+ * Injected into `PermissionPrompter` and the RPC handlers.
12
+ */
13
+ export interface ReviewLogger {
14
+ review(event: string, details?: Record<string, unknown>): void;
15
+ }
16
+
17
+ /**
18
+ * Logging seam for consumers that write both debug and review entries.
19
+ * Injected into `ConfigStore` and `PermissionForwarder`.
20
+ */
21
+ export interface DebugReviewLogger extends ReviewLogger {
22
+ debug(event: string, details?: Record<string, unknown>): void;
23
+ }
2
24
 
3
25
  /**
4
26
  * Unified logging + notification surface for handler deps.
@@ -7,23 +29,52 @@ import type { ExtensionRuntime } from "./runtime";
7
29
  * `writeReviewLog`, `notifyWarning`) with a single typed collaborator.
8
30
  * This is an intermediate abstraction on the path to PermissionSession (#129).
9
31
  */
10
- export interface SessionLogger {
11
- debug(event: string, details?: Record<string, unknown>): void;
12
- review(event: string, details?: Record<string, unknown>): void;
32
+ export interface SessionLogger extends DebugReviewLogger {
13
33
  warn(message: string): void;
14
34
  }
15
35
 
36
+ /** Narrow dependencies for constructing a {@link SessionLogger}. */
37
+ export interface SessionLoggerDeps {
38
+ /** Root logs directory; the debug + review log file paths derive from it. */
39
+ globalLogsDir: string;
40
+ /** Reads current config for the debug/review write toggles (call-time). */
41
+ getConfig: () => PermissionSystemExtensionConfig;
42
+ /** Surfaces a warning message to the user; called at warn/IO-failure time. */
43
+ notify: (message: string) => void;
44
+ }
45
+
16
46
  /**
17
- * Create a SessionLogger backed by an ExtensionRuntime.
47
+ * Create a SessionLogger from narrow dependencies.
18
48
  *
19
- * Captures `runtime` by reference so `warn` always reads the current
20
- * `runtimeContext` at call time matching the behavior of the inline
21
- * closures it replaces in `src/index.ts`.
49
+ * Composes the JSONL log writer, owns the IO-failure warning dedup Set,
50
+ * and routes both IO-failure warnings and explicit warn() calls through
51
+ * the injected notify sink. No ExtensionRuntime reference required.
22
52
  */
23
- export function createSessionLogger(runtime: ExtensionRuntime): SessionLogger {
53
+ export function createSessionLogger(deps: SessionLoggerDeps): SessionLogger {
54
+ const writer = createPermissionSystemLogger({
55
+ getConfig: deps.getConfig,
56
+ debugLogPath: join(deps.globalLogsDir, DEBUG_LOG_FILENAME),
57
+ reviewLogPath: join(deps.globalLogsDir, REVIEW_LOG_FILENAME),
58
+ ensureLogsDirectory: () =>
59
+ ensurePermissionSystemLogsDirectory(deps.globalLogsDir),
60
+ });
61
+
62
+ const reported = new Set<string>();
63
+ const reportOnce = (warning: string): void => {
64
+ if (reported.has(warning)) return;
65
+ reported.add(warning);
66
+ deps.notify(warning);
67
+ };
68
+
24
69
  return {
25
- debug: (event, details) => runtime.writeDebugLog(event, details),
26
- review: (event, details) => runtime.writeReviewLog(event, details),
27
- warn: (message) => runtime.runtimeContext?.ui.notify(message, "warning"),
70
+ debug: (event, details) => {
71
+ const warning = writer.debug(event, details);
72
+ if (warning) reportOnce(warning);
73
+ },
74
+ review: (event, details) => {
75
+ const warning = writer.review(event, details);
76
+ if (warning) reportOnce(warning);
77
+ },
78
+ warn: (message) => deps.notify(message),
28
79
  };
29
80
  }
@@ -31,7 +31,10 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
31
31
  import { getGlobalConfigPath } from "#src/config-paths";
32
32
  import { DEFAULT_EXTENSION_CONFIG } from "#src/extension-config";
33
33
  import piPermissionSystemExtension from "#src/index";
34
- import { PERMISSIONS_READY_CHANNEL } from "#src/permission-events";
34
+ import {
35
+ PERMISSIONS_READY_CHANNEL,
36
+ PERMISSIONS_RPC_CHECK_CHANNEL,
37
+ } from "#src/permission-events";
35
38
  import {
36
39
  createPermissionForwardingLocation,
37
40
  type ForwardedPermissionRequest,
@@ -359,6 +362,87 @@ describe("ready emitted after service publication", () => {
359
362
  });
360
363
  });
361
364
 
365
+ describe("single source of truth for session state", () => {
366
+ // Regression guard for the split-brain bug: before the fix, the gate path
367
+ // recorded session approvals into a private SessionRules instance that the
368
+ // RPC check and the service never saw. After the fix, both readers use the
369
+ // same SessionRules the gate writes into.
370
+ it("gate session-approval is visible to the RPC check and the service", async () => {
371
+ writeGlobalConfig({
372
+ permission: { "*": "allow", demo: "ask" },
373
+ });
374
+
375
+ const cwd = mkdtempSync(join(tmpdir(), "pi-perm-sot-cwd-"));
376
+ const pi = makeFakePi({ toolNames: ["demo"] });
377
+ piPermissionSystemExtension(pi as unknown as ExtensionAPI);
378
+
379
+ // UI ctx that approves the gate prompt for this session (options[1]).
380
+ const ctx = {
381
+ cwd,
382
+ hasUI: true,
383
+ sessionManager: {
384
+ getEntries: (): unknown[] => [],
385
+ getSessionId: (): string => "sot-session",
386
+ getSessionDir: (): string => cwd,
387
+ },
388
+ ui: {
389
+ notify: (): void => {},
390
+ setStatus: (): void => {},
391
+ // Return the second option label-agnostically — always the
392
+ // "for this session" choice regardless of the exact label text.
393
+ select: async (
394
+ _title: string,
395
+ options: string[],
396
+ ): Promise<string | undefined> => options[1],
397
+ input: async (): Promise<string | undefined> => undefined,
398
+ },
399
+ };
400
+
401
+ await fireSessionStart(pi, ctx);
402
+
403
+ // Drive a tool_call on "demo"; the gate prompts and the mock selects
404
+ // options[1], recording a session-scoped approval.
405
+ await pi.fire(
406
+ "tool_call",
407
+ {
408
+ toolName: "demo",
409
+ toolCallId: "demo-for-session",
410
+ input: { foo: "bar" },
411
+ },
412
+ ctx,
413
+ );
414
+
415
+ // RPC check — the deprecated channel must now reflect the session approval.
416
+ // eslint-disable-next-line @typescript-eslint/no-deprecated -- intentionally testing the deprecated RPC channel's session-rules visibility
417
+ const rpcCheckChannel: string = PERMISSIONS_RPC_CHECK_CHANNEL;
418
+ const requestId = "sot-rpc-1";
419
+ const replyPromise = new Promise<unknown>((resolve) => {
420
+ const unsub = pi.events.on(
421
+ `${rpcCheckChannel}:reply:${requestId}`,
422
+ (data) => {
423
+ unsub();
424
+ resolve(data);
425
+ },
426
+ );
427
+ });
428
+ pi.events.emit(rpcCheckChannel, { requestId, surface: "demo" });
429
+ const reply = (await replyPromise) as {
430
+ success: boolean;
431
+ data?: { result: string };
432
+ };
433
+
434
+ expect(reply.success).toBe(true);
435
+ // Before the fix this was "ask" — the RPC channel read an empty SessionRules.
436
+ expect(reply.data?.result).toBe("allow");
437
+
438
+ // Service accessor must also see the session approval.
439
+ const serviceResult = getPermissionsService()!.checkPermission("demo");
440
+ expect(serviceResult.state).toBe("allow");
441
+
442
+ rmSync(cwd, { recursive: true, force: true });
443
+ });
444
+ });
445
+
362
446
  describe("multi-instance global service interplay", () => {
363
447
  // The fix (#302) scopes the process-global service slot to the publishing
364
448
  // instance. The parent publishes at its session_start; an in-process child
@@ -9,7 +9,7 @@ import {
9
9
  normalizePermissionSystemConfig,
10
10
  type PermissionSystemExtensionConfig,
11
11
  } from "#src/extension-config";
12
- import type { Rule } from "#src/rule";
12
+ import type { Rule, Ruleset } from "#src/rule";
13
13
 
14
14
  vi.mock("@earendil-works/pi-coding-agent", () => ({
15
15
  getSettingsListTheme: () => ({}),
@@ -88,7 +88,9 @@ test("permission-system command completions expose top-level config actions", ()
88
88
  };
89
89
  const controller = {
90
90
  config: configStore,
91
- getConfigPath: () => configPath,
91
+ configPath,
92
+ permissionManager: { getComposedConfigRules: () => [] as Ruleset },
93
+ session: { lastKnownActiveAgentName: null },
92
94
  };
93
95
 
94
96
  let definition: {
@@ -160,7 +162,9 @@ test("permission-system command handlers manage config summary, persistence, and
160
162
  };
161
163
  const controller = {
162
164
  config: configStore,
163
- getConfigPath: () => configPath,
165
+ configPath,
166
+ permissionManager: { getComposedConfigRules: () => [] as Ruleset },
167
+ session: { lastKnownActiveAgentName: null },
164
168
  };
165
169
 
166
170
  let registeredName = "";
@@ -257,8 +261,9 @@ test("show output includes rule origins when getComposedRules is provided", asyn
257
261
 
258
262
  const controller = {
259
263
  config: { current: () => config, save: () => {} } as CommandConfigStore,
260
- getConfigPath: () => "/fake/config.json",
261
- getComposedRules: () => composedRules,
264
+ configPath: "/fake/config.json",
265
+ permissionManager: { getComposedConfigRules: () => composedRules },
266
+ session: { lastKnownActiveAgentName: null },
262
267
  };
263
268
 
264
269
  let definition: {
@@ -289,8 +294,9 @@ test("show output omits rule summary when getComposedRules is not provided", asy
289
294
 
290
295
  const controller = {
291
296
  config: { current: () => config, save: () => {} } as CommandConfigStore,
292
- getConfigPath: () => "/fake/config.json",
293
- // no getComposedRules
297
+ configPath: "/fake/config.json",
298
+ permissionManager: { getComposedConfigRules: () => [] as Ruleset },
299
+ session: { lastKnownActiveAgentName: null },
294
300
  };
295
301
 
296
302
  let definition: {