@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,432 @@
1
+ import { existsSync, readFileSync } from "node:fs";
2
+ import { normalize } from "node:path";
3
+ import {
4
+ isDenyWithReason,
5
+ isPermissionState,
6
+ normalizeOptionalPositiveInt,
7
+ normalizeOptionalStringArray,
8
+ toRecord,
9
+ } from "./common";
10
+ import {
11
+ getGlobalConfigPath,
12
+ getLegacyExtensionConfigPath,
13
+ getLegacyGlobalPolicyPath,
14
+ getLegacyProjectPolicyPath,
15
+ getProjectConfigPath,
16
+ } from "./config-paths";
17
+ import { mergeFlatPermissions } from "./permission-merge";
18
+ import type { FlatPermissionConfig, PatternValue } from "./types";
19
+
20
+ /**
21
+ * Unified config shape combining runtime knobs and flat permission policy.
22
+ * All fields are optional so partial configs (project-only, global-only) work.
23
+ */
24
+ export interface UnifiedPermissionConfig {
25
+ // Runtime knobs
26
+ debugLog?: boolean;
27
+ permissionReviewLog?: boolean;
28
+ yoloMode?: boolean;
29
+ toolInputPreviewMaxLength?: number;
30
+ toolTextSummaryMaxLength?: number;
31
+ piInfrastructureReadPaths?: string[];
32
+
33
+ // Flat permission policy
34
+ permission?: FlatPermissionConfig;
35
+ }
36
+
37
+ export interface UnifiedConfigLoadResult {
38
+ config: UnifiedPermissionConfig;
39
+ issues: string[];
40
+ }
41
+
42
+ export function stripJsonComments(input: string): string {
43
+ let output = "";
44
+ let i = 0;
45
+ while (i < input.length) {
46
+ const char = input[i];
47
+ const next = input[i + 1] ?? "";
48
+
49
+ if (char === "/" && next === "/") {
50
+ const seg = consumeLineComment(input, i);
51
+ output += seg.output;
52
+ i = seg.nextIndex;
53
+ continue;
54
+ }
55
+ if (char === "/" && next === "*") {
56
+ const seg = consumeBlockComment(input, i);
57
+ output += seg.output;
58
+ i = seg.nextIndex;
59
+ continue;
60
+ }
61
+ if (char === '"' || char === "'") {
62
+ const seg = consumeString(input, i);
63
+ output += seg.output;
64
+ i = seg.nextIndex;
65
+ continue;
66
+ }
67
+
68
+ output += char;
69
+ i++;
70
+ }
71
+ return output;
72
+ }
73
+
74
+ /** A consumed run of source: the text to emit and the index to resume scanning. */
75
+ interface ScanSegment {
76
+ output: string;
77
+ nextIndex: number;
78
+ }
79
+
80
+ /** Consume a `//` line comment starting at `start`; drop the body, keep the newline. */
81
+ function consumeLineComment(input: string, start: number): ScanSegment {
82
+ const newlineIndex = input.indexOf("\n", start);
83
+ if (newlineIndex === -1) return { output: "", nextIndex: input.length };
84
+ return { output: "\n", nextIndex: newlineIndex + 1 };
85
+ }
86
+
87
+ /** Consume a block comment starting at `start`; drop it entirely. */
88
+ function consumeBlockComment(input: string, start: number): ScanSegment {
89
+ const closeIndex = input.indexOf("*/", start + 2);
90
+ if (closeIndex === -1) return { output: "", nextIndex: input.length };
91
+ return { output: "", nextIndex: closeIndex + 2 };
92
+ }
93
+
94
+ /**
95
+ * Consume a string literal starting at the opening quote at `start`.
96
+ * Honors backslash escapes so an escaped quote does not close the literal.
97
+ * Emits the opening quote, body, and closing quote verbatim.
98
+ */
99
+ function consumeString(input: string, start: number): ScanSegment {
100
+ const quote = input[start];
101
+ let output = quote;
102
+ let i = start + 1;
103
+ let escaping = false;
104
+ while (i < input.length) {
105
+ const char = input[i];
106
+ output += char;
107
+ i++;
108
+ if (escaping) {
109
+ escaping = false;
110
+ continue;
111
+ }
112
+ if (char === "\\") {
113
+ escaping = true;
114
+ continue;
115
+ }
116
+ if (char === quote) break;
117
+ }
118
+ return { output, nextIndex: i };
119
+ }
120
+
121
+ function normalizeOptionalBoolean(value: unknown): boolean | undefined {
122
+ if (typeof value === "boolean") {
123
+ return value;
124
+ }
125
+ return undefined;
126
+ }
127
+
128
+ /**
129
+ * Normalize a raw `permission` value from parsed JSON into a FlatPermissionConfig.
130
+ * Accepts PermissionState strings and DenyWithReason objects inside pattern
131
+ * maps. Drops non-object top-level values, invalid PermissionState strings, and
132
+ * invalid action values inside object maps.
133
+ */
134
+ function normalizeFlatPermissionValue(
135
+ value: unknown,
136
+ ): FlatPermissionConfig | undefined {
137
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
138
+ return undefined;
139
+ }
140
+ const record = value as Record<string, unknown>;
141
+ const normalized: FlatPermissionConfig = {};
142
+ let hasAny = false;
143
+
144
+ for (const [key, val] of Object.entries(record)) {
145
+ if (typeof val === "string") {
146
+ if (isPermissionState(val)) {
147
+ normalized[key] = val;
148
+ hasAny = true;
149
+ }
150
+ } else if (typeof val === "object" && val !== null && !Array.isArray(val)) {
151
+ const map: Record<string, PatternValue> = {};
152
+ let mapHasAny = false;
153
+ for (const [pattern, action] of Object.entries(
154
+ val as Record<string, unknown>,
155
+ )) {
156
+ if (isDenyWithReason(action)) {
157
+ map[pattern] = action;
158
+ mapHasAny = true;
159
+ } else if (isPermissionState(action)) {
160
+ map[pattern] = action;
161
+ mapHasAny = true;
162
+ }
163
+ }
164
+ if (mapHasAny) {
165
+ normalized[key] = map;
166
+ hasAny = true;
167
+ }
168
+ }
169
+ }
170
+
171
+ return hasAny ? normalized : undefined;
172
+ }
173
+
174
+ /**
175
+ * Normalize raw parsed JSON into the unified config shape.
176
+ */
177
+ export function normalizeUnifiedConfig(raw: unknown): {
178
+ config: UnifiedPermissionConfig;
179
+ issues: string[];
180
+ } {
181
+ const record = toRecord(raw);
182
+ const issues: string[] = [];
183
+ const config: UnifiedPermissionConfig = {};
184
+
185
+ // Runtime knobs
186
+ const debugLog = normalizeOptionalBoolean(record.debugLog);
187
+ if (debugLog !== undefined) config.debugLog = debugLog;
188
+
189
+ const permissionReviewLog = normalizeOptionalBoolean(
190
+ record.permissionReviewLog,
191
+ );
192
+ if (permissionReviewLog !== undefined)
193
+ config.permissionReviewLog = permissionReviewLog;
194
+
195
+ const yoloMode = normalizeOptionalBoolean(record.yoloMode);
196
+ if (yoloMode !== undefined) config.yoloMode = yoloMode;
197
+
198
+ const toolInputPreviewMaxLength = normalizeOptionalPositiveInt(
199
+ record.toolInputPreviewMaxLength,
200
+ );
201
+ if (toolInputPreviewMaxLength !== undefined)
202
+ config.toolInputPreviewMaxLength = toolInputPreviewMaxLength;
203
+
204
+ const toolTextSummaryMaxLength = normalizeOptionalPositiveInt(
205
+ record.toolTextSummaryMaxLength,
206
+ );
207
+ if (toolTextSummaryMaxLength !== undefined)
208
+ config.toolTextSummaryMaxLength = toolTextSummaryMaxLength;
209
+
210
+ const piInfrastructureReadPaths = normalizeOptionalStringArray(
211
+ record.piInfrastructureReadPaths,
212
+ );
213
+ if (piInfrastructureReadPaths !== undefined)
214
+ config.piInfrastructureReadPaths = piInfrastructureReadPaths;
215
+
216
+ // Flat permission policy
217
+ const permission = normalizeFlatPermissionValue(record.permission);
218
+ if (permission !== undefined) config.permission = permission;
219
+
220
+ return { config, issues };
221
+ }
222
+
223
+ /**
224
+ * Merge two unified configs.
225
+ * - `permission` is deep-shallow merged (surface-level object maps are shallow-merged).
226
+ * - Scalar fields (debugLog, permissionReviewLog, yoloMode) are replaced when
227
+ * present in the override.
228
+ * - Array fields (piInfrastructureReadPaths) replace the base when present in
229
+ * the override (override-wins, same as scalars).
230
+ */
231
+ export function mergeUnifiedConfigs(
232
+ base: UnifiedPermissionConfig,
233
+ override: UnifiedPermissionConfig,
234
+ ): UnifiedPermissionConfig {
235
+ const merged: UnifiedPermissionConfig = {};
236
+
237
+ // Boolean scalars: override replaces base when defined
238
+ for (const key of ["debugLog", "permissionReviewLog", "yoloMode"] as const) {
239
+ const value = override[key] ?? base[key];
240
+ if (value !== undefined) {
241
+ merged[key] = value;
242
+ }
243
+ }
244
+
245
+ // Number scalars: override replaces base when defined
246
+ for (const key of [
247
+ "toolInputPreviewMaxLength",
248
+ "toolTextSummaryMaxLength",
249
+ ] as const) {
250
+ const value = override[key] ?? base[key];
251
+ if (value !== undefined) {
252
+ merged[key] = value;
253
+ }
254
+ }
255
+
256
+ // Array fields: override replaces base when defined
257
+ const piInfrastructureReadPaths =
258
+ override.piInfrastructureReadPaths ?? base.piInfrastructureReadPaths;
259
+ if (piInfrastructureReadPaths !== undefined) {
260
+ merged.piInfrastructureReadPaths = piInfrastructureReadPaths;
261
+ }
262
+
263
+ // Permission: deep-shallow merge
264
+ const basePerm = base.permission;
265
+ const overridePerm = override.permission;
266
+ if (basePerm && overridePerm) {
267
+ merged.permission = mergeFlatPermissions(basePerm, overridePerm);
268
+ } else if (basePerm) {
269
+ merged.permission = basePerm;
270
+ } else if (overridePerm) {
271
+ merged.permission = overridePerm;
272
+ }
273
+
274
+ return merged;
275
+ }
276
+
277
+ export interface MergedConfigResult {
278
+ global: UnifiedPermissionConfig;
279
+ project: UnifiedPermissionConfig;
280
+ merged: UnifiedPermissionConfig;
281
+ issues: string[];
282
+ }
283
+
284
+ /**
285
+ * Load global and project configs from the new layout, detect legacy files,
286
+ * merge everything, and collect issues.
287
+ *
288
+ * Merge order:
289
+ * 1. Legacy global policy (if present) — lowest precedence
290
+ * 2. Legacy extension runtime config (if present and path differs from new global)
291
+ * 3. New global config
292
+ * 4. Legacy project policy (if present)
293
+ * 5. New project config — highest precedence
294
+ *
295
+ * Legacy files are detected and warned about. Their content is parsed with the
296
+ * flat-format parser — legacy-format keys (defaultPolicy, tools, bash, etc.)
297
+ * are not translated and contribute no permission rules.
298
+ */
299
+ export function loadAndMergeConfigs(
300
+ agentDir: string,
301
+ cwd: string,
302
+ extensionRoot: string,
303
+ ): MergedConfigResult {
304
+ const allIssues: string[] = [];
305
+
306
+ const newGlobalPath = getGlobalConfigPath(agentDir);
307
+ const newProjectPath = getProjectConfigPath(cwd);
308
+ const legacyGlobalPolicyPath = getLegacyGlobalPolicyPath(agentDir);
309
+ const legacyProjectPolicyPath = getLegacyProjectPolicyPath(cwd);
310
+ const legacyExtConfigPath = getLegacyExtensionConfigPath(extensionRoot);
311
+
312
+ // Start with empty
313
+ let merged: UnifiedPermissionConfig = {};
314
+
315
+ // 1. Legacy global policy
316
+ if (existsSync(legacyGlobalPolicyPath)) {
317
+ const legacy = loadUnifiedConfig(legacyGlobalPolicyPath);
318
+ allIssues.push(
319
+ `Legacy global policy found at '${legacyGlobalPolicyPath}'. ` +
320
+ `Move it to '${newGlobalPath}':\n` +
321
+ ` mv '${legacyGlobalPolicyPath}' '${newGlobalPath}'`,
322
+ );
323
+ allIssues.push(...legacy.issues);
324
+ merged = mergeUnifiedConfigs(merged, legacy.config);
325
+ }
326
+
327
+ // 2. Legacy extension runtime config (only if different from new global path)
328
+ const normalizedLegacyExt = normalize(legacyExtConfigPath);
329
+ const normalizedNewGlobal = normalize(newGlobalPath);
330
+ if (
331
+ normalizedLegacyExt !== normalizedNewGlobal &&
332
+ existsSync(legacyExtConfigPath)
333
+ ) {
334
+ const legacy = loadUnifiedConfig(legacyExtConfigPath);
335
+ allIssues.push(
336
+ `Legacy extension config found at '${legacyExtConfigPath}'. ` +
337
+ `Move runtime settings to '${newGlobalPath}':\n` +
338
+ ` mv '${legacyExtConfigPath}' '${newGlobalPath}'`,
339
+ );
340
+ allIssues.push(...legacy.issues);
341
+ merged = mergeUnifiedConfigs(merged, legacy.config);
342
+ }
343
+
344
+ // 3. New global config
345
+ const globalResult = loadUnifiedConfig(newGlobalPath);
346
+ allIssues.push(...globalResult.issues);
347
+ const globalConfig = globalResult.config;
348
+ merged = mergeUnifiedConfigs(merged, globalConfig);
349
+
350
+ // 4. Legacy project policy
351
+ if (existsSync(legacyProjectPolicyPath)) {
352
+ const legacy = loadUnifiedConfig(legacyProjectPolicyPath);
353
+ allIssues.push(
354
+ `Legacy project policy found at '${legacyProjectPolicyPath}'. ` +
355
+ `Move it to '${newProjectPath}':\n` +
356
+ ` mv '${legacyProjectPolicyPath}' '${newProjectPath}'`,
357
+ );
358
+ allIssues.push(...legacy.issues);
359
+ merged = mergeUnifiedConfigs(merged, legacy.config);
360
+ }
361
+
362
+ // 5. New project config
363
+ const projectResult = loadUnifiedConfig(newProjectPath);
364
+ allIssues.push(...projectResult.issues);
365
+ const projectConfig = projectResult.config;
366
+ merged = mergeUnifiedConfigs(merged, projectConfig);
367
+
368
+ const bashFallbackIssue = detectPermissiveBashFallback(merged.permission);
369
+ if (bashFallbackIssue) allIssues.push(bashFallbackIssue);
370
+
371
+ return {
372
+ global: globalConfig,
373
+ project: projectConfig,
374
+ merged,
375
+ issues: allIssues,
376
+ };
377
+ }
378
+
379
+ /**
380
+ * Detect the config footgun where a permissive top-level `*: allow` leaves the
381
+ * bash surface ungated, so every bash command silently inherits `allow`.
382
+ *
383
+ * Returns one warning string when `permission["*"] === "allow"` and the `bash`
384
+ * surface neither is a bare string (shorthand for `{ "*": … }`) nor an object
385
+ * map with an explicit `"*"` key. Returns `undefined` otherwise. The detector
386
+ * is pure: it takes the merged permission map and returns a message; the caller
387
+ * owns pushing it onto the issue list.
388
+ */
389
+ export function detectPermissiveBashFallback(
390
+ permission: FlatPermissionConfig | undefined,
391
+ ): string | undefined {
392
+ if (permission?.["*"] !== "allow") return undefined;
393
+
394
+ // The Record index signature reports an absent surface as the value type, not
395
+ // `undefined`; read through a Partial view so the absent-bash guard is honest
396
+ // (an unguarded Object.hasOwn(undefined, …) would throw at runtime).
397
+ const surfaces: Partial<FlatPermissionConfig> = permission;
398
+ const bash = surfaces.bash;
399
+ // A bare string surface is shorthand for `{ "*": action }` — explicitly gated.
400
+ if (typeof bash === "string") return undefined;
401
+ // An object map with an explicit `"*"` key is explicitly gated.
402
+ if (bash && Object.hasOwn(bash, "*")) return undefined;
403
+
404
+ return (
405
+ "Permission config sets a permissive top-level '*': 'allow' with no 'bash' '*' policy, " +
406
+ "so bash commands silently inherit 'allow'. Set an explicit 'bash' policy " +
407
+ '(e.g. "bash": { "*": "ask" }) to gate bash commands.'
408
+ );
409
+ }
410
+
411
+ /**
412
+ * Load and normalize a unified config file.
413
+ * Returns an empty config with no issues if the file does not exist.
414
+ * Returns an empty config with an issue if the file cannot be parsed.
415
+ */
416
+ export function loadUnifiedConfig(path: string): UnifiedConfigLoadResult {
417
+ if (!existsSync(path)) {
418
+ return { config: {}, issues: [] };
419
+ }
420
+
421
+ try {
422
+ const raw = readFileSync(path, "utf-8");
423
+ const parsed = JSON.parse(stripJsonComments(raw)) as unknown;
424
+ return normalizeUnifiedConfig(parsed);
425
+ } catch (error) {
426
+ const message = error instanceof Error ? error.message : String(error);
427
+ return {
428
+ config: {},
429
+ issues: [`Failed to read config at '${path}': ${message}`],
430
+ };
431
+ }
432
+ }
@@ -0,0 +1,259 @@
1
+ import {
2
+ type ExtensionAPI,
3
+ type ExtensionCommandContext,
4
+ getSettingsListTheme,
5
+ } from "@earendil-works/pi-coding-agent";
6
+ import { type SettingItem, SettingsList } from "@earendil-works/pi-tui";
7
+
8
+ import type { CommandConfigStore } from "./config-store";
9
+ import {
10
+ DEFAULT_EXTENSION_CONFIG,
11
+ type PermissionSystemExtensionConfig,
12
+ } from "./extension-config";
13
+ import type { Ruleset } from "./rule";
14
+
15
+ interface PermissionSystemConfigController {
16
+ config: CommandConfigStore;
17
+ /** Precomputed global config file path. */
18
+ configPath: string;
19
+ /** Returns the composed config-layer ruleset for the active agent scope. */
20
+ getActiveAgentConfigRules(): Ruleset;
21
+ }
22
+
23
+ const ON_OFF = ["on", "off"];
24
+ const COMMAND_ARGUMENTS = [
25
+ {
26
+ value: "show",
27
+ label: "Show active settings",
28
+ description: "Display the current permission-system config summary",
29
+ },
30
+ {
31
+ value: "path",
32
+ label: "Show config path",
33
+ description: "Display the config.json path used by pi-permission-system",
34
+ },
35
+ {
36
+ value: "reset",
37
+ label: "Reset defaults",
38
+ description: "Restore default yolo/logging settings and persist them",
39
+ },
40
+ {
41
+ value: "help",
42
+ label: "Show help",
43
+ description: "Display command usage",
44
+ },
45
+ ] as const;
46
+ const USAGE_TEXT =
47
+ "Usage: /permission-system [show|path|reset|help] (or run /permission-system with no args to open settings modal)";
48
+
49
+ function cloneDefaultConfig(): PermissionSystemExtensionConfig {
50
+ return {
51
+ debugLog: DEFAULT_EXTENSION_CONFIG.debugLog,
52
+ permissionReviewLog: DEFAULT_EXTENSION_CONFIG.permissionReviewLog,
53
+ yoloMode: DEFAULT_EXTENSION_CONFIG.yoloMode,
54
+ };
55
+ }
56
+
57
+ function toOnOff(value: boolean): string {
58
+ return value ? "on" : "off";
59
+ }
60
+
61
+ function formatRulesSummary(rules: Ruleset): string {
62
+ // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- origin may be absent despite its type
63
+ const configRules = rules.filter((r) => r.layer === "config" && r.origin);
64
+ if (configRules.length === 0) return "";
65
+ const formatted = configRules
66
+ .map((r) => {
67
+ const key =
68
+ r.pattern === "*" ? r.surface : `${r.surface}["${r.pattern}"]`;
69
+ return `${key}=${r.action} (${r.origin})`;
70
+ })
71
+ .join(", ");
72
+ return `\n rules: ${formatted}`;
73
+ }
74
+
75
+ function summarizeConfig(
76
+ config: PermissionSystemExtensionConfig,
77
+ rules?: Ruleset,
78
+ ): string {
79
+ const knobs = [
80
+ `yoloMode=${toOnOff(config.yoloMode)}`,
81
+ `permissionReviewLog=${toOnOff(config.permissionReviewLog)}`,
82
+ `debugLog=${toOnOff(config.debugLog)}`,
83
+ ].join(", ");
84
+ const rulesSuffix = rules ? formatRulesSummary(rules) : "";
85
+ return `${knobs}${rulesSuffix}`;
86
+ }
87
+
88
+ function buildSettingItems(
89
+ config: PermissionSystemExtensionConfig,
90
+ ): SettingItem[] {
91
+ return [
92
+ {
93
+ id: "yoloMode",
94
+ label: "YOLO mode",
95
+ description:
96
+ "Auto-approve ask-state permission checks, including subagent approval forwarding",
97
+ currentValue: toOnOff(config.yoloMode),
98
+ values: ON_OFF,
99
+ },
100
+ {
101
+ id: "permissionReviewLog",
102
+ label: "Permission review log",
103
+ description:
104
+ "Write permission request and decision audit events to the extension logs directory",
105
+ currentValue: toOnOff(config.permissionReviewLog),
106
+ values: ON_OFF,
107
+ },
108
+ {
109
+ id: "debugLog",
110
+ label: "Debug logging",
111
+ description:
112
+ "Write verbose permission-system diagnostics to the extension logs directory",
113
+ currentValue: toOnOff(config.debugLog),
114
+ values: ON_OFF,
115
+ },
116
+ ];
117
+ }
118
+
119
+ function applySetting(
120
+ config: PermissionSystemExtensionConfig,
121
+ id: string,
122
+ value: string,
123
+ ): PermissionSystemExtensionConfig {
124
+ switch (id) {
125
+ case "yoloMode":
126
+ return { ...config, yoloMode: value === "on" };
127
+ case "permissionReviewLog":
128
+ return { ...config, permissionReviewLog: value === "on" };
129
+ case "debugLog":
130
+ return { ...config, debugLog: value === "on" };
131
+ default:
132
+ return config;
133
+ }
134
+ }
135
+
136
+ function syncSettingValues(
137
+ settingsList: SettingsList,
138
+ config: PermissionSystemExtensionConfig,
139
+ ): void {
140
+ settingsList.updateValue("yoloMode", toOnOff(config.yoloMode));
141
+ settingsList.updateValue(
142
+ "permissionReviewLog",
143
+ toOnOff(config.permissionReviewLog),
144
+ );
145
+ settingsList.updateValue("debugLog", toOnOff(config.debugLog));
146
+ }
147
+
148
+ function getArgumentCompletions(
149
+ argumentPrefix: string,
150
+ ): Array<{ value: string; label: string; description: string }> | null {
151
+ const normalized = argumentPrefix.trim().toLowerCase();
152
+ if (normalized.includes(" ")) {
153
+ return null;
154
+ }
155
+
156
+ const filtered = COMMAND_ARGUMENTS.filter((item) =>
157
+ item.value.startsWith(normalized),
158
+ );
159
+ return filtered.length > 0 ? [...filtered] : null;
160
+ }
161
+
162
+ async function openSettingsModal(
163
+ ctx: ExtensionCommandContext,
164
+ controller: PermissionSystemConfigController,
165
+ ): Promise<void> {
166
+ const overlayOptions = {
167
+ anchor: "center" as const,
168
+ width: 82,
169
+ maxHeight: "85%" as const,
170
+ margin: 1,
171
+ };
172
+
173
+ // eslint-disable-next-line @typescript-eslint/no-invalid-void-type -- ctx.ui.custom<void> is valid; rule does not allow void in generic fn call type args
174
+ await ctx.ui.custom<void>(
175
+ (_tui, _theme, _keybindings, done) => {
176
+ let current = controller.config.current();
177
+ const settingsList = new SettingsList(
178
+ buildSettingItems(current),
179
+ 10,
180
+ getSettingsListTheme(),
181
+ (id, newValue) => {
182
+ current = applySetting(current, id, newValue);
183
+ controller.config.save(current, ctx);
184
+ current = controller.config.current();
185
+ syncSettingValues(settingsList, current);
186
+ },
187
+ () => done(),
188
+ );
189
+
190
+ return settingsList;
191
+ },
192
+ { overlay: true, overlayOptions },
193
+ );
194
+ }
195
+
196
+ function handleArgs(
197
+ args: string,
198
+ ctx: ExtensionCommandContext,
199
+ controller: PermissionSystemConfigController,
200
+ ): boolean {
201
+ const normalized = args.trim().toLowerCase();
202
+ if (!normalized) {
203
+ return false;
204
+ }
205
+
206
+ if (normalized === "show") {
207
+ const rules = controller.getActiveAgentConfigRules();
208
+ ctx.ui.notify(
209
+ `permission-system: ${summarizeConfig(controller.config.current(), rules)}`,
210
+ "info",
211
+ );
212
+ return true;
213
+ }
214
+
215
+ if (normalized === "path") {
216
+ ctx.ui.notify(`permission-system config: ${controller.configPath}`, "info");
217
+ return true;
218
+ }
219
+
220
+ if (normalized === "reset") {
221
+ controller.config.save(cloneDefaultConfig(), ctx);
222
+ ctx.ui.notify("Permission system settings reset to defaults.", "info");
223
+ return true;
224
+ }
225
+
226
+ if (normalized === "help") {
227
+ ctx.ui.notify(USAGE_TEXT, "info");
228
+ return true;
229
+ }
230
+
231
+ ctx.ui.notify(USAGE_TEXT, "warning");
232
+ return true;
233
+ }
234
+
235
+ export function registerPermissionSystemCommand(
236
+ pi: ExtensionAPI,
237
+ controller: PermissionSystemConfigController,
238
+ ): void {
239
+ pi.registerCommand("permission-system", {
240
+ description:
241
+ "Configure pi-permission-system logging and yolo-mode behavior",
242
+ getArgumentCompletions,
243
+ handler: async (args, ctx) => {
244
+ if (handleArgs(args, ctx, controller)) {
245
+ return;
246
+ }
247
+
248
+ if (!ctx.hasUI) {
249
+ ctx.ui.notify(
250
+ "/permission-system requires interactive TUI mode.",
251
+ "warning",
252
+ );
253
+ return;
254
+ }
255
+
256
+ await openSettingsModal(ctx, controller);
257
+ },
258
+ });
259
+ }