@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,274 @@
1
+ export interface SanitizeSystemPromptResult {
2
+ prompt: string;
3
+ removed: boolean;
4
+ }
5
+
6
+ type LineSection = {
7
+ start: number;
8
+ end: number;
9
+ };
10
+
11
+ type GuidelineRule = {
12
+ matches: (guideline: string) => boolean;
13
+ shouldKeep: (allowedTools: ReadonlySet<string>) => boolean;
14
+ };
15
+
16
+ const AVAILABLE_TOOLS_SECTION_HEADER = "Available tools:";
17
+ const GUIDELINES_SECTION_HEADER = "Guidelines:";
18
+
19
+ const TOOL_GUIDELINE_RULES: readonly GuidelineRule[] = [
20
+ {
21
+ matches: (guideline) =>
22
+ guideline === "use bash for file operations like ls, rg, find",
23
+ shouldKeep: (allowedTools) => allowedTools.has("bash"),
24
+ },
25
+ {
26
+ matches: (guideline) =>
27
+ guideline ===
28
+ "prefer grep/find/ls tools over bash for file exploration (faster, respects .gitignore)",
29
+ shouldKeep: (allowedTools) =>
30
+ allowedTools.has("bash") &&
31
+ (allowedTools.has("grep") ||
32
+ allowedTools.has("find") ||
33
+ allowedTools.has("ls")),
34
+ },
35
+ {
36
+ matches: (guideline) =>
37
+ guideline ===
38
+ "use read to examine files before editing. you must use this tool instead of cat or sed." ||
39
+ guideline === "use read to examine files instead of cat or sed.",
40
+ shouldKeep: (allowedTools) => allowedTools.has("read"),
41
+ },
42
+ {
43
+ matches: (guideline) =>
44
+ guideline ===
45
+ "use edit for precise changes (old text must match exactly)",
46
+ shouldKeep: (allowedTools) => allowedTools.has("edit"),
47
+ },
48
+ {
49
+ matches: (guideline) =>
50
+ guideline === "use write only for new files or complete rewrites",
51
+ shouldKeep: (allowedTools) => allowedTools.has("write"),
52
+ },
53
+ {
54
+ matches: (guideline) =>
55
+ guideline ===
56
+ "when summarizing your actions, output plain text directly - do not use cat or bash to display what you did",
57
+ shouldKeep: (allowedTools) =>
58
+ allowedTools.has("edit") || allowedTools.has("write"),
59
+ },
60
+ {
61
+ matches: (guideline) =>
62
+ guideline ===
63
+ "use task when work should be delegated to one or more specialized agents instead of handled entirely in the current session.",
64
+ shouldKeep: (allowedTools) => allowedTools.has("task"),
65
+ },
66
+ {
67
+ matches: (guideline) =>
68
+ guideline ===
69
+ "use mcp for mcp discovery first: search by capability, describe one exact tool name, then call it.",
70
+ shouldKeep: (allowedTools) => allowedTools.has("mcp"),
71
+ },
72
+ ];
73
+
74
+ function normalizePrompt(prompt: string): string {
75
+ return (prompt || "").replace(/\r\n/g, "\n");
76
+ }
77
+
78
+ function collapseExtraBlankLines(text: string): string {
79
+ return text.replace(/\n{3,}/g, "\n\n").trimEnd();
80
+ }
81
+
82
+ function normalizeGuidelineText(line: string): string {
83
+ return line
84
+ .trim()
85
+ .replace(/^[-*]\s+/, "")
86
+ .replace(/\s+/g, " ")
87
+ .toLowerCase();
88
+ }
89
+
90
+ function isTopLevelSectionHeader(line: string): boolean {
91
+ const trimmed = line.trim();
92
+ return (
93
+ trimmed.length > 0 && trimmed.endsWith(":") && !trimmed.startsWith("-")
94
+ );
95
+ }
96
+
97
+ function isSectionBodyLine(line: string): boolean {
98
+ const trimmed = line.trim();
99
+ if (trimmed.length === 0) return true; // blank line
100
+ if (trimmed.startsWith("- ")) return true; // bullet
101
+ if (line !== line.trimStart()) return true; // indented
102
+ return false;
103
+ }
104
+
105
+ function findSection(
106
+ lines: readonly string[],
107
+ header: string,
108
+ ): LineSection | null {
109
+ const start = lines.findIndex((line) => line.trim() === header);
110
+ if (start === -1) {
111
+ return null;
112
+ }
113
+
114
+ // If a subsequent recognised section header exists, use it as the boundary.
115
+ // This preserves the original behaviour for the common case where sections
116
+ // are adjacent (e.g. "Available tools:" followed by "Guidelines:") and
117
+ // ensures any prose continuation between the two headers is also removed.
118
+ for (let index = start + 1; index < lines.length; index += 1) {
119
+ if (isTopLevelSectionHeader(lines[index])) {
120
+ return { start, end: index };
121
+ }
122
+ }
123
+
124
+ // No subsequent section header — stop at the first non-body line so that
125
+ // content after the section (e.g. custom user notes) is not silently deleted.
126
+ let end = start + 1;
127
+ for (let index = start + 1; index < lines.length; index += 1) {
128
+ if (!isSectionBodyLine(lines[index])) {
129
+ end = index;
130
+ break;
131
+ }
132
+ end = index + 1;
133
+ }
134
+
135
+ return { start, end };
136
+ }
137
+
138
+ /**
139
+ * Tool name from an `Available tools:` bullet (`- read: …` -> `read`), or
140
+ * `null` for non-tool lines (blank lines, boilerplate prose). Matches the
141
+ * first token after the bullet marker, with or without a trailing colon.
142
+ */
143
+ function extractToolBulletName(line: string): string | null {
144
+ const match = /^\s*-\s+([A-Za-z0-9_-]+)/.exec(line);
145
+ return match ? match[1] : null;
146
+ }
147
+
148
+ /**
149
+ * Narrow the `Available tools:` section to the allowed tools: keep allowed-tool
150
+ * bullet lines and any non-tool prose, drop denied/inactive bullet lines. When
151
+ * no tool bullet survives, remove the section header too. This mirrors what Pi
152
+ * itself renders for the active tool set, so the result is byte-stable across
153
+ * turns regardless of whether the input still carries the full default listing.
154
+ */
155
+ function narrowAvailableToolsSection(
156
+ lines: readonly string[],
157
+ allowedTools: ReadonlySet<string>,
158
+ ): { lines: string[]; removed: boolean } {
159
+ const section = findSection(lines, AVAILABLE_TOOLS_SECTION_HEADER);
160
+ if (!section) {
161
+ return { lines: [...lines], removed: false };
162
+ }
163
+
164
+ const before = lines.slice(0, section.start);
165
+ const header = lines[section.start];
166
+ const body = lines.slice(section.start + 1, section.end);
167
+ const after = lines.slice(section.end);
168
+
169
+ const filteredBody = body.filter((line) => {
170
+ const toolName = extractToolBulletName(line);
171
+ if (toolName === null) {
172
+ return true; // keep blank lines and non-tool boilerplate
173
+ }
174
+ return allowedTools.has(toolName);
175
+ });
176
+
177
+ const removed = filteredBody.length !== body.length;
178
+ if (!removed) {
179
+ return { lines: [...lines], removed: false };
180
+ }
181
+
182
+ const hasToolBullet = filteredBody.some(
183
+ (line) => extractToolBulletName(line) !== null,
184
+ );
185
+ if (!hasToolBullet) {
186
+ return { lines: [...before, ...after], removed: true };
187
+ }
188
+
189
+ return {
190
+ lines: [...before, header, ...filteredBody, ...after],
191
+ removed: true,
192
+ };
193
+ }
194
+
195
+ function shouldKeepGuideline(
196
+ line: string,
197
+ allowedTools: ReadonlySet<string>,
198
+ ): boolean {
199
+ const normalized = normalizeGuidelineText(line);
200
+
201
+ for (const rule of TOOL_GUIDELINE_RULES) {
202
+ if (rule.matches(normalized)) {
203
+ return rule.shouldKeep(allowedTools);
204
+ }
205
+ }
206
+
207
+ return true;
208
+ }
209
+
210
+ function sanitizeGuidelinesSection(
211
+ lines: readonly string[],
212
+ allowedTools: ReadonlySet<string>,
213
+ ): { lines: string[]; removed: boolean } {
214
+ const section = findSection(lines, GUIDELINES_SECTION_HEADER);
215
+ if (!section) {
216
+ return { lines: [...lines], removed: false };
217
+ }
218
+
219
+ const before = lines.slice(0, section.start + 1);
220
+ const after = lines.slice(section.end);
221
+ const body = lines.slice(section.start + 1, section.end);
222
+ const filteredBody = body.filter((line) => {
223
+ const trimmed = line.trim();
224
+ if (!trimmed.startsWith("- ")) {
225
+ return true;
226
+ }
227
+
228
+ return shouldKeepGuideline(line, allowedTools);
229
+ });
230
+
231
+ const removed = filteredBody.length !== body.length;
232
+ if (!removed) {
233
+ return { lines: [...lines], removed: false };
234
+ }
235
+
236
+ const hasBullet = filteredBody.some((line) => line.trim().startsWith("- "));
237
+ if (!hasBullet) {
238
+ return {
239
+ lines: [...lines.slice(0, section.start), ...after],
240
+ removed: true,
241
+ };
242
+ }
243
+
244
+ return {
245
+ lines: [...before, ...filteredBody, ...after],
246
+ removed: true,
247
+ };
248
+ }
249
+
250
+ export function sanitizeAvailableToolsSection(
251
+ systemPrompt: string,
252
+ allowedToolNames: readonly string[],
253
+ ): SanitizeSystemPromptResult {
254
+ const allowedTools = new Set(
255
+ allowedToolNames.map((toolName) => toolName.trim()).filter(Boolean),
256
+ );
257
+ const normalizedLines = normalizePrompt(systemPrompt).split("\n");
258
+ const narrowedToolsSection = narrowAvailableToolsSection(
259
+ normalizedLines,
260
+ allowedTools,
261
+ );
262
+ const sanitizedGuidelines = sanitizeGuidelinesSection(
263
+ narrowedToolsSection.lines,
264
+ allowedTools,
265
+ );
266
+ const removed = narrowedToolsSection.removed || sanitizedGuidelines.removed;
267
+
268
+ return {
269
+ prompt: removed
270
+ ? collapseExtraBlankLines(sanitizedGuidelines.lines.join("\n"))
271
+ : systemPrompt,
272
+ removed,
273
+ };
274
+ }
@@ -0,0 +1,68 @@
1
+ /**
2
+ * Registry for custom tool access-intent extractors.
3
+ *
4
+ * Lets sibling extensions declare the filesystem path a tool will access when
5
+ * the tool's input shape is not the default `input.path` convention, so the
6
+ * cross-cutting `path` and `external_directory` gates can see it.
7
+ * One extractor per tool name; duplicate registration throws.
8
+ */
9
+
10
+ /** Returns the filesystem path this tool will access, or `undefined` to decline. */
11
+ export type ToolAccessExtractor = (
12
+ input: Record<string, unknown>,
13
+ ) => string | undefined;
14
+
15
+ /**
16
+ * Read-only lookup used by the gate pipeline (ISP — exposes only the read
17
+ * side, not the registration surface).
18
+ */
19
+ export interface ToolAccessExtractorLookup {
20
+ get(toolName: string): ToolAccessExtractor | undefined;
21
+ }
22
+
23
+ /**
24
+ * Registration side of the extractor registry (ISP — exposes only the write
25
+ * surface, mirroring the read-only {@link ToolAccessExtractorLookup}).
26
+ */
27
+ export interface ToolAccessExtractorRegistrar {
28
+ register(toolName: string, extractor: ToolAccessExtractor): () => void;
29
+ }
30
+
31
+ /**
32
+ * Persistent registry mapping tool names to custom access-intent extractors.
33
+ *
34
+ * Owned by the extension factory (`index.ts`) so it survives across the
35
+ * per-tool-call gate evaluation cycle.
36
+ * Exposed to sibling extensions via `PermissionsService.registerToolAccessExtractor`.
37
+ */
38
+ export class ToolAccessExtractorRegistry
39
+ implements ToolAccessExtractorLookup, ToolAccessExtractorRegistrar
40
+ {
41
+ private readonly extractors = new Map<string, ToolAccessExtractor>();
42
+
43
+ /**
44
+ * Register an extractor for `toolName`.
45
+ *
46
+ * Throws if an extractor is already registered for that name — keeps
47
+ * resolution deterministic (a pi-permission-system package priority).
48
+ * Returns a disposer that removes the extractor; the disposer is
49
+ * identity-guarded so a stale call cannot evict a later registration.
50
+ */
51
+ register(toolName: string, extractor: ToolAccessExtractor): () => void {
52
+ if (this.extractors.has(toolName)) {
53
+ throw new Error(
54
+ `A tool access extractor is already registered for '${toolName}'.`,
55
+ );
56
+ }
57
+ this.extractors.set(toolName, extractor);
58
+ return () => {
59
+ if (this.extractors.get(toolName) === extractor) {
60
+ this.extractors.delete(toolName);
61
+ }
62
+ };
63
+ }
64
+
65
+ get(toolName: string): ToolAccessExtractor | undefined {
66
+ return this.extractors.get(toolName);
67
+ }
68
+ }
@@ -0,0 +1,67 @@
1
+ /**
2
+ * Registry for custom tool-input preview formatters.
3
+ *
4
+ * Allows extensions to register a formatter for a specific tool name so
5
+ * permission prompts can show a human-readable summary instead of raw JSON.
6
+ * One formatter per tool name; duplicate registration throws.
7
+ */
8
+
9
+ /** A custom preview formatter for one tool's input. Returns `undefined` to decline. */
10
+ export type ToolInputFormatter = (
11
+ input: Record<string, unknown>,
12
+ ) => string | undefined;
13
+
14
+ /**
15
+ * Read-only lookup used by `ToolPreviewFormatter` (ISP — exposes only the
16
+ * read side, not the registration surface).
17
+ */
18
+ export interface ToolInputFormatterLookup {
19
+ get(toolName: string): ToolInputFormatter | undefined;
20
+ }
21
+
22
+ /**
23
+ * Registration side of the formatter registry (ISP — exposes only the
24
+ * write surface, mirroring the read-only {@link ToolInputFormatterLookup}).
25
+ */
26
+ export interface ToolInputFormatterRegistrar {
27
+ register(toolName: string, formatter: ToolInputFormatter): () => void;
28
+ }
29
+
30
+ /**
31
+ * Persistent registry mapping tool names to custom preview formatters.
32
+ *
33
+ * Owned by the extension factory (`index.ts`) so it survives across the
34
+ * per-tool-call `ToolPreviewFormatter` construction cycle.
35
+ * Exposed to sibling extensions via `PermissionsService.registerToolInputFormatter`.
36
+ */
37
+ export class ToolInputFormatterRegistry
38
+ implements ToolInputFormatterLookup, ToolInputFormatterRegistrar
39
+ {
40
+ private readonly formatters = new Map<string, ToolInputFormatter>();
41
+
42
+ /**
43
+ * Register a formatter for `toolName`.
44
+ *
45
+ * Throws if a formatter is already registered for that name — keeps
46
+ * resolution deterministic (a pi-permission-system package priority).
47
+ * Returns a disposer that removes the formatter; the disposer is
48
+ * identity-guarded so a stale call cannot evict a later registration.
49
+ */
50
+ register(toolName: string, formatter: ToolInputFormatter): () => void {
51
+ if (this.formatters.has(toolName)) {
52
+ throw new Error(
53
+ `A tool input formatter is already registered for '${toolName}'.`,
54
+ );
55
+ }
56
+ this.formatters.set(toolName, formatter);
57
+ return () => {
58
+ if (this.formatters.get(toolName) === formatter) {
59
+ this.formatters.delete(toolName);
60
+ }
61
+ };
62
+ }
63
+
64
+ get(toolName: string): ToolInputFormatter | undefined {
65
+ return this.formatters.get(toolName);
66
+ }
67
+ }
@@ -0,0 +1,34 @@
1
+ import { safeJsonStringify } from "./logging";
2
+
3
+ export const TOOL_INPUT_PREVIEW_MAX_LENGTH = 200;
4
+ export const TOOL_INPUT_LOG_PREVIEW_MAX_LENGTH = 1000;
5
+ export const TOOL_TEXT_SUMMARY_MAX_LENGTH = 80;
6
+
7
+ export function truncateInlineText(value: string, maxLength: number): string {
8
+ return value.length > maxLength ? `${value.slice(0, maxLength)}…` : value;
9
+ }
10
+
11
+ export function countTextLines(value: string): number {
12
+ if (!value) {
13
+ return 0;
14
+ }
15
+
16
+ return value.split(/\r\n|\r|\n/).length;
17
+ }
18
+
19
+ export function formatCount(
20
+ value: number,
21
+ singular: string,
22
+ plural: string,
23
+ ): string {
24
+ return `${value} ${value === 1 ? singular : plural}`;
25
+ }
26
+
27
+ export function serializeToolInputPreview(input: unknown): string {
28
+ const serialized = safeJsonStringify(input);
29
+ if (!serialized || serialized === "{}" || serialized === "null") {
30
+ return "";
31
+ }
32
+
33
+ return serialized.replace(/\s+/g, " ").trim();
34
+ }
@@ -0,0 +1,63 @@
1
+ import { getNonEmptyString, toRecord } from "./common";
2
+ import { countTextLines, formatCount } from "./tool-input-preview";
3
+
4
+ export function getPromptPath(input: Record<string, unknown>): string | null {
5
+ return getNonEmptyString(input.path) ?? getNonEmptyString(input.file_path);
6
+ }
7
+
8
+ export function formatEditInputForPrompt(
9
+ input: Record<string, unknown>,
10
+ ): string {
11
+ const path = getPromptPath(input);
12
+ const rawEdits = Array.isArray(input.edits)
13
+ ? input.edits
14
+ : typeof input.oldText === "string" && typeof input.newText === "string"
15
+ ? [{ oldText: input.oldText, newText: input.newText }]
16
+ : [];
17
+
18
+ const edits = rawEdits
19
+ .map((edit) => toRecord(edit))
20
+ .filter(
21
+ (edit) =>
22
+ typeof edit.oldText === "string" && typeof edit.newText === "string",
23
+ );
24
+
25
+ const pathPart = path ? `for '${path}'` : "";
26
+ if (edits.length === 0) {
27
+ return pathPart ? `${pathPart} with edit input` : "with edit input";
28
+ }
29
+
30
+ const firstEdit = edits[0];
31
+ const oldText = String(firstEdit.oldText);
32
+ const newText = String(firstEdit.newText);
33
+ const firstEditSummary = `edit #1 replaces ${formatCount(countTextLines(oldText), "line", "lines")} with ${formatCount(countTextLines(newText), "line", "lines")}`;
34
+ const extraEdits =
35
+ edits.length > 1
36
+ ? `, plus ${formatCount(edits.length - 1, "additional edit", "additional edits")}`
37
+ : "";
38
+ const summary = `(${formatCount(edits.length, "replacement", "replacements")}: ${firstEditSummary}${extraEdits})`;
39
+ return pathPart ? `${pathPart} ${summary}` : summary;
40
+ }
41
+
42
+ export function formatWriteInputForPrompt(
43
+ input: Record<string, unknown>,
44
+ ): string {
45
+ const path = getPromptPath(input);
46
+ const content = typeof input.content === "string" ? input.content : "";
47
+ const summary = `(${formatCount(countTextLines(content), "line", "lines")}, ${formatCount(content.length, "character", "characters")})`;
48
+ return path ? `for '${path}' ${summary}` : summary;
49
+ }
50
+
51
+ export function formatReadInputForPrompt(
52
+ input: Record<string, unknown>,
53
+ ): string {
54
+ const path = getPromptPath(input);
55
+ const parts = path ? [`path '${path}'`] : [];
56
+ if (typeof input.offset === "number") {
57
+ parts.push(`offset ${input.offset}`);
58
+ }
59
+ if (typeof input.limit === "number") {
60
+ parts.push(`limit ${input.limit}`);
61
+ }
62
+ return parts.length > 0 ? `for ${parts.join(", ")}` : "";
63
+ }