@gotgenes/pi-permission-system 10.0.0 → 10.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (68) hide show
  1. package/CHANGELOG.md +33 -0
  2. package/README.md +1 -1
  3. package/package.json +1 -1
  4. package/src/agent-prep-session.ts +28 -0
  5. package/src/decision-reporter.ts +41 -0
  6. package/src/denial-messages.ts +11 -0
  7. package/src/forwarded-permissions/permission-forwarder.ts +549 -0
  8. package/src/forwarding-manager.ts +3 -7
  9. package/src/gate-handler-session.ts +13 -0
  10. package/src/gate-prompter.ts +14 -0
  11. package/src/handlers/before-agent-start.ts +2 -3
  12. package/src/handlers/gates/bash-command.ts +4 -18
  13. package/src/handlers/gates/bash-external-directory.ts +3 -15
  14. package/src/handlers/gates/bash-path.ts +3 -16
  15. package/src/handlers/gates/descriptor.ts +0 -28
  16. package/src/handlers/gates/path.ts +3 -15
  17. package/src/handlers/gates/runner.ts +142 -105
  18. package/src/handlers/gates/skill-input-gate-pipeline.ts +104 -0
  19. package/src/handlers/gates/skill-input.ts +44 -0
  20. package/src/handlers/gates/tool-call-gate-pipeline.ts +120 -0
  21. package/src/handlers/lifecycle.ts +9 -9
  22. package/src/handlers/permission-gate-handler.ts +34 -238
  23. package/src/index.ts +53 -69
  24. package/src/mcp-targets.ts +56 -46
  25. package/src/permission-manager.ts +69 -3
  26. package/src/permission-prompter.ts +7 -58
  27. package/src/permission-resolver.ts +17 -0
  28. package/src/permission-session.ts +83 -27
  29. package/src/permissions-service.ts +53 -0
  30. package/src/runtime.ts +1 -37
  31. package/src/service-lifecycle.ts +49 -0
  32. package/src/session-approval-recorder.ts +6 -0
  33. package/src/session-lifecycle-session.ts +24 -0
  34. package/src/tool-input-preview.ts +0 -62
  35. package/src/tool-input-prompt-formatters.ts +63 -0
  36. package/src/tool-preview-formatter.ts +6 -4
  37. package/test/decision-reporter.test.ts +112 -0
  38. package/test/denial-messages.test.ts +62 -0
  39. package/test/forwarding-manager.test.ts +26 -44
  40. package/test/handlers/before-agent-start.test.ts +45 -21
  41. package/test/handlers/external-directory-integration.test.ts +83 -114
  42. package/test/handlers/external-directory-session-dedup.test.ts +102 -55
  43. package/test/handlers/gates/bash-command.test.ts +49 -90
  44. package/test/handlers/gates/bash-external-directory.test.ts +54 -95
  45. package/test/handlers/gates/bash-path.test.ts +54 -157
  46. package/test/handlers/gates/path.test.ts +38 -105
  47. package/test/handlers/gates/runner.test.ts +151 -186
  48. package/test/handlers/gates/skill-input-gate-pipeline.test.ts +176 -0
  49. package/test/handlers/gates/skill-input.test.ts +128 -0
  50. package/test/handlers/gates/tool-call-gate-pipeline.test.ts +180 -0
  51. package/test/handlers/input.test.ts +1 -2
  52. package/test/handlers/lifecycle.test.ts +49 -33
  53. package/test/handlers/tool-call-events.test.ts +1 -1
  54. package/test/handlers/tool-call.test.ts +44 -153
  55. package/test/helpers/gate-fixtures.ts +212 -17
  56. package/test/helpers/handler-fixtures.ts +226 -29
  57. package/test/mcp-targets.test.ts +55 -0
  58. package/test/permission-forwarder.test.ts +295 -0
  59. package/test/permission-forwarding.test.ts +0 -282
  60. package/test/permission-manager-unified.test.ts +159 -1
  61. package/test/permission-prompter.test.ts +33 -44
  62. package/test/permission-session.test.ts +211 -105
  63. package/test/permissions-service.test.ts +151 -0
  64. package/test/runtime.test.ts +2 -86
  65. package/test/service-lifecycle.test.ts +162 -0
  66. package/test/tool-input-preview.test.ts +0 -111
  67. package/test/tool-input-prompt-formatters.test.ts +115 -0
  68. package/src/forwarded-permissions/polling.ts +0 -411
@@ -1,5 +1,28 @@
1
1
  import { getNonEmptyString, toRecord } from "./common";
2
2
 
3
+ /**
4
+ * An ordered accumulator that owns the uniqueness invariant.
5
+ *
6
+ * `add` ignores null/empty values and silently skips duplicates (first-insertion
7
+ * wins). `toArray` returns the ordered result as an independent copy.
8
+ */
9
+ export class McpTargetList {
10
+ private readonly targets: string[] = [];
11
+
12
+ add(value: string | null): void {
13
+ if (!value) {
14
+ return;
15
+ }
16
+ if (!this.targets.includes(value)) {
17
+ this.targets.push(value);
18
+ }
19
+ }
20
+
21
+ toArray(): string[] {
22
+ return [...this.targets];
23
+ }
24
+ }
25
+
3
26
  /**
4
27
  * Parse a qualified MCP tool name of the form `server:tool`.
5
28
  *
@@ -31,7 +54,7 @@ export function parseQualifiedMcpToolName(
31
54
  function addDerivedMcpServerTargets(
32
55
  toolName: string,
33
56
  configuredServerNames: readonly string[],
34
- pushTarget: (value: string | null) => void,
57
+ targets: McpTargetList,
35
58
  ): void {
36
59
  const trimmedToolName = toolName.trim();
37
60
  if (!trimmedToolName) {
@@ -52,9 +75,9 @@ function addDerivedMcpServerTargets(
52
75
  continue;
53
76
  }
54
77
 
55
- pushTarget(`${trimmedServerName}_${trimmedToolName}`);
56
- pushTarget(`${trimmedServerName}:${trimmedToolName}`);
57
- pushTarget(trimmedServerName);
78
+ targets.add(`${trimmedServerName}_${trimmedToolName}`);
79
+ targets.add(`${trimmedServerName}:${trimmedToolName}`);
80
+ targets.add(trimmedServerName);
58
81
  }
59
82
  }
60
83
 
@@ -62,22 +85,22 @@ function pushMcpToolPermissionTargets(
62
85
  rawReference: string,
63
86
  serverHint: string | null,
64
87
  configuredServerNames: readonly string[],
65
- pushTarget: (value: string | null) => void,
88
+ targets: McpTargetList,
66
89
  ): void {
67
90
  const qualified = parseQualifiedMcpToolName(rawReference);
68
91
  const resolvedServer = serverHint ?? qualified?.server ?? null;
69
92
  const resolvedTool = qualified?.tool ?? rawReference;
70
93
 
71
94
  if (resolvedServer) {
72
- pushTarget(`${resolvedServer}_${resolvedTool}`);
73
- pushTarget(`${resolvedServer}:${resolvedTool}`);
74
- pushTarget(resolvedServer);
95
+ targets.add(`${resolvedServer}_${resolvedTool}`);
96
+ targets.add(`${resolvedServer}:${resolvedTool}`);
97
+ targets.add(resolvedServer);
75
98
  } else {
76
- addDerivedMcpServerTargets(resolvedTool, configuredServerNames, pushTarget);
99
+ addDerivedMcpServerTargets(resolvedTool, configuredServerNames, targets);
77
100
  }
78
101
 
79
- pushTarget(resolvedTool);
80
- pushTarget(rawReference);
102
+ targets.add(resolvedTool);
103
+ targets.add(rawReference);
81
104
  }
82
105
 
83
106
  /**
@@ -98,32 +121,19 @@ export function createMcpPermissionTargets(
98
121
  const describe = getNonEmptyString(record.describe);
99
122
  const search = getNonEmptyString(record.search);
100
123
 
101
- const targets: string[] = [];
102
- const pushTarget = (value: string | null) => {
103
- if (!value) {
104
- return;
105
- }
106
- if (!targets.includes(value)) {
107
- targets.push(value);
108
- }
109
- };
124
+ const targets = new McpTargetList();
110
125
 
111
126
  if (tool) {
112
- pushMcpToolPermissionTargets(
113
- tool,
114
- server,
115
- configuredServerNames,
116
- pushTarget,
117
- );
118
- pushTarget("mcp_call");
119
- return targets;
127
+ pushMcpToolPermissionTargets(tool, server, configuredServerNames, targets);
128
+ targets.add("mcp_call");
129
+ return targets.toArray();
120
130
  }
121
131
 
122
132
  if (connect) {
123
- pushTarget(`mcp_connect_${connect}`);
124
- pushTarget(connect);
125
- pushTarget("mcp_connect");
126
- return targets;
133
+ targets.add(`mcp_connect_${connect}`);
134
+ targets.add(connect);
135
+ targets.add("mcp_connect");
136
+ return targets.toArray();
127
137
  }
128
138
 
129
139
  if (describe) {
@@ -131,30 +141,30 @@ export function createMcpPermissionTargets(
131
141
  describe,
132
142
  server,
133
143
  configuredServerNames,
134
- pushTarget,
144
+ targets,
135
145
  );
136
- pushTarget("mcp_describe");
137
- return targets;
146
+ targets.add("mcp_describe");
147
+ return targets.toArray();
138
148
  }
139
149
 
140
150
  if (search) {
141
151
  if (server) {
142
- pushTarget(`mcp_server_${server}`);
143
- pushTarget(server);
152
+ targets.add(`mcp_server_${server}`);
153
+ targets.add(server);
144
154
  }
145
155
 
146
- pushTarget(search);
147
- pushTarget("mcp_search");
148
- return targets;
156
+ targets.add(search);
157
+ targets.add("mcp_search");
158
+ return targets.toArray();
149
159
  }
150
160
 
151
161
  if (server) {
152
- pushTarget(`mcp_server_${server}`);
153
- pushTarget(server);
154
- pushTarget("mcp_list");
155
- return targets;
162
+ targets.add(`mcp_server_${server}`);
163
+ targets.add(server);
164
+ targets.add("mcp_list");
165
+ return targets.toArray();
156
166
  }
157
167
 
158
- pushTarget("mcp_status");
159
- return targets;
168
+ targets.add("mcp_status");
169
+ return targets.toArray();
160
170
  }
@@ -1,4 +1,6 @@
1
+ import { join } from "node:path";
1
2
  import { isPermissionState } from "./common";
3
+ import { getGlobalConfigPath, getProjectConfigPath } from "./config-paths";
2
4
  import { normalizeInput } from "./input-normalizer";
3
5
  import { normalizeFlatConfig } from "./normalize";
4
6
  import {
@@ -48,19 +50,66 @@ type ResolvedPermissions = {
48
50
  composedRules: Ruleset;
49
51
  };
50
52
 
53
+ /**
54
+ * Narrow interface for session-scoped permission checking.
55
+ * `PermissionSession` depends on this — not the full concrete class — so
56
+ * test mocks can satisfy it without an `as unknown as PermissionManager` cast.
57
+ */
58
+ export interface ScopedPermissionManager {
59
+ configureForCwd(cwd: string | undefined | null): void;
60
+ checkPermission(
61
+ toolName: string,
62
+ input: unknown,
63
+ agentName?: string,
64
+ sessionRules?: Ruleset,
65
+ ): PermissionCheckResult;
66
+ getToolPermission(toolName: string, agentName?: string): PermissionState;
67
+ getConfigIssues(agentName?: string): string[];
68
+ getPolicyCacheStamp(agentName?: string): string;
69
+ }
70
+
51
71
  export interface PermissionManagerOptions extends PolicyLoaderOptions {
52
72
  policyLoader?: PolicyLoader;
73
+ /**
74
+ * Pi agent directory. When provided, the manager derives all loader paths
75
+ * from this value and supports {@link PermissionManager.configureForCwd}.
76
+ */
77
+ agentDir?: string;
53
78
  }
54
79
 
55
- export class PermissionManager {
56
- private readonly loader: PolicyLoader;
80
+ export class PermissionManager implements ScopedPermissionManager {
81
+ private readonly agentDir: string | undefined;
82
+ private loader: PolicyLoader;
57
83
  private readonly resolvedPermissionsCache = new Map<
58
84
  string,
59
85
  FileCacheEntry<ResolvedPermissions>
60
86
  >();
61
87
 
62
88
  constructor(options: PermissionManagerOptions = {}) {
63
- this.loader = options.policyLoader ?? new FilePolicyLoader(options);
89
+ this.agentDir = options.agentDir;
90
+ this.loader =
91
+ options.policyLoader ??
92
+ new FilePolicyLoader(
93
+ options.agentDir !== undefined
94
+ ? derivePolicyLoaderOptions(options.agentDir, undefined)
95
+ : options,
96
+ );
97
+ }
98
+
99
+ /**
100
+ * Rebuild the policy loader for a new working directory and clear the
101
+ * resolved-permissions cache.
102
+ *
103
+ * When `agentDir` was not provided at construction (e.g. test managers
104
+ * built with explicit paths), only the cache is cleared.
105
+ */
106
+ configureForCwd(cwd: string | undefined | null): void {
107
+ if (this.agentDir !== undefined) {
108
+ this.loader = new FilePolicyLoader(
109
+ derivePolicyLoaderOptions(this.agentDir, cwd),
110
+ );
111
+ }
112
+ this.resolvedPermissionsCache.clear();
64
113
  }
65
114
 
66
115
  getConfigIssues(agentName?: string): string[] {
@@ -219,6 +268,23 @@ export class PermissionManager {
219
268
  }
220
269
  }
221
270
 
271
+ /**
272
+ * Derive `PolicyLoaderOptions` from an agentDir + an optional cwd.
273
+ * Setting agentsDir explicitly from agentDir removes the hidden
274
+ * `getAgentDir()` env-read that FilePolicyLoader's default would perform.
275
+ */
276
+ function derivePolicyLoaderOptions(
277
+ agentDir: string,
278
+ cwd: string | undefined | null,
279
+ ): PolicyLoaderOptions {
280
+ return {
281
+ globalConfigPath: getGlobalConfigPath(agentDir),
282
+ agentsDir: join(agentDir, "agents"),
283
+ projectGlobalConfigPath: cwd ? getProjectConfigPath(cwd) : undefined,
284
+ projectAgentsDir: cwd ? join(cwd, ".pi", "agent", "agents") : undefined,
285
+ };
286
+ }
287
+
222
288
  /**
223
289
  * Map a matched rule + tool name to the correct PermissionCheckResult.source.
224
290
  *
@@ -1,20 +1,12 @@
1
1
  import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
2
2
  import type { PermissionSystemExtensionConfig } from "./extension-config";
3
- import type { ForwardedPermissionLogger } from "./forwarded-permissions/io";
4
- import {
5
- confirmPermission,
6
- type PermissionForwardingDeps,
7
- } from "./forwarded-permissions/polling";
8
- import type {
9
- PermissionPromptDecision,
10
- RequestPermissionOptions,
11
- } from "./permission-dialog";
3
+ import type { ApprovalRequester } from "./forwarded-permissions/permission-forwarder";
4
+ import type { PermissionPromptDecision } from "./permission-dialog";
12
5
  import {
13
6
  emitUiPromptEvent,
14
7
  type PermissionEventBus,
15
8
  } from "./permission-events";
16
9
  import { buildDirectUiPrompt } from "./permission-ui-prompt";
17
- import type { SubagentSessionRegistry } from "./subagent-registry";
18
10
  import { shouldAutoApprovePermissionState } from "./yolo-mode";
19
11
 
20
12
  export type PermissionReviewSource = "tool_call" | "skill_input" | "skill_read";
@@ -48,29 +40,18 @@ export interface PermissionPrompterApi {
48
40
  * Dependencies required by PermissionPrompter.
49
41
  *
50
42
  * Keeps the prompter's external surface narrow: callers provide config
51
- * access, review-log writing, path constants, and the UI dialog function.
52
- * The prompter synthesises the PermissionForwardingDeps it needs internally.
43
+ * access, review-log writing, the UI-prompt event bus, and the forwarder
44
+ * that owns the UI/subagent-forwarding branching logic.
53
45
  */
54
46
  export interface PermissionPrompterDeps {
55
47
  /** Read current config for yolo-mode check (called at prompt time). */
56
48
  getConfig(): PermissionSystemExtensionConfig;
57
49
  /** Write structured entries to the permission review log. */
58
50
  writeReviewLog(event: string, details: Record<string, unknown>): void;
59
- /** Directory containing subagent session state. */
60
- subagentSessionsDir: string;
61
- /** Directory used for file-based permission forwarding requests/responses. */
62
- forwardingDir: string;
63
- /** In-process subagent session registry for detection and forwarding target resolution. */
64
- registry?: SubagentSessionRegistry;
65
51
  /** Event bus used for UI prompt broadcasts. */
66
52
  events: PermissionEventBus;
67
- /** Show the interactive permission dialog in the UI. */
68
- requestPermissionDecisionFromUi(
69
- ui: ExtensionContext["ui"],
70
- title: string,
71
- message: string,
72
- options?: RequestPermissionOptions,
73
- ): Promise<PermissionPromptDecision>;
53
+ /** Resolves the permission decision: direct UI dialog or forwarded to parent. */
54
+ forwarder: ApprovalRequester;
74
55
  }
75
56
 
76
57
  /**
@@ -107,10 +88,9 @@ export class PermissionPrompter implements PermissionPrompterApi {
107
88
  emitUiPromptEvent(this.deps.events, uiPrompt);
108
89
  }
109
90
 
110
- const decision = await confirmPermission(
91
+ const decision = await this.deps.forwarder.requestApproval(
111
92
  ctx,
112
93
  details.message,
113
- this.buildForwardingDeps(),
114
94
  details.sessionLabel ? { sessionLabel: details.sessionLabel } : undefined,
115
95
  {
116
96
  source: uiPrompt.source,
@@ -158,35 +138,4 @@ export class PermissionPrompter implements PermissionPrompterApi {
158
138
  denialReason: details.denialReason ?? null,
159
139
  });
160
140
  }
161
-
162
- /**
163
- * Build a PermissionForwardingDeps to pass to confirmPermission.
164
- *
165
- * Yolo-mode is already handled at the prompter level, so shouldAutoApprove
166
- * returns false here (confirmPermission does not call it; only
167
- * processForwardedPermissionRequests does, and that has its own deps).
168
- *
169
- * The logger delegates writeReviewLog to deps and uses a no-op writeDebugLog
170
- * (trace-level forwarding debug is deferred — see open question in the plan).
171
- */
172
- private buildForwardingDeps(): PermissionForwardingDeps {
173
- const { deps } = this;
174
- const logger: ForwardedPermissionLogger = {
175
- // eslint-disable-next-line @typescript-eslint/unbound-method -- logger methods are plain function closures; no this-binding issue
176
- writeReviewLog: deps.writeReviewLog,
177
- writeDebugLog: () => undefined,
178
- };
179
- return {
180
- forwardingDir: deps.forwardingDir,
181
- subagentSessionsDir: deps.subagentSessionsDir,
182
- registry: deps.registry,
183
- events: deps.events,
184
- logger,
185
- // eslint-disable-next-line @typescript-eslint/unbound-method -- logger methods are plain function closures; no this-binding issue
186
- writeReviewLog: deps.writeReviewLog,
187
- // eslint-disable-next-line @typescript-eslint/unbound-method -- same as above
188
- requestPermissionDecisionFromUi: deps.requestPermissionDecisionFromUi,
189
- shouldAutoApprove: () => false,
190
- };
191
- }
192
141
  }
@@ -0,0 +1,17 @@
1
+ import type { PermissionCheckResult } from "./types";
2
+
3
+ /**
4
+ * Resolves the effective permission for a surface/input, applying the current
5
+ * session rules internally.
6
+ *
7
+ * Collapses the `checkPermission` + `getSessionRuleset` relay that every gate
8
+ * previously threaded by hand: the ruleset was only ever fetched to be passed
9
+ * straight back into `checkPermission`, so the two are one operation.
10
+ */
11
+ export interface PermissionResolver {
12
+ resolve(
13
+ surface: string,
14
+ input: unknown,
15
+ agentName?: string,
16
+ ): PermissionCheckResult;
17
+ }
@@ -4,18 +4,27 @@ import {
4
4
  getActiveAgentName,
5
5
  getActiveAgentNameFromSystemPrompt,
6
6
  } from "./active-agent";
7
+ import type { AgentPrepSession } from "./agent-prep-session";
7
8
  import type { PermissionSystemExtensionConfig } from "./extension-config";
8
9
  import type { ExtensionPaths } from "./extension-paths";
9
10
  import type { ForwardingController } from "./forwarding-manager";
11
+ import type { GateHandlerSession } from "./gate-handler-session";
12
+ import type { GatePrompter } from "./gate-prompter";
10
13
  import type { PermissionPromptDecision } from "./permission-dialog";
11
- import type { PermissionManager } from "./permission-manager";
14
+ import type { ScopedPermissionManager } from "./permission-manager";
12
15
  import type { PromptPermissionDetails } from "./permission-prompter";
16
+ import type { PermissionResolver } from "./permission-resolver";
13
17
  import type { Rule } from "./rule";
14
- import { createPermissionManagerForCwd } from "./runtime";
15
18
  import type { SessionApproval } from "./session-approval";
19
+ import type { SessionApprovalRecorder } from "./session-approval-recorder";
20
+ import type { SessionLifecycleSession } from "./session-lifecycle-session";
16
21
  import type { SessionLogger } from "./session-logger";
17
22
  import { SessionRules } from "./session-rules";
18
23
  import type { SkillPromptEntry } from "./skill-prompt-sanitizer";
24
+ import {
25
+ resolveToolPreviewLimits,
26
+ type ToolPreviewFormatterOptions,
27
+ } from "./tool-preview-formatter";
19
28
  import type { PermissionCheckResult, PermissionState } from "./types";
20
29
 
21
30
  /**
@@ -54,9 +63,16 @@ export interface PermissionSessionRuntimeDeps {
54
63
  * - `ForwardingController` — polling lifecycle
55
64
  * - `PermissionSessionRuntimeDeps` — config refresh + log delegates
56
65
  */
57
- export class PermissionSession {
66
+ export class PermissionSession
67
+ implements
68
+ PermissionResolver,
69
+ SessionApprovalRecorder,
70
+ GatePrompter,
71
+ GateHandlerSession,
72
+ AgentPrepSession,
73
+ SessionLifecycleSession
74
+ {
58
75
  private context: ExtensionContext | null = null;
59
- private permissionManager: PermissionManager;
60
76
  private readonly sessionRules = new SessionRules();
61
77
  private skillEntries: SkillPromptEntry[] = [];
62
78
  private knownAgentName: string | null = null;
@@ -67,13 +83,9 @@ export class PermissionSession {
67
83
  private readonly paths: ExtensionPaths,
68
84
  readonly logger: SessionLogger,
69
85
  private readonly forwarding: ForwardingController,
86
+ private readonly permissionManager: ScopedPermissionManager,
70
87
  private readonly runtimeDeps: PermissionSessionRuntimeDeps,
71
- ) {
72
- this.permissionManager = createPermissionManagerForCwd(
73
- paths.agentDir,
74
- undefined,
75
- );
76
- }
88
+ ) {}
77
89
 
78
90
  // ── Context lifecycle ──────────────────────────────────────────────────
79
91
 
@@ -110,6 +122,24 @@ export class PermissionSession {
110
122
  );
111
123
  }
112
124
 
125
+ /**
126
+ * Resolve the effective permission for a surface/input, applying the current
127
+ * session rules. Composes `checkPermission` with `getSessionRuleset` so
128
+ * callers never thread the ruleset by hand.
129
+ */
130
+ resolve(
131
+ surface: string,
132
+ input: unknown,
133
+ agentName?: string,
134
+ ): PermissionCheckResult {
135
+ return this.checkPermission(
136
+ surface,
137
+ input,
138
+ agentName,
139
+ this.getSessionRuleset(),
140
+ );
141
+ }
142
+
113
143
  getToolPermission(toolName: string, agentName?: string): PermissionState {
114
144
  return this.permissionManager.getToolPermission(toolName, agentName);
115
145
  }
@@ -137,14 +167,11 @@ export class PermissionSession {
137
167
  /**
138
168
  * Reset all mutable state for a new session.
139
169
  *
140
- * Creates a fresh PermissionManager scoped to `ctx.cwd`, clears caches,
170
+ * Configures the injected PermissionManager for `ctx.cwd`, clears caches,
141
171
  * skill entries, and activates the new context.
142
172
  */
143
173
  resetForNewSession(ctx: ExtensionContext): void {
144
- this.permissionManager = createPermissionManagerForCwd(
145
- this.paths.agentDir,
146
- ctx.cwd,
147
- );
174
+ this.permissionManager.configureForCwd(ctx.cwd);
148
175
  this.skillEntries = [];
149
176
  this.toolsCacheKey = null;
150
177
  this.promptCacheKey = null;
@@ -168,10 +195,7 @@ export class PermissionSession {
168
195
  * Used on config reload (e.g. `resources_discover` with reason "reload").
169
196
  */
170
197
  reload(): void {
171
- this.permissionManager = createPermissionManagerForCwd(
172
- this.paths.agentDir,
173
- this.context?.cwd,
174
- );
198
+ this.permissionManager.configureForCwd(this.context?.cwd);
175
199
  this.skillEntries = [];
176
200
  this.toolsCacheKey = null;
177
201
  this.promptCacheKey = null;
@@ -251,13 +275,25 @@ export class PermissionSession {
251
275
 
252
276
  // ── Infrastructure paths ───────────────────────────────────────────────
253
277
 
254
- getInfrastructureDirs(): readonly string[] {
255
- return this.paths.piInfrastructureDirs;
278
+ /**
279
+ * Combined infrastructure read directories: static paths from
280
+ * `ExtensionPaths` plus config-derived paths.
281
+ */
282
+ getInfrastructureReadDirs(): string[] {
283
+ return [
284
+ ...this.paths.piInfrastructureDirs,
285
+ ...(this.config.piInfrastructureReadPaths ?? []),
286
+ ];
256
287
  }
257
288
 
258
- /** Config-derived infrastructure read paths (current at call time). */
259
- getInfrastructureReadPaths(): string[] {
260
- return this.config.piInfrastructureReadPaths ?? [];
289
+ /**
290
+ * Resolved tool-preview formatter options from the current config.
291
+ *
292
+ * Replaces the handler's `resolveToolPreviewLimits(session.config)` reach
293
+ * so the pipeline reads a clean value rather than pulling raw config.
294
+ */
295
+ getToolPreviewLimits(): ToolPreviewFormatterOptions {
296
+ return resolveToolPreviewLimits(this.config);
261
297
  }
262
298
 
263
299
  // ── Prompting ──────────────────────────────────────────────────────────
@@ -275,8 +311,28 @@ export class PermissionSession {
275
311
  return this.runtimeDeps.promptPermission(ctx, details);
276
312
  }
277
313
 
278
- /** Generate a unique ID for a permission request. */
279
- createPermissionRequestId(prefix: string): string {
280
- return `${prefix}-${Date.now()}-${Math.random().toString(36).slice(2, 10)}-${process.pid}`;
314
+ /**
315
+ * Whether an interactive confirmation is possible using the stored context.
316
+ * Returns `false` when no context is active (before `activate` is called).
317
+ * Implements {@link GatePrompter}.
318
+ */
319
+ canConfirm(): boolean {
320
+ return this.context !== null && this.canPrompt(this.context);
321
+ }
322
+
323
+ /**
324
+ * Prompt the user for a permission decision using the stored context.
325
+ * Throws if no context is active — `canConfirm()` guards this in normal use.
326
+ * Implements {@link GatePrompter}.
327
+ */
328
+ promptPermission(
329
+ details: PromptPermissionDetails,
330
+ ): Promise<PermissionPromptDecision> {
331
+ if (this.context === null) {
332
+ return Promise.reject(
333
+ new Error("promptPermission called before the session was activated"),
334
+ );
335
+ }
336
+ return this.prompt(this.context, details);
281
337
  }
282
338
  }
@@ -0,0 +1,53 @@
1
+ import { buildInputForSurface } from "./input-normalizer";
2
+ import type { PermissionManager } from "./permission-manager";
3
+ import type { PermissionsService } from "./service";
4
+ import type { SessionRules } from "./session-rules";
5
+ import type {
6
+ ToolInputFormatter,
7
+ ToolInputFormatterRegistry,
8
+ } from "./tool-input-formatter-registry";
9
+
10
+ /**
11
+ * In-process implementation of the cross-extension {@link PermissionsService}.
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`.
18
+ */
19
+ export class LocalPermissionsService implements PermissionsService {
20
+ constructor(
21
+ private readonly permissionManager: PermissionManager,
22
+ private readonly sessionRules: SessionRules,
23
+ private readonly formatterRegistry: ToolInputFormatterRegistry,
24
+ ) {}
25
+
26
+ checkPermission(
27
+ surface: string,
28
+ value?: string,
29
+ agentName?: string,
30
+ ): ReturnType<PermissionsService["checkPermission"]> {
31
+ const input = buildInputForSurface(surface, value);
32
+ return this.permissionManager.checkPermission(
33
+ surface,
34
+ input,
35
+ agentName,
36
+ this.sessionRules.getRuleset(),
37
+ );
38
+ }
39
+
40
+ getToolPermission(
41
+ toolName: string,
42
+ agentName?: string,
43
+ ): ReturnType<PermissionsService["getToolPermission"]> {
44
+ return this.permissionManager.getToolPermission(toolName, agentName);
45
+ }
46
+
47
+ registerToolInputFormatter(
48
+ toolName: string,
49
+ formatter: ToolInputFormatter,
50
+ ): ReturnType<PermissionsService["registerToolInputFormatter"]> {
51
+ return this.formatterRegistry.register(toolName, formatter);
52
+ }
53
+ }
package/src/runtime.ts CHANGED
@@ -19,7 +19,6 @@ import {
19
19
  getLegacyExtensionConfigPath,
20
20
  getLegacyGlobalPolicyPath,
21
21
  getLegacyProjectPolicyPath,
22
- getProjectConfigPath,
23
22
  REVIEW_LOG_FILENAME,
24
23
  } from "./config-paths";
25
24
  import { buildResolvedConfigLogEntry } from "./config-reporter";
@@ -77,41 +76,6 @@ export interface ExtensionRuntime extends ExtensionPaths, SessionState {
77
76
  writeReviewLog(event: string, details?: Record<string, unknown>): void;
78
77
  }
79
78
 
80
- // ── Pure helpers ───────────────────────────────────────────────────────────
81
-
82
- /**
83
- * Derive Pi project-level config and agents paths from a working directory.
84
- * Returns null when cwd is absent (headless / global-only config).
85
- */
86
- export function derivePiProjectPaths(cwd: string | undefined | null): {
87
- projectGlobalConfigPath: string;
88
- projectAgentsDir: string;
89
- } | null {
90
- if (!cwd) {
91
- return null;
92
- }
93
- return {
94
- projectGlobalConfigPath: getProjectConfigPath(cwd),
95
- projectAgentsDir: join(cwd, ".pi", "agent", "agents"),
96
- };
97
- }
98
-
99
- /**
100
- * Create a new PermissionManager scoped to a working directory's config hierarchy.
101
- * Pass `cwd` as null/undefined to use global config only.
102
- */
103
- export function createPermissionManagerForCwd(
104
- agentDir: string,
105
- cwd: string | undefined | null,
106
- ): PermissionManager {
107
- const projectPaths = derivePiProjectPaths(cwd);
108
- return new PermissionManager({
109
- globalConfigPath: getGlobalConfigPath(agentDir),
110
- projectGlobalConfigPath: projectPaths?.projectGlobalConfigPath,
111
- projectAgentsDir: projectPaths?.projectAgentsDir,
112
- });
113
- }
114
-
115
79
  /**
116
80
  * Reload merged config from disk into the runtime.
117
81
  * If `ctx` is provided, updates `runtime.runtimeContext` first.
@@ -261,7 +225,7 @@ export function createExtensionRuntime(options?: {
261
225
  ...paths,
262
226
  config: { ...DEFAULT_EXTENSION_CONFIG },
263
227
  runtimeContext: null,
264
- permissionManager: createPermissionManagerForCwd(agentDir, undefined),
228
+ permissionManager: new PermissionManager({ agentDir }),
265
229
  activeSkillEntries: [],
266
230
  lastKnownActiveAgentName: null,
267
231
  lastActiveToolsCacheKey: null,