@gotgenes/pi-permission-system 10.3.0 → 10.3.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,13 @@ 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
+ ## [10.3.1](https://github.com/gotgenes/pi-packages/compare/pi-permission-system-v10.3.0...pi-permission-system-v10.3.1) (2026-06-06)
9
+
10
+
11
+ ### Bug Fixes
12
+
13
+ * share one PermissionManager and SessionRules across gate and RPC paths ([#337](https://github.com/gotgenes/pi-packages/issues/337)) ([7dd1e65](https://github.com/gotgenes/pi-packages/commit/7dd1e65493fa0061a3b84eb329457f939b953e0a))
14
+
8
15
  ## [10.3.0](https://github.com/gotgenes/pi-packages/compare/pi-permission-system-v10.2.0...pi-permission-system-v10.3.0) (2026-06-05)
9
16
 
10
17
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gotgenes/pi-permission-system",
3
- "version": "10.3.0",
3
+ "version": "10.3.1",
4
4
  "description": "Permission enforcement extension for the Pi coding agent.",
5
5
  "type": "module",
6
6
  "exports": {
@@ -41,7 +41,7 @@ export interface ConfigReader {
41
41
  */
42
42
  export interface SessionConfigStore extends ConfigReader {
43
43
  refresh(ctx?: ExtensionContext): void;
44
- logResolvedPaths(): void;
44
+ logResolvedPaths(cwd?: string): void;
45
45
  }
46
46
 
47
47
  /**
@@ -57,16 +57,6 @@ export interface CommandConfigStore extends ConfigReader {
57
57
  ): void;
58
58
  }
59
59
 
60
- /**
61
- * Transitional get/set seam over the runtime-owned context.
62
- *
63
- * Retired in Step 4 (#337) when context ownership moves to `PermissionSession`.
64
- */
65
- export interface RuntimeContextRef {
66
- get(): ExtensionContext | null;
67
- set(ctx: ExtensionContext): void;
68
- }
69
-
70
60
  /** Narrow logging sink — replaced by an injected logger in Step 3 (#336). */
71
61
  export interface ConfigStoreLogger {
72
62
  writeDebugLog(event: string, details?: Record<string, unknown>): void;
@@ -80,7 +70,6 @@ export interface ResolvedPolicyPathProvider {
80
70
 
81
71
  export interface ConfigStoreDeps {
82
72
  agentDir: string;
83
- context: RuntimeContextRef;
84
73
  policyPaths: ResolvedPolicyPathProvider;
85
74
  logger: ConfigStoreLogger;
86
75
  }
@@ -111,14 +100,11 @@ export class ConfigStore implements SessionConfigStore, CommandConfigStore {
111
100
  /**
112
101
  * Reload merged config from disk.
113
102
  *
114
- * If `ctx` is provided, updates the stored runtime context via the seam first.
103
+ * If `ctx` is provided, uses it to derive the cwd and sync UI status.
115
104
  * Equivalent to `refreshExtensionConfig(runtime, ctx?)`.
116
105
  */
117
106
  refresh(ctx?: ExtensionContext): void {
118
- if (ctx) {
119
- this.deps.context.set(ctx);
120
- }
121
- const cwd = this.deps.context.get()?.cwd ?? null;
107
+ const cwd = ctx?.cwd ?? null;
122
108
  const mergeResult = loadAndMergeConfigs(
123
109
  this.deps.agentDir,
124
110
  cwd ?? "",
@@ -127,9 +113,8 @@ export class ConfigStore implements SessionConfigStore, CommandConfigStore {
127
113
  const runtimeConfig = normalizePermissionSystemConfig(mergeResult.merged);
128
114
  this.config = runtimeConfig;
129
115
 
130
- const currentCtx = this.deps.context.get();
131
- if (currentCtx?.hasUI) {
132
- syncPermissionSystemStatus(currentCtx, runtimeConfig);
116
+ if (ctx?.hasUI) {
117
+ syncPermissionSystemStatus(ctx, runtimeConfig);
133
118
  }
134
119
 
135
120
  const warning =
@@ -137,7 +122,7 @@ export class ConfigStore implements SessionConfigStore, CommandConfigStore {
137
122
 
138
123
  if (warning && warning !== this.lastConfigWarning) {
139
124
  this.lastConfigWarning = warning;
140
- currentCtx?.ui.notify(warning, "warning");
125
+ ctx?.ui.notify(warning, "warning");
141
126
  } else if (!warning) {
142
127
  this.lastConfigWarning = null;
143
128
  }
@@ -210,9 +195,8 @@ export class ConfigStore implements SessionConfigStore, CommandConfigStore {
210
195
  *
211
196
  * Equivalent to `logResolvedConfigPaths(runtime)`.
212
197
  */
213
- logResolvedPaths(): void {
198
+ logResolvedPaths(cwd?: string): void {
214
199
  const policyPaths = this.deps.policyPaths.getResolvedPolicyPaths();
215
- const cwd = this.deps.context.get()?.cwd ?? null;
216
200
  const { agentDir } = this.deps;
217
201
  const legacyGlobalPolicyDetected = existsSync(
218
202
  getLegacyGlobalPolicyPath(agentDir),
package/src/index.ts CHANGED
@@ -1,8 +1,11 @@
1
1
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
+ import { getAgentDir } from "@earendil-works/pi-coding-agent";
2
3
  import { registerBuiltinToolInputFormatters } from "./builtin-tool-input-formatters";
3
4
  import { registerPermissionSystemCommand } from "./config-modal";
4
5
  import { getGlobalConfigPath } from "./config-paths";
6
+ import { ConfigStore } from "./config-store";
5
7
  import { GateDecisionReporter } from "./decision-reporter";
8
+ import { computeExtensionPaths } from "./extension-paths";
6
9
  import {
7
10
  PermissionForwarder,
8
11
  type PermissionForwarderDeps,
@@ -22,9 +25,9 @@ import { PermissionManager } from "./permission-manager";
22
25
  import { PermissionPrompter } from "./permission-prompter";
23
26
  import { PermissionSession } from "./permission-session";
24
27
  import { LocalPermissionsService } from "./permissions-service";
25
- import { createExtensionRuntime } from "./runtime";
26
28
  import { PermissionServiceLifecycle } from "./service-lifecycle";
27
29
  import { createSessionLogger } from "./session-logger";
30
+ import { SessionRules } from "./session-rules";
28
31
  import { isSubagentExecutionContext } from "./subagent-context";
29
32
  import { subscribeSubagentLifecycle } from "./subagent-lifecycle-events";
30
33
  import { getSubagentSessionRegistry } from "./subagent-registry";
@@ -35,56 +38,85 @@ import {
35
38
  } from "./yolo-mode";
36
39
 
37
40
  export default function piPermissionSystemExtension(pi: ExtensionAPI): void {
38
- const runtime = createExtensionRuntime();
41
+ const agentDir = getAgentDir();
42
+ const paths = computeExtensionPaths(agentDir);
43
+ const permissionManager = new PermissionManager({ agentDir });
44
+ const sessionRules = new SessionRules();
39
45
  const subagentRegistry = getSubagentSessionRegistry();
40
46
  const formatterRegistry = new ToolInputFormatterRegistry();
41
47
  registerBuiltinToolInputFormatters(formatterRegistry);
42
48
 
49
+ // Forward reference: configStore is declared before the logger so the
50
+ // logger's getConfig thunk can close over the variable; assigned immediately
51
+ // after. Typed via cast so the closure compiles without assertions.
52
+ // The same null-at-init pattern used in the former createExtensionRuntime.
53
+ let configStore = null as unknown as ConfigStore;
54
+
55
+ // sessionNotify is a mutable holder so the logger's notify closure can
56
+ // reach the UI once PermissionSession is constructed. Starts as null;
57
+ // notify is a best-effort sink (no-op at factory-init when there is no UI).
58
+ let sessionNotify: PermissionSession | null = null;
59
+
60
+ const logger = createSessionLogger({
61
+ globalLogsDir: paths.globalLogsDir,
62
+ getConfig: () => configStore.current(),
63
+ notify: (message) =>
64
+ sessionNotify?.getRuntimeContext()?.ui.notify(message, "warning"),
65
+ });
66
+
67
+ configStore = new ConfigStore({
68
+ agentDir,
69
+ policyPaths: permissionManager,
70
+ logger: {
71
+ writeDebugLog: (e, d) => logger.debug(e, d),
72
+ writeReviewLog: (e, d) => logger.review(e, d),
73
+ },
74
+ });
75
+
43
76
  const forwardingDeps: PermissionForwarderDeps = {
44
- forwardingDir: runtime.forwardingDir,
45
- subagentSessionsDir: runtime.subagentSessionsDir,
77
+ forwardingDir: paths.forwardingDir,
78
+ subagentSessionsDir: paths.subagentSessionsDir,
46
79
  registry: subagentRegistry,
47
80
  events: pi.events,
48
81
  logger: {
49
- writeReviewLog: runtime.writeReviewLog.bind(runtime),
50
- writeDebugLog: runtime.writeDebugLog.bind(runtime),
82
+ writeReviewLog: (event, details) => logger.review(event, details),
83
+ writeDebugLog: (event, details) => logger.debug(event, details),
51
84
  },
52
- writeReviewLog: runtime.writeReviewLog.bind(runtime),
85
+ writeReviewLog: (event, details) => logger.review(event, details),
53
86
  requestPermissionDecisionFromUi,
54
87
  shouldAutoApprove: () =>
55
- shouldAutoApprovePermissionState("ask", runtime.configStore.current()),
88
+ shouldAutoApprovePermissionState("ask", configStore.current()),
56
89
  };
57
90
  const forwarder = new PermissionForwarder(forwardingDeps);
58
91
 
59
92
  const prompter = new PermissionPrompter({
60
- config: runtime.configStore,
61
- writeReviewLog: runtime.writeReviewLog.bind(runtime),
93
+ config: configStore,
94
+ writeReviewLog: (event, details) => logger.review(event, details),
62
95
  events: pi.events,
63
96
  forwarder,
64
97
  });
65
98
 
66
- runtime.configStore.refresh();
67
-
68
- const sessionManager = new PermissionManager({ agentDir: runtime.agentDir });
99
+ configStore.refresh();
69
100
 
70
101
  const session = new PermissionSession(
71
- runtime,
72
- createSessionLogger(runtime),
102
+ paths,
103
+ logger,
73
104
  new ForwardingManager(
74
- runtime.subagentSessionsDir,
105
+ paths.subagentSessionsDir,
75
106
  forwarder,
76
107
  subagentRegistry,
77
108
  ),
78
- sessionManager,
79
- runtime.configStore,
109
+ permissionManager,
110
+ sessionRules,
111
+ configStore,
80
112
  {
81
113
  canRequestPermissionConfirmation: (ctx) =>
82
114
  canResolveAskPermissionRequest({
83
- config: runtime.configStore.current(),
115
+ config: configStore.current(),
84
116
  hasUI: ctx.hasUI,
85
117
  isSubagent: isSubagentExecutionContext(
86
118
  ctx,
87
- runtime.subagentSessionsDir,
119
+ paths.subagentSessionsDir,
88
120
  subagentRegistry,
89
121
  ),
90
122
  }),
@@ -92,26 +124,29 @@ export default function piPermissionSystemExtension(pi: ExtensionAPI): void {
92
124
  },
93
125
  );
94
126
 
127
+ // Connect the notify sink now that session is available.
128
+ sessionNotify = session;
129
+
95
130
  registerPermissionSystemCommand(pi, {
96
- config: runtime.configStore,
97
- getConfigPath: () => getGlobalConfigPath(runtime.agentDir),
131
+ config: configStore,
132
+ getConfigPath: () => getGlobalConfigPath(agentDir),
98
133
  getComposedRules: () =>
99
- runtime.permissionManager.getComposedConfigRules(
100
- runtime.lastKnownActiveAgentName ?? undefined,
134
+ permissionManager.getComposedConfigRules(
135
+ session.lastKnownActiveAgentName ?? undefined,
101
136
  ),
102
137
  });
103
138
 
104
139
  const rpcHandles = registerPermissionRpcHandlers(pi.events, {
105
- getPermissionManager: () => runtime.permissionManager,
106
- getSessionRules: () => runtime.sessionRules.getRuleset(),
107
- getRuntimeContext: () => runtime.runtimeContext,
140
+ getPermissionManager: () => permissionManager,
141
+ getSessionRules: () => sessionRules.getRuleset(),
142
+ getRuntimeContext: () => session.getRuntimeContext(),
108
143
  requestPermissionDecisionFromUi,
109
- writeReviewLog: runtime.writeReviewLog.bind(runtime),
144
+ writeReviewLog: (event, details) => logger.review(event, details),
110
145
  });
111
146
 
112
147
  const permissionsService = new LocalPermissionsService(
113
- runtime.permissionManager,
114
- runtime.sessionRules,
148
+ permissionManager,
149
+ sessionRules,
115
150
  formatterRegistry,
116
151
  );
117
152
 
@@ -20,7 +20,7 @@ import type { SessionApproval } from "./session-approval";
20
20
  import type { SessionApprovalRecorder } from "./session-approval-recorder";
21
21
  import type { SessionLifecycleSession } from "./session-lifecycle-session";
22
22
  import type { SessionLogger } from "./session-logger";
23
- import { SessionRules } from "./session-rules";
23
+ import type { SessionRules } from "./session-rules";
24
24
  import type { SkillPromptEntry } from "./skill-prompt-sanitizer";
25
25
  import {
26
26
  resolveToolPreviewLimits,
@@ -31,8 +31,7 @@ import type { PermissionCheckResult, PermissionState } from "./types";
31
31
  /**
32
32
  * Runtime operations that `PermissionSession` delegates to but does not own.
33
33
  *
34
- * Injected at construction time from the composition root (`index.ts`),
35
- * where the `ExtensionRuntime` is available.
34
+ * Injected at construction time from the composition root (`index.ts`).
36
35
  */
37
36
  export interface PermissionSessionRuntimeDeps {
38
37
  /** Whether the current context can show an interactive permission prompt. */
@@ -69,7 +68,6 @@ export class PermissionSession
69
68
  SessionLifecycleSession
70
69
  {
71
70
  private context: ExtensionContext | null = null;
72
- private readonly sessionRules = new SessionRules();
73
71
  private skillEntries: SkillPromptEntry[] = [];
74
72
  private knownAgentName: string | null = null;
75
73
  private toolsCacheKey: string | null = null;
@@ -80,6 +78,7 @@ export class PermissionSession
80
78
  readonly logger: SessionLogger,
81
79
  private readonly forwarding: ForwardingController,
82
80
  private readonly permissionManager: ScopedPermissionManager,
81
+ private readonly sessionRules: SessionRules,
83
82
  private readonly configStore: SessionConfigStore,
84
83
  private readonly runtimeDeps: PermissionSessionRuntimeDeps,
85
84
  ) {}
@@ -262,7 +261,7 @@ export class PermissionSession
262
261
 
263
262
  /** Write the resolved config path set to the review and debug logs. */
264
263
  logResolvedConfigPaths(): void {
265
- this.configStore.logResolvedPaths();
264
+ this.configStore.logResolvedPaths(this.context?.cwd);
266
265
  }
267
266
 
268
267
  /** Read current extension config. */
@@ -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(
@@ -1,4 +1,10 @@
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";
2
8
 
3
9
  /**
4
10
  * Unified logging + notification surface for handler deps.
@@ -13,17 +19,48 @@ export interface SessionLogger {
13
19
  warn(message: string): void;
14
20
  }
15
21
 
22
+ /** Narrow dependencies for constructing a {@link SessionLogger}. */
23
+ export interface SessionLoggerDeps {
24
+ /** Root logs directory; the debug + review log file paths derive from it. */
25
+ globalLogsDir: string;
26
+ /** Reads current config for the debug/review write toggles (call-time). */
27
+ getConfig: () => PermissionSystemExtensionConfig;
28
+ /** Surfaces a warning message to the user; called at warn/IO-failure time. */
29
+ notify: (message: string) => void;
30
+ }
31
+
16
32
  /**
17
- * Create a SessionLogger backed by an ExtensionRuntime.
33
+ * Create a SessionLogger from narrow dependencies.
18
34
  *
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`.
35
+ * Composes the JSONL log writer, owns the IO-failure warning dedup Set,
36
+ * and routes both IO-failure warnings and explicit warn() calls through
37
+ * the injected notify sink. No ExtensionRuntime reference required.
22
38
  */
23
- export function createSessionLogger(runtime: ExtensionRuntime): SessionLogger {
39
+ export function createSessionLogger(deps: SessionLoggerDeps): SessionLogger {
40
+ const writer = createPermissionSystemLogger({
41
+ getConfig: deps.getConfig,
42
+ debugLogPath: join(deps.globalLogsDir, DEBUG_LOG_FILENAME),
43
+ reviewLogPath: join(deps.globalLogsDir, REVIEW_LOG_FILENAME),
44
+ ensureLogsDirectory: () =>
45
+ ensurePermissionSystemLogsDirectory(deps.globalLogsDir),
46
+ });
47
+
48
+ const reported = new Set<string>();
49
+ const reportOnce = (warning: string): void => {
50
+ if (reported.has(warning)) return;
51
+ reported.add(warning);
52
+ deps.notify(warning);
53
+ };
54
+
24
55
  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"),
56
+ debug: (event, details) => {
57
+ const warning = writer.debug(event, details);
58
+ if (warning) reportOnce(warning);
59
+ },
60
+ review: (event, details) => {
61
+ const warning = writer.review(event, details);
62
+ if (warning) reportOnce(warning);
63
+ },
64
+ warn: (message) => deps.notify(message),
28
65
  };
29
66
  }
@@ -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
@@ -62,26 +62,12 @@ import {
62
62
  ConfigStore,
63
63
  type ConfigStoreDeps,
64
64
  type ResolvedPolicyPathProvider,
65
- type RuntimeContextRef,
66
65
  } from "#src/config-store";
67
66
  import { DEFAULT_EXTENSION_CONFIG } from "#src/extension-config";
68
67
  import type { ResolvedPolicyPaths } from "#src/policy-loader";
69
68
 
70
69
  // ── Helpers ────────────────────────────────────────────────────────────────
71
70
 
72
- function makeContextRef(
73
- initialCtx: ExtensionContext | null = null,
74
- ): RuntimeContextRef & { _ctx: ExtensionContext | null } {
75
- const ref = {
76
- _ctx: initialCtx,
77
- get: () => ref._ctx,
78
- set: (ctx: ExtensionContext) => {
79
- ref._ctx = ctx;
80
- },
81
- };
82
- return ref;
83
- }
84
-
85
71
  function makePolicyPathProvider(
86
72
  paths?: Partial<ResolvedPolicyPaths>,
87
73
  ): ResolvedPolicyPathProvider {
@@ -133,19 +119,16 @@ function makeCommandCtx(
133
119
 
134
120
  function makeStore(overrides: Partial<ConfigStoreDeps> = {}): {
135
121
  store: ConfigStore;
136
- contextRef: RuntimeContextRef & { _ctx: ExtensionContext | null };
137
122
  logger: ReturnType<typeof makeLogger>;
138
123
  } {
139
- const contextRef = makeContextRef();
140
124
  const logger = makeLogger();
141
125
  const deps: ConfigStoreDeps = {
142
126
  agentDir: "/test/agent",
143
- context: contextRef,
144
127
  policyPaths: makePolicyPathProvider(),
145
128
  logger,
146
129
  ...overrides,
147
130
  };
148
- return { store: new ConfigStore(deps), contextRef, logger };
131
+ return { store: new ConfigStore(deps), logger };
149
132
  }
150
133
 
151
134
  // ── Tests ──────────────────────────────────────────────────────────────────
@@ -180,10 +163,9 @@ describe("ConfigStore", () => {
180
163
  // ── refresh() ─────────────────────────────────────────────────────────
181
164
 
182
165
  describe("refresh()", () => {
183
- it("calls loadAndMergeConfigs with agentDir and cwd from context", () => {
184
- const { store, contextRef } = makeStore();
185
- contextRef._ctx = makeCtx({ cwd: "/my/project" });
186
- store.refresh();
166
+ it("uses the passed ctx cwd for loadAndMergeConfigs", () => {
167
+ const { store } = makeStore();
168
+ store.refresh(makeCtx({ cwd: "/my/project" }));
187
169
  expect(mockLoadAndMergeConfigs).toHaveBeenCalledWith(
188
170
  "/test/agent",
189
171
  "/my/project",
@@ -191,19 +173,14 @@ describe("ConfigStore", () => {
191
173
  );
192
174
  });
193
175
 
194
- it("updates context via context.set when ctx is provided", () => {
195
- const { store, contextRef } = makeStore();
196
- const ctx = makeCtx({ cwd: "/new/project" });
197
- store.refresh(ctx);
198
- expect(contextRef._ctx).toBe(ctx);
199
- });
200
-
201
- it("does not overwrite context when ctx is omitted", () => {
202
- const { store, contextRef } = makeStore();
203
- const existing = makeCtx();
204
- contextRef._ctx = existing;
176
+ it("uses empty string cwd when no ctx is provided", () => {
177
+ const { store } = makeStore();
205
178
  store.refresh();
206
- expect(contextRef._ctx).toBe(existing);
179
+ expect(mockLoadAndMergeConfigs).toHaveBeenCalledWith(
180
+ "/test/agent",
181
+ "",
182
+ expect.any(String),
183
+ );
207
184
  });
208
185
 
209
186
  it("updates current() with normalized merged result", () => {
@@ -422,13 +399,12 @@ describe("ConfigStore", () => {
422
399
  });
423
400
 
424
401
  it("passes legacy detection results to buildResolvedConfigLogEntry", () => {
425
- const { store, contextRef } = makeStore();
426
- contextRef._ctx = makeCtx({ cwd: "/some/project" });
402
+ const { store } = makeStore();
427
403
  // Make one legacy path exist
428
404
  mockExistsSync.mockImplementation((p: string) =>
429
405
  p.includes("policies.json"),
430
406
  );
431
- store.logResolvedPaths();
407
+ store.logResolvedPaths("/some/project");
432
408
  expect(mockBuildResolvedConfigLogEntry).toHaveBeenCalledWith(
433
409
  expect.objectContaining({
434
410
  legacyGlobalPolicyDetected: expect.any(Boolean),
@@ -438,9 +414,9 @@ describe("ConfigStore", () => {
438
414
  );
439
415
  });
440
416
 
441
- it("does not check project legacy path when context has no cwd", () => {
442
- const { store } = makeStore(); // contextRef._ctx = null
443
- store.logResolvedPaths();
417
+ it("does not check project legacy path when no cwd is provided", () => {
418
+ const { store } = makeStore();
419
+ store.logResolvedPaths(); // no cwd
444
420
  // existsSync called for global and ext-config legacy paths only (not project)
445
421
  const calls = mockExistsSync.mock.calls.map(([p]: [string]) => p);
446
422
  const projectCalls = calls.filter(
@@ -29,6 +29,7 @@ import {
29
29
  import type { Ruleset } from "#src/rule";
30
30
  import { SessionApproval } from "#src/session-approval";
31
31
  import type { SessionLogger } from "#src/session-logger";
32
+ import { SessionRules } from "#src/session-rules";
32
33
  import type { SkillPromptEntry } from "#src/skill-prompt-sanitizer";
33
34
  import type { PermissionCheckResult, PermissionState } from "#src/types";
34
35
  import { makeCtx } from "#test/helpers/handler-fixtures";
@@ -129,6 +130,7 @@ function createSession(overrides?: {
129
130
  logger?: SessionLogger;
130
131
  forwarding?: ForwardingController;
131
132
  permissionManager?: ScopedPermissionManager;
133
+ sessionRules?: SessionRules;
132
134
  configStore?: SessionConfigStore;
133
135
  runtimeDeps?: PermissionSessionRuntimeDeps;
134
136
  }): {
@@ -136,6 +138,7 @@ function createSession(overrides?: {
136
138
  paths: ExtensionPaths;
137
139
  logger: SessionLogger;
138
140
  forwarding: ForwardingController;
141
+ sessionRules: SessionRules;
139
142
  configStore: SessionConfigStore;
140
143
  runtimeDeps: PermissionSessionRuntimeDeps;
141
144
  } {
@@ -144,6 +147,7 @@ function createSession(overrides?: {
144
147
  const forwarding = overrides?.forwarding ?? makeForwarding();
145
148
  const permissionManager =
146
149
  overrides?.permissionManager ?? makePermissionManager();
150
+ const sessionRules = overrides?.sessionRules ?? new SessionRules();
147
151
  const configStore = overrides?.configStore ?? makeConfigStore();
148
152
  const runtimeDeps = overrides?.runtimeDeps ?? makeRuntimeDeps();
149
153
  const session = new PermissionSession(
@@ -151,10 +155,19 @@ function createSession(overrides?: {
151
155
  logger,
152
156
  forwarding,
153
157
  permissionManager,
158
+ sessionRules,
154
159
  configStore,
155
160
  runtimeDeps,
156
161
  );
157
- return { session, paths, logger, forwarding, configStore, runtimeDeps };
162
+ return {
163
+ session,
164
+ paths,
165
+ logger,
166
+ forwarding,
167
+ sessionRules,
168
+ configStore,
169
+ runtimeDeps,
170
+ };
158
171
  }
159
172
 
160
173
  // ── Tests ──────────────────────────────────────────────────────────────────