@komarspn/pi-permission-system 16.0.2

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 (203) hide show
  1. package/CHANGELOG.md +2234 -0
  2. package/LICENSE +21 -0
  3. package/README.md +158 -0
  4. package/config/config.example.json +39 -0
  5. package/package.json +82 -0
  6. package/schemas/permissions.schema.json +158 -0
  7. package/src/active-agent.ts +72 -0
  8. package/src/async-cache.ts +21 -0
  9. package/src/bash-arity.ts +210 -0
  10. package/src/builtin-tool-input-formatters.ts +82 -0
  11. package/src/canonicalize-path.ts +30 -0
  12. package/src/common.ts +121 -0
  13. package/src/config-loader.ts +432 -0
  14. package/src/config-modal.ts +259 -0
  15. package/src/config-paths.ts +47 -0
  16. package/src/config-reporter.ts +34 -0
  17. package/src/config-store.ts +222 -0
  18. package/src/decision-audit.ts +75 -0
  19. package/src/decision-reporter.ts +41 -0
  20. package/src/denial-messages.ts +232 -0
  21. package/src/expand-home.ts +28 -0
  22. package/src/extension-config.ts +79 -0
  23. package/src/extension-paths.ts +66 -0
  24. package/src/forwarded-permissions/io.ts +404 -0
  25. package/src/forwarded-permissions/permission-forwarder.ts +580 -0
  26. package/src/forwarding-manager.ts +74 -0
  27. package/src/gate-prompter.ts +12 -0
  28. package/src/handlers/before-agent-start.ts +94 -0
  29. package/src/handlers/gates/bash-command.ts +75 -0
  30. package/src/handlers/gates/bash-external-directory.ts +127 -0
  31. package/src/handlers/gates/bash-path-extractor.ts +15 -0
  32. package/src/handlers/gates/bash-path.ts +152 -0
  33. package/src/handlers/gates/bash-program.ts +1143 -0
  34. package/src/handlers/gates/bash-token-classification.ts +105 -0
  35. package/src/handlers/gates/candidate-check.ts +32 -0
  36. package/src/handlers/gates/descriptor.ts +81 -0
  37. package/src/handlers/gates/external-directory-messages.ts +20 -0
  38. package/src/handlers/gates/external-directory.ts +133 -0
  39. package/src/handlers/gates/helpers.ts +76 -0
  40. package/src/handlers/gates/path.ts +91 -0
  41. package/src/handlers/gates/runner.ts +186 -0
  42. package/src/handlers/gates/skill-input-gate-pipeline.ts +104 -0
  43. package/src/handlers/gates/skill-input.ts +46 -0
  44. package/src/handlers/gates/skill-read.ts +87 -0
  45. package/src/handlers/gates/tool-call-gate-pipeline.ts +129 -0
  46. package/src/handlers/gates/tool.ts +102 -0
  47. package/src/handlers/gates/types.ts +13 -0
  48. package/src/handlers/index.ts +3 -0
  49. package/src/handlers/lifecycle.ts +95 -0
  50. package/src/handlers/permission-gate-handler.ts +190 -0
  51. package/src/handlers/tool-call-boundary.ts +91 -0
  52. package/src/index.ts +225 -0
  53. package/src/input-normalizer.ts +157 -0
  54. package/src/logging.ts +113 -0
  55. package/src/mcp-targets.ts +170 -0
  56. package/src/node-modules-discovery.ts +76 -0
  57. package/src/normalize.ts +43 -0
  58. package/src/path-utils.ts +355 -0
  59. package/src/pattern-suggest.ts +132 -0
  60. package/src/permission-dialog.ts +138 -0
  61. package/src/permission-event-rpc.ts +223 -0
  62. package/src/permission-events.ts +266 -0
  63. package/src/permission-forwarding.ts +188 -0
  64. package/src/permission-gate.ts +94 -0
  65. package/src/permission-manager.ts +392 -0
  66. package/src/permission-merge.ts +32 -0
  67. package/src/permission-prompter.ts +142 -0
  68. package/src/permission-prompts.ts +93 -0
  69. package/src/permission-resolver.ts +109 -0
  70. package/src/permission-session.ts +189 -0
  71. package/src/permission-ui-prompt.ts +127 -0
  72. package/src/permissions-service.ts +63 -0
  73. package/src/persistent-approval-recorder.ts +139 -0
  74. package/src/policy-loader.ts +350 -0
  75. package/src/prompting-gateway.ts +104 -0
  76. package/src/rule.ts +188 -0
  77. package/src/scope-merge.ts +72 -0
  78. package/src/service-lifecycle.ts +49 -0
  79. package/src/service.ts +163 -0
  80. package/src/session-approval-recorder.ts +6 -0
  81. package/src/session-approval.ts +43 -0
  82. package/src/session-logger.ts +91 -0
  83. package/src/session-rules.ts +79 -0
  84. package/src/skill-prompt-sanitizer.ts +292 -0
  85. package/src/status.ts +35 -0
  86. package/src/subagent-context.ts +104 -0
  87. package/src/subagent-lifecycle-events.ts +72 -0
  88. package/src/subagent-registry.ts +105 -0
  89. package/src/synthesize.ts +92 -0
  90. package/src/system-prompt-sanitizer.ts +274 -0
  91. package/src/tool-access-extractor-registry.ts +68 -0
  92. package/src/tool-input-formatter-registry.ts +67 -0
  93. package/src/tool-input-preview.ts +34 -0
  94. package/src/tool-input-prompt-formatters.ts +63 -0
  95. package/src/tool-preview-formatter.ts +207 -0
  96. package/src/tool-registry.ts +148 -0
  97. package/src/types.ts +64 -0
  98. package/src/wildcard-matcher.ts +120 -0
  99. package/src/yolo-mode.ts +30 -0
  100. package/test/active-agent.test.ts +155 -0
  101. package/test/async-cache.test.ts +48 -0
  102. package/test/bash-arity.test.ts +144 -0
  103. package/test/bash-external-directory.test.ts +956 -0
  104. package/test/builtin-tool-input-formatters.test.ts +109 -0
  105. package/test/canonicalize-path.test.ts +93 -0
  106. package/test/common.test.ts +287 -0
  107. package/test/composition-root.test.ts +603 -0
  108. package/test/config-loader.test.ts +740 -0
  109. package/test/config-modal.test.ts +320 -0
  110. package/test/config-paths.test.ts +83 -0
  111. package/test/config-pipeline.test.ts +90 -0
  112. package/test/config-reporter.test.ts +147 -0
  113. package/test/config-store.test.ts +466 -0
  114. package/test/decision-audit.test.ts +72 -0
  115. package/test/decision-reporter.test.ts +112 -0
  116. package/test/denial-messages.test.ts +656 -0
  117. package/test/detect-permissive-bash-fallback.test.ts +56 -0
  118. package/test/expand-home.test.ts +93 -0
  119. package/test/extension-config.test.ts +129 -0
  120. package/test/extension-paths.test.ts +108 -0
  121. package/test/forwarded-permissions/io.test.ts +251 -0
  122. package/test/forwarding-manager.test.ts +194 -0
  123. package/test/handlers/before-agent-start.test.ts +317 -0
  124. package/test/handlers/external-directory-integration.test.ts +623 -0
  125. package/test/handlers/external-directory-session-dedup.test.ts +430 -0
  126. package/test/handlers/external-directory-symlink-acceptance.test.ts +149 -0
  127. package/test/handlers/gates/bash-command-metamorphic.test.ts +83 -0
  128. package/test/handlers/gates/bash-command.test.ts +191 -0
  129. package/test/handlers/gates/bash-external-directory.test.ts +269 -0
  130. package/test/handlers/gates/bash-path.test.ts +337 -0
  131. package/test/handlers/gates/bash-program.test.ts +410 -0
  132. package/test/handlers/gates/bash-token-classification.test.ts +241 -0
  133. package/test/handlers/gates/candidate-check.test.ts +52 -0
  134. package/test/handlers/gates/external-directory-messages.test.ts +61 -0
  135. package/test/handlers/gates/external-directory.test.ts +259 -0
  136. package/test/handlers/gates/helpers.test.ts +177 -0
  137. package/test/handlers/gates/path.test.ts +294 -0
  138. package/test/handlers/gates/runner.test.ts +447 -0
  139. package/test/handlers/gates/skill-input-gate-pipeline.test.ts +176 -0
  140. package/test/handlers/gates/skill-input.test.ts +131 -0
  141. package/test/handlers/gates/skill-read.test.ts +158 -0
  142. package/test/handlers/gates/tool-call-gate-pipeline.test.ts +252 -0
  143. package/test/handlers/gates/tool.test.ts +223 -0
  144. package/test/handlers/input-events.test.ts +168 -0
  145. package/test/handlers/input.test.ts +199 -0
  146. package/test/handlers/lifecycle.test.ts +221 -0
  147. package/test/handlers/tool-call-boundary.test.ts +145 -0
  148. package/test/handlers/tool-call-events.test.ts +277 -0
  149. package/test/handlers/tool-call.test.ts +395 -0
  150. package/test/handlers/validate-requested-tool.test.ts +92 -0
  151. package/test/helpers/gate-fixtures.ts +323 -0
  152. package/test/helpers/handler-fixtures.ts +335 -0
  153. package/test/helpers/make-fake-pi.ts +100 -0
  154. package/test/helpers/manager-harness.ts +112 -0
  155. package/test/helpers/session-fixtures.ts +204 -0
  156. package/test/input-normalizer.test.ts +367 -0
  157. package/test/logging.test.ts +51 -0
  158. package/test/mcp-targets.test.ts +233 -0
  159. package/test/node-modules-discovery.test.ts +97 -0
  160. package/test/normalize.test.ts +247 -0
  161. package/test/path-utils.test.ts +650 -0
  162. package/test/pattern-suggest.test.ts +248 -0
  163. package/test/permission-dialog.test.ts +241 -0
  164. package/test/permission-event-rpc.test.ts +541 -0
  165. package/test/permission-events.test.ts +402 -0
  166. package/test/permission-forwarder.test.ts +369 -0
  167. package/test/permission-forwarding.test.ts +315 -0
  168. package/test/permission-gate.test.ts +305 -0
  169. package/test/permission-manager-unified.test.ts +3368 -0
  170. package/test/permission-merge.test.ts +61 -0
  171. package/test/permission-prompter.test.ts +518 -0
  172. package/test/permission-prompts.test.ts +363 -0
  173. package/test/permission-resolver.test.ts +265 -0
  174. package/test/permission-session.test.ts +363 -0
  175. package/test/permission-ui-prompt.test.ts +146 -0
  176. package/test/permissions-service.test.ts +177 -0
  177. package/test/persistent-approval-recorder.test.ts +133 -0
  178. package/test/pi-infrastructure-read.test.ts +369 -0
  179. package/test/policy-loader.test.ts +561 -0
  180. package/test/prompting-gateway.test.ts +230 -0
  181. package/test/rule.test.ts +604 -0
  182. package/test/scope-merge.test.ts +116 -0
  183. package/test/service-lifecycle.test.ts +163 -0
  184. package/test/service.test.ts +308 -0
  185. package/test/session-approval.test.ts +75 -0
  186. package/test/session-logger.test.ts +200 -0
  187. package/test/session-rules.test.ts +304 -0
  188. package/test/session-start.test.ts +112 -0
  189. package/test/skill-prompt-sanitizer.test.ts +374 -0
  190. package/test/status.test.ts +10 -0
  191. package/test/subagent-context.test.ts +326 -0
  192. package/test/subagent-lifecycle-events.test.ts +132 -0
  193. package/test/subagent-registry.test.ts +145 -0
  194. package/test/synthesize.test.ts +300 -0
  195. package/test/system-prompt-sanitizer.test.ts +382 -0
  196. package/test/tool-access-extractor-registry.test.ts +77 -0
  197. package/test/tool-input-formatter-registry.test.ts +75 -0
  198. package/test/tool-input-preview.test.ts +129 -0
  199. package/test/tool-input-prompt-formatters.test.ts +115 -0
  200. package/test/tool-preview-formatter.test.ts +458 -0
  201. package/test/tool-registry.test.ts +197 -0
  202. package/test/wildcard-matcher.test.ts +424 -0
  203. package/test/yolo-mode.test.ts +188 -0
@@ -0,0 +1,94 @@
1
+ import type { PermissionPromptDecision } from "./permission-dialog";
2
+
3
+ /** Result of applying the permission gate. */
4
+ export type PermissionGateResult =
5
+ | {
6
+ action: "allow";
7
+ sessionApproval?: { surface: string; pattern: string };
8
+ persistentApprovalScope?: "project" | "global";
9
+ }
10
+ | { action: "block"; reason: string };
11
+
12
+ /** Everything the gate needs — no direct dependency on ExtensionContext. */
13
+ export interface PermissionGateParams {
14
+ /** The resolved permission state from checkPermission(). */
15
+ state: "allow" | "deny" | "ask";
16
+
17
+ /** Whether the current context supports interactive prompts. */
18
+ canConfirm: boolean;
19
+
20
+ /** Prompt the user for approval. Only called when state === "ask" and canConfirm is true. */
21
+ promptForApproval: () => Promise<PermissionPromptDecision>;
22
+
23
+ /**
24
+ * Session approval suggestion to record when the user selects
25
+ * "for this session". When present and the decision is `approved_for_session`,
26
+ * the result carries the suggestion back to the caller for recording.
27
+ */
28
+ sessionApproval?: { surface: string; pattern: string };
29
+
30
+ /** Write a review-log entry. Called for deny and ask-but-unavailable paths. */
31
+ writeLog: (event: string, extra: Record<string, unknown>) => void;
32
+
33
+ /** Log context fields shared across all log calls for this gate. */
34
+ logContext: Record<string, unknown>;
35
+
36
+ /** Message strings/factories for each outcome. */
37
+ messages: {
38
+ denyReason: string;
39
+ unavailableReason: string;
40
+ userDeniedReason: (decision: PermissionPromptDecision) => string;
41
+ };
42
+ }
43
+
44
+ /**
45
+ * Apply the deny/ask/allow permission gate.
46
+ *
47
+ * This is a pure decision function: all IO is injected via callbacks.
48
+ */
49
+ export async function applyPermissionGate(
50
+ params: PermissionGateParams,
51
+ ): Promise<PermissionGateResult> {
52
+ const {
53
+ state,
54
+ canConfirm,
55
+ promptForApproval,
56
+ writeLog,
57
+ logContext,
58
+ messages,
59
+ } = params;
60
+
61
+ if (state === "deny") {
62
+ writeLog("permission_request.blocked", {
63
+ ...logContext,
64
+ resolution: "policy_denied",
65
+ });
66
+ return { action: "block", reason: messages.denyReason };
67
+ }
68
+
69
+ if (state === "ask") {
70
+ if (!canConfirm) {
71
+ writeLog("permission_request.blocked", {
72
+ ...logContext,
73
+ resolution: "confirmation_unavailable",
74
+ });
75
+ return { action: "block", reason: messages.unavailableReason };
76
+ }
77
+
78
+ const decision = await promptForApproval();
79
+ if (!decision.approved) {
80
+ return { action: "block", reason: messages.userDeniedReason(decision) };
81
+ }
82
+ if (decision.state === "approved_for_session" && params.sessionApproval) {
83
+ return { action: "allow", sessionApproval: params.sessionApproval };
84
+ }
85
+ if (decision.state === "approved_for_project") {
86
+ return { action: "allow", persistentApprovalScope: "project" };
87
+ }
88
+ if (decision.state === "approved_globally") {
89
+ return { action: "allow", persistentApprovalScope: "global" };
90
+ }
91
+ }
92
+
93
+ return { action: "allow" };
94
+ }
@@ -0,0 +1,392 @@
1
+ import { join } from "node:path";
2
+ import { isPermissionState } from "./common";
3
+ import {
4
+ getGlobalConfigPath,
5
+ getProjectAgentsDir,
6
+ getProjectConfigPath,
7
+ } from "./config-paths";
8
+ import { normalizeInput } from "./input-normalizer";
9
+ import { normalizeFlatConfig } from "./normalize";
10
+ import { PATH_SURFACES } from "./path-utils";
11
+ import {
12
+ FilePolicyLoader,
13
+ type PolicyLoader,
14
+ type PolicyLoaderOptions,
15
+ type ResolvedPolicyPaths,
16
+ } from "./policy-loader";
17
+ import type { Rule, RuleOrigin, Ruleset } from "./rule";
18
+ import { evaluate, evaluateAnyValue, evaluateFirst } from "./rule";
19
+ import { mergeScopesWithOrigins } from "./scope-merge";
20
+ import {
21
+ composeRuleset,
22
+ synthesizeBaseline,
23
+ synthesizeDefaults,
24
+ } from "./synthesize";
25
+ import type {
26
+ FlatPermissionConfig,
27
+ PermissionCheckResult,
28
+ PermissionState,
29
+ } from "./types";
30
+
31
+ const BUILT_IN_TOOL_PERMISSION_NAMES = new Set([
32
+ "bash",
33
+ "read",
34
+ "write",
35
+ "edit",
36
+ "grep",
37
+ "find",
38
+ "ls",
39
+ ]);
40
+ const SPECIAL_PERMISSION_KEYS = new Set(["external_directory", "path"]);
41
+
42
+ /** Universal fallback when permission["*"] is absent from all scopes. */
43
+ const DEFAULT_UNIVERSAL_FALLBACK: PermissionState = "ask";
44
+
45
+ type FileCacheEntry<TValue> = {
46
+ stamp: string;
47
+ value: TValue;
48
+ };
49
+
50
+ type ResolvedPermissions = {
51
+ /**
52
+ * Fully composed ruleset: synthesized defaults → baseline → config.
53
+ * Session rules are appended at call-time inside checkPermission().
54
+ */
55
+ composedRules: Ruleset;
56
+ };
57
+
58
+ /**
59
+ * Narrow interface for session-scoped permission checking.
60
+ * `PermissionSession` depends on this — not the full concrete class — so
61
+ * test mocks can satisfy it without an `as unknown as PermissionManager` cast.
62
+ */
63
+ export interface ScopedPermissionManager {
64
+ configureForCwd(cwd: string | undefined | null): void;
65
+ checkPermission(
66
+ toolName: string,
67
+ input: unknown,
68
+ agentName?: string,
69
+ sessionRules?: Ruleset,
70
+ ): PermissionCheckResult;
71
+ /**
72
+ * Evaluate a path-shaped surface (`path` or `external_directory`) against a
73
+ * caller-supplied set of equivalent policy values (e.g. bash tokens already
74
+ * resolved against a preceding literal `cd`, or a path's typed and
75
+ * symlink-resolved aliases). The values are trusted because they are computed
76
+ * internally, never read from a field on raw tool input. `surface` defaults
77
+ * to `path`.
78
+ */
79
+ checkPathPolicy(
80
+ values: readonly string[],
81
+ agentName?: string,
82
+ sessionRules?: Ruleset,
83
+ surface?: string,
84
+ ): PermissionCheckResult;
85
+ getToolPermission(toolName: string, agentName?: string): PermissionState;
86
+ getConfigIssues(agentName?: string): string[];
87
+ }
88
+
89
+ export interface PermissionManagerOptions extends PolicyLoaderOptions {
90
+ policyLoader?: PolicyLoader;
91
+ /**
92
+ * Pi agent directory. When provided, the manager derives all loader paths
93
+ * from this value and supports {@link PermissionManager.configureForCwd}.
94
+ */
95
+ agentDir?: string;
96
+ }
97
+
98
+ export class PermissionManager implements ScopedPermissionManager {
99
+ private readonly agentDir: string | undefined;
100
+ private currentCwd: string | undefined;
101
+ private loader: PolicyLoader;
102
+ private readonly resolvedPermissionsCache = new Map<
103
+ string,
104
+ FileCacheEntry<ResolvedPermissions>
105
+ >();
106
+
107
+ constructor(options: PermissionManagerOptions = {}) {
108
+ this.agentDir = options.agentDir;
109
+ this.loader =
110
+ options.policyLoader ??
111
+ new FilePolicyLoader(
112
+ options.agentDir !== undefined
113
+ ? derivePolicyLoaderOptions(options.agentDir, undefined)
114
+ : options,
115
+ );
116
+ }
117
+
118
+ /**
119
+ * Rebuild the policy loader for a new working directory and clear the
120
+ * resolved-permissions cache.
121
+ *
122
+ * When `agentDir` was not provided at construction (e.g. test managers
123
+ * built with explicit paths), only the cache is cleared.
124
+ */
125
+ configureForCwd(cwd: string | undefined | null): void {
126
+ this.currentCwd =
127
+ typeof cwd === "string" && cwd.trim().length > 0 ? cwd : undefined;
128
+ if (this.agentDir !== undefined) {
129
+ this.loader = new FilePolicyLoader(
130
+ derivePolicyLoaderOptions(this.agentDir, cwd),
131
+ );
132
+ }
133
+ this.resolvedPermissionsCache.clear();
134
+ }
135
+
136
+ getConfigIssues(agentName?: string): string[] {
137
+ // Trigger a load/resolve to ensure issues are collected.
138
+ this.resolvePermissions(agentName);
139
+ return [...this.loader.getConfigIssues()];
140
+ }
141
+
142
+ getResolvedPolicyPaths(): ResolvedPolicyPaths {
143
+ return this.loader.getResolvedPolicyPaths();
144
+ }
145
+
146
+ private resolvePermissions(agentName?: string): ResolvedPermissions {
147
+ const cacheKey = agentName ?? "__global__";
148
+ const stamp = this.loader.getCacheStamp(agentName);
149
+ const cached = this.resolvedPermissionsCache.get(cacheKey);
150
+ if (cached?.stamp === stamp) {
151
+ return cached.value;
152
+ }
153
+
154
+ const globalConfig = this.loader.loadGlobalConfig();
155
+ const projectConfig = this.loader.loadProjectConfig();
156
+ const agentConfig = this.loader.loadAgentConfig(agentName);
157
+ const projectAgentConfig = this.loader.loadProjectAgentConfig(agentName);
158
+
159
+ // Merge permission objects across scopes (lowest → highest precedence),
160
+ // building a parallel origin map that tracks which scope contributed each
161
+ // (surface, pattern) entry.
162
+ const { mergedPermission, origins } = mergeScopesWithOrigins([
163
+ ["global", globalConfig],
164
+ ["project", projectConfig],
165
+ ["agent", agentConfig],
166
+ ["project-agent", projectAgentConfig],
167
+ ]);
168
+
169
+ // Extract the universal fallback from permission["*"].
170
+ // The "*" key feeds synthesizeDefaults() only — it is NOT included as a
171
+ // config rule so that extension tools fall through to source:"default".
172
+ const universalFallback = isPermissionState(mergedPermission["*"])
173
+ ? mergedPermission["*"]
174
+ : DEFAULT_UNIVERSAL_FALLBACK;
175
+ // Track which scope contributed the universal fallback.
176
+ const universalFallbackOrigin: RuleOrigin =
177
+ origins.get("*")?.get("*") ?? "builtin";
178
+
179
+ // Build config rules from everything except the universal "*" key.
180
+ const permissionWithoutUniversal: FlatPermissionConfig = Object.fromEntries(
181
+ Object.entries(mergedPermission).filter(([k]) => k !== "*"),
182
+ );
183
+
184
+ // Normalize to config rules, tagged with "config" layer and their origin.
185
+ const configRules: Ruleset = normalizeFlatConfig(
186
+ permissionWithoutUniversal,
187
+ ).map(
188
+ (r): Rule => ({
189
+ ...r,
190
+ layer: "config",
191
+ origin: origins.get(r.surface)?.get(r.pattern) ?? "builtin",
192
+ }),
193
+ );
194
+
195
+ const composedRules = composeRuleset(
196
+ synthesizeDefaults(universalFallback, universalFallbackOrigin),
197
+ synthesizeBaseline(configRules),
198
+ configRules,
199
+ );
200
+
201
+ const value: ResolvedPermissions = { composedRules };
202
+ this.resolvedPermissionsCache.set(cacheKey, { stamp, value });
203
+ return value;
204
+ }
205
+
206
+ /**
207
+ * Return the composed config-layer rules for the given agent scope.
208
+ * Used by the `/permission-system show` command to display effective rules
209
+ * with their origin annotations.
210
+ * Session rules are not included — they are runtime-only.
211
+ */
212
+ getComposedConfigRules(agentName?: string): Ruleset {
213
+ const { composedRules } = this.resolvePermissions(agentName);
214
+ return composedRules.filter((r) => r.layer === "config");
215
+ }
216
+
217
+ /**
218
+ * Get the tool-level permission state for a tool, without considering
219
+ * command-level rules. Used for tool injection decisions.
220
+ */
221
+ getToolPermission(toolName: string, agentName?: string): PermissionState {
222
+ const { composedRules } = this.resolvePermissions(agentName);
223
+ const normalizedToolName = toolName.trim();
224
+
225
+ // Special surfaces (external_directory): evaluate directly by surface name.
226
+ if (SPECIAL_PERMISSION_KEYS.has(normalizedToolName)) {
227
+ return evaluate(normalizedToolName, "*", composedRules).action;
228
+ }
229
+
230
+ // Bash, MCP, skill: evaluate with "*" value — the per-surface catch-all
231
+ // (or universal default) handles this correctly.
232
+ if (normalizedToolName === "bash") {
233
+ return evaluate("bash", "*", composedRules).action;
234
+ }
235
+ if (normalizedToolName === "mcp") {
236
+ return evaluate("mcp", "*", composedRules).action;
237
+ }
238
+ if (normalizedToolName === "skill") {
239
+ return evaluate("skill", "*", composedRules).action;
240
+ }
241
+
242
+ // Tool-name surfaces (read, write, etc. and extension tools).
243
+ return evaluate(normalizedToolName, "*", composedRules).action;
244
+ }
245
+
246
+ checkPermission(
247
+ toolName: string,
248
+ input: unknown,
249
+ agentName?: string,
250
+ sessionRules?: Ruleset,
251
+ ): PermissionCheckResult {
252
+ const { composedRules } = this.resolvePermissions(agentName);
253
+ const normalizedToolName = toolName.trim();
254
+
255
+ // Append session rules at the end (highest priority) so evaluate() handles
256
+ // them via last-match-wins — no separate per-branch pre-check needed.
257
+ const fullRules: Ruleset = sessionRules?.length
258
+ ? [...composedRules, ...sessionRules]
259
+ : composedRules;
260
+
261
+ const { surface, values, resultExtras } = normalizeInput(
262
+ normalizedToolName,
263
+ input,
264
+ this.loader.getConfiguredMcpServerNames(),
265
+ this.currentCwd,
266
+ );
267
+
268
+ return buildCheckResult(
269
+ surface,
270
+ values,
271
+ resultExtras,
272
+ normalizedToolName,
273
+ toolName,
274
+ fullRules,
275
+ );
276
+ }
277
+
278
+ checkPathPolicy(
279
+ values: readonly string[],
280
+ agentName?: string,
281
+ sessionRules?: Ruleset,
282
+ surface = "path",
283
+ ): PermissionCheckResult {
284
+ const { composedRules } = this.resolvePermissions(agentName);
285
+ const fullRules: Ruleset = sessionRules?.length
286
+ ? [...composedRules, ...sessionRules]
287
+ : composedRules;
288
+
289
+ const lookupValues = values.length > 0 ? [...values] : ["*"];
290
+ return buildCheckResult(
291
+ surface,
292
+ lookupValues,
293
+ {},
294
+ surface,
295
+ surface,
296
+ fullRules,
297
+ );
298
+ }
299
+ }
300
+
301
+ /**
302
+ * Evaluate a normalized surface/values triple and shape the result.
303
+ *
304
+ * Path surfaces use {@link evaluateAnyValue} (last-match-wins across equivalent
305
+ * aliases); every other surface keeps {@link evaluateFirst}. Shared by
306
+ * `checkPermission` and `checkPathPolicy`.
307
+ */
308
+ function buildCheckResult(
309
+ surface: string,
310
+ values: string[],
311
+ resultExtras: Record<string, unknown>,
312
+ normalizedToolName: string,
313
+ toolName: string,
314
+ fullRules: Ruleset,
315
+ ): PermissionCheckResult {
316
+ const { rule, value } = PATH_SURFACES.has(surface)
317
+ ? evaluateAnyValue(surface, values, fullRules)
318
+ : evaluateFirst(surface, values, fullRules);
319
+
320
+ // For MCP, replace the normalizer's fallback target with the actual
321
+ // matched candidate value so PermissionCheckResult.target is accurate.
322
+ const extras =
323
+ surface === "mcp" ? { ...resultExtras, target: value } : resultExtras;
324
+
325
+ return {
326
+ toolName,
327
+ state: rule.action,
328
+ reason: rule.reason,
329
+ matchedPattern:
330
+ rule.layer === "config" || rule.layer === "session"
331
+ ? rule.pattern
332
+ : undefined,
333
+ source: deriveSource(rule, normalizedToolName),
334
+ origin: rule.origin,
335
+ ...extras,
336
+ };
337
+ }
338
+
339
+ /**
340
+ * Derive `PolicyLoaderOptions` from an agentDir + an optional cwd.
341
+ * Setting agentsDir explicitly from agentDir removes the hidden
342
+ * `getAgentDir()` env-read that FilePolicyLoader's default would perform.
343
+ */
344
+ function derivePolicyLoaderOptions(
345
+ agentDir: string,
346
+ cwd: string | undefined | null,
347
+ ): PolicyLoaderOptions {
348
+ return {
349
+ globalConfigPath: getGlobalConfigPath(agentDir),
350
+ agentsDir: join(agentDir, "agents"),
351
+ projectGlobalConfigPath: cwd ? getProjectConfigPath(cwd) : undefined,
352
+ projectAgentsDir: cwd ? getProjectAgentsDir(cwd) : undefined,
353
+ };
354
+ }
355
+
356
+ /**
357
+ * Map a matched rule + tool name to the correct PermissionCheckResult.source.
358
+ *
359
+ * Mirrors the source-derivation logic from the former per-branch
360
+ * checkPermission() implementation:
361
+ *
362
+ * - session → "session" (always, all surfaces)
363
+ * - mcp + default → "default"
364
+ * - mcp + other → "mcp"
365
+ * - special → "special" (always)
366
+ * - skill → "skill" (always)
367
+ * - bash → "bash" (always)
368
+ * - built-in tool → "tool" (always)
369
+ * - extension tool → "default" when default layer, "tool" otherwise
370
+ */
371
+ function deriveSource(
372
+ rule: Rule,
373
+ toolName: string,
374
+ ): PermissionCheckResult["source"] {
375
+ if (rule.layer === "session") return "session";
376
+
377
+ if (toolName === "mcp") {
378
+ if (rule.layer === "default") return "default";
379
+ return "mcp";
380
+ }
381
+
382
+ if (SPECIAL_PERMISSION_KEYS.has(toolName)) return "special";
383
+ if (toolName === "skill") return "skill";
384
+ if (toolName === "bash") return "bash";
385
+
386
+ // Built-in tools always report "tool"; extension tools distinguish default.
387
+ if (BUILT_IN_TOOL_PERMISSION_NAMES.has(toolName)) return "tool";
388
+ return rule.layer === "default" ? "default" : "tool";
389
+ }
390
+
391
+ // Re-export types that external modules import from this file.
392
+ export type { PolicyLoader, ResolvedPolicyPaths } from "./policy-loader";
@@ -0,0 +1,32 @@
1
+ import type { FlatPermissionConfig } from "./types";
2
+
3
+ /**
4
+ * Deep-shallow merge two flat permission configs.
5
+ * Both objects → shallow-merge the pattern maps.
6
+ * Otherwise → override replaces base.
7
+ */
8
+ export function mergeFlatPermissions(
9
+ base: FlatPermissionConfig,
10
+ override: FlatPermissionConfig,
11
+ ): FlatPermissionConfig {
12
+ const merged: FlatPermissionConfig = { ...base };
13
+ for (const [key, value] of Object.entries(override)) {
14
+ const baseVal = merged[key];
15
+ /* eslint-disable @typescript-eslint/no-unnecessary-condition -- defensive null/type checks; config values may differ at runtime */
16
+ if (
17
+ typeof baseVal === "object" &&
18
+ baseVal !== null &&
19
+ typeof value === "object" &&
20
+ value !== null
21
+ ) {
22
+ /* eslint-enable @typescript-eslint/no-unnecessary-condition */
23
+ merged[key] = {
24
+ ...baseVal,
25
+ ...value,
26
+ };
27
+ } else {
28
+ merged[key] = value;
29
+ }
30
+ }
31
+ return merged;
32
+ }
@@ -0,0 +1,142 @@
1
+ import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
2
+ import type { ConfigReader } from "./config-store";
3
+ import type { ApprovalRequester } from "./forwarded-permissions/permission-forwarder";
4
+ import type { PermissionPromptDecision } from "./permission-dialog";
5
+ import {
6
+ emitUiPromptEvent,
7
+ type PermissionEventBus,
8
+ } from "./permission-events";
9
+ import { buildDirectUiPrompt } from "./permission-ui-prompt";
10
+ import type { ReviewLogger } from "./session-logger";
11
+ import { shouldAutoApprovePermissionState } from "./yolo-mode";
12
+
13
+ export type PermissionReviewSource = "tool_call" | "skill_input" | "skill_read";
14
+
15
+ /** Details passed when prompting the user for a permission decision. */
16
+ export interface PromptPermissionDetails {
17
+ requestId: string;
18
+ source: PermissionReviewSource;
19
+ agentName: string | null;
20
+ message: string;
21
+ toolCallId?: string;
22
+ toolName?: string;
23
+ skillName?: string;
24
+ path?: string;
25
+ command?: string;
26
+ target?: string;
27
+ toolInputPreview?: string;
28
+ /** Override label for the "for this session" dialog option. */
29
+ sessionLabel?: string;
30
+ }
31
+
32
+ /** Mockable contract for permission prompting. */
33
+ export interface PermissionPrompterApi {
34
+ prompt(
35
+ ctx: ExtensionContext,
36
+ details: PromptPermissionDetails,
37
+ ): Promise<PermissionPromptDecision>;
38
+ }
39
+
40
+ /**
41
+ * Dependencies required by PermissionPrompter.
42
+ *
43
+ * Keeps the prompter's external surface narrow: callers provide config
44
+ * access, a review logger, the UI-prompt event bus, and the forwarder
45
+ * that owns the UI/subagent-forwarding branching logic.
46
+ */
47
+ export interface PermissionPrompterDeps {
48
+ /** Read current config for yolo-mode check (called at prompt time). */
49
+ config: ConfigReader;
50
+ /** Write structured entries to the permission review log. */
51
+ logger: ReviewLogger;
52
+ /** Event bus used for UI prompt broadcasts. */
53
+ events: PermissionEventBus;
54
+ /** Resolves the permission decision: direct UI dialog or forwarded to parent. */
55
+ forwarder: ApprovalRequester;
56
+ }
57
+
58
+ /**
59
+ * Encapsulates the full permission-prompt flow:
60
+ * 1. Yolo-mode auto-approval check.
61
+ * 2. Review-log "waiting" entry.
62
+ * 3. UI-present vs. subagent-forwarding branching (via confirmPermission).
63
+ * 4. Review-log "approved" / "denied" entry.
64
+ *
65
+ * Injecting a single PermissionPrompter instance means adding a new prompt
66
+ * parameter (e.g. a future sessionLabel variant) only requires changing
67
+ * PromptPermissionDetails and this class — not the full threading chain.
68
+ */
69
+ export class PermissionPrompter implements PermissionPrompterApi {
70
+ constructor(private readonly deps: PermissionPrompterDeps) {}
71
+
72
+ async prompt(
73
+ ctx: ExtensionContext,
74
+ details: PromptPermissionDetails,
75
+ ): Promise<PermissionPromptDecision> {
76
+ if (shouldAutoApprovePermissionState("ask", this.deps.config.current())) {
77
+ this.writeReviewEntry("permission_request.auto_approved", details);
78
+ return { approved: true, state: "approved", autoApproved: true };
79
+ }
80
+
81
+ this.writeReviewEntry("permission_request.waiting", details);
82
+
83
+ // Build the event once. When this session has UI it broadcasts directly;
84
+ // when it does not (a forwarding subagent), the display fields ride along
85
+ // to the parent so the parent emits a non-degraded event from the
86
+ // forwarded path instead of here.
87
+ const uiPrompt = buildDirectUiPrompt(details);
88
+ if (ctx.hasUI) {
89
+ emitUiPromptEvent(this.deps.events, uiPrompt);
90
+ }
91
+
92
+ const decision = await this.deps.forwarder.requestApproval(
93
+ ctx,
94
+ details.message,
95
+ details.sessionLabel ? { sessionLabel: details.sessionLabel } : undefined,
96
+ {
97
+ source: uiPrompt.source,
98
+ surface: uiPrompt.surface,
99
+ value: uiPrompt.value,
100
+ },
101
+ );
102
+
103
+ this.writeReviewEntry(
104
+ decision.approved
105
+ ? "permission_request.approved"
106
+ : "permission_request.denied",
107
+ {
108
+ ...details,
109
+ resolution: decision.state,
110
+ denialReason: decision.denialReason,
111
+ },
112
+ );
113
+
114
+ return decision;
115
+ }
116
+
117
+ // ── Private helpers ──────────────────────────────────────────────────────
118
+
119
+ private writeReviewEntry(
120
+ event: string,
121
+ details: PromptPermissionDetails & {
122
+ resolution?: string;
123
+ denialReason?: string;
124
+ },
125
+ ): void {
126
+ this.deps.logger.review(event, {
127
+ requestId: details.requestId,
128
+ source: details.source,
129
+ agentName: details.agentName,
130
+ message: details.message,
131
+ toolCallId: details.toolCallId ?? null,
132
+ toolName: details.toolName ?? null,
133
+ skillName: details.skillName ?? null,
134
+ path: details.path ?? null,
135
+ command: details.command ?? null,
136
+ target: details.target ?? null,
137
+ toolInputPreview: details.toolInputPreview ?? null,
138
+ resolution: details.resolution ?? null,
139
+ denialReason: details.denialReason ?? null,
140
+ });
141
+ }
142
+ }