@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,355 @@
1
+ import {
2
+ join,
3
+ normalize,
4
+ posix as posixPath,
5
+ relative,
6
+ resolve,
7
+ win32 as winPath,
8
+ } from "node:path";
9
+
10
+ import { canonicalizePath } from "./canonicalize-path";
11
+ import { getNonEmptyString, toRecord } from "./common";
12
+ import { expandHomePath } from "./expand-home";
13
+ import type { ToolAccessExtractorLookup } from "./tool-access-extractor-registry";
14
+ import { wildcardMatch } from "./wildcard-matcher";
15
+
16
+ export function normalizePathForComparison(
17
+ pathValue: string,
18
+ cwd: string,
19
+ ): string {
20
+ const trimmed = pathValue.trim().replace(/^['"]|['"]$/g, "");
21
+ if (!trimmed) {
22
+ return "";
23
+ }
24
+
25
+ let normalizedPath = trimmed.startsWith("@") ? trimmed.slice(1) : trimmed;
26
+ normalizedPath = expandHomePath(normalizedPath);
27
+
28
+ const absolutePath = resolve(cwd, normalizedPath);
29
+ const normalizedAbsolutePath = normalize(absolutePath);
30
+ return process.platform === "win32"
31
+ ? normalizedAbsolutePath.toLowerCase()
32
+ : normalizedAbsolutePath;
33
+ }
34
+
35
+ /**
36
+ * Returns true when `pathValue` is `directory` itself or nested inside it.
37
+ *
38
+ * Containment is decided with Node's platform-native `path.relative` rather
39
+ * than a hand-rolled prefix check: on `win32` the comparison folds case (and
40
+ * tolerates either separator), matching the case-insensitive filesystem.
41
+ * `platform` defaults to `process.platform` and is injectable so Windows
42
+ * behavior is testable on a POSIX CI.
43
+ */
44
+ export function isPathWithinDirectory(
45
+ pathValue: string,
46
+ directory: string,
47
+ platform: NodeJS.Platform = process.platform,
48
+ ): boolean {
49
+ if (!pathValue || !directory) {
50
+ return false;
51
+ }
52
+
53
+ if (pathValue === directory) {
54
+ return true;
55
+ }
56
+
57
+ const impl = platform === "win32" ? winPath : posixPath;
58
+ const rel = impl.relative(directory, pathValue);
59
+ return (
60
+ rel !== "" &&
61
+ rel !== ".." &&
62
+ !rel.startsWith(`..${impl.sep}`) &&
63
+ !impl.isAbsolute(rel)
64
+ );
65
+ }
66
+
67
+ export interface PathPolicyValueOptions {
68
+ /**
69
+ * Current Pi working directory. When provided, returned values include a
70
+ * project-relative alias for paths that resolve inside this directory.
71
+ */
72
+ cwd?: string;
73
+ /**
74
+ * Directory used to resolve `pathValue` into an absolute policy value.
75
+ * Defaults to `cwd`. Bash uses this for tokens seen after a literal `cd`.
76
+ */
77
+ resolveBase?: string;
78
+ }
79
+
80
+ /**
81
+ * Normalize a single path-like lookup value without resolving it against CWD.
82
+ *
83
+ * Preserves compatibility with existing relative path rules (`src/*`, `*.env`)
84
+ * while applying the same lexical cleanup as
85
+ * {@link normalizePathForComparison}: trim, strip simple wrapping quotes,
86
+ * strip the OpenCode-style leading `@`, and expand `~` / `$HOME`.
87
+ */
88
+ export function normalizePathPolicyLiteral(pathValue: string): string {
89
+ const trimmed = pathValue.trim().replace(/^['"]|['"]$/g, "");
90
+ if (!trimmed) return "";
91
+ const unprefixed = trimmed.startsWith("@") ? trimmed.slice(1) : trimmed;
92
+ return expandHomePath(unprefixed);
93
+ }
94
+
95
+ /**
96
+ * Return equivalent lookup values for path-policy matching.
97
+ *
98
+ * The first value is the cwd/effective-base normalized absolute path when a
99
+ * base is available. The later values preserve project-relative and raw
100
+ * relative forms so existing rules like `src/*` and `*.env` continue to match.
101
+ */
102
+ export function getPathPolicyValues(
103
+ pathValue: string,
104
+ options: PathPolicyValueOptions = {},
105
+ ): string[] {
106
+ const literal = normalizePathPolicyLiteral(pathValue);
107
+ if (!literal) return [];
108
+ if (literal === "*") return ["*"];
109
+
110
+ return [
111
+ ...new Set([...getAbsolutePathPolicyValues(pathValue, options), literal]),
112
+ ];
113
+ }
114
+
115
+ /**
116
+ * Equivalent `external_directory` policy-match values for a path: the lexical
117
+ * (as-typed) alias list plus the canonical (symlink-resolved) absolute path.
118
+ *
119
+ * The outside-CWD boundary decision uses the canonical form separately; this
120
+ * helper exists only for pattern matching, so a user's pattern on the typed
121
+ * path (`/tmp/*`) and on the resolved path (`/private/tmp/*`) both match under
122
+ * the last-match-wins alias evaluation. On systems where the path is not a
123
+ * symlink the canonical form equals the lexical absolute alias and the `Set`
124
+ * collapses it, leaving today's behavior unchanged.
125
+ */
126
+ export function getExternalDirectoryPolicyValues(
127
+ pathValue: string,
128
+ cwd: string,
129
+ ): string[] {
130
+ const lexical = getPathPolicyValues(pathValue, { cwd });
131
+ const canonical = canonicalNormalizePathForComparison(pathValue, cwd);
132
+ return canonical ? [...new Set([...lexical, canonical])] : lexical;
133
+ }
134
+
135
+ function getAbsolutePathPolicyValues(
136
+ pathValue: string,
137
+ options: PathPolicyValueOptions,
138
+ ): string[] {
139
+ const resolveBase = options.resolveBase ?? options.cwd;
140
+ if (!resolveBase) return [];
141
+
142
+ const absolute = normalizePathForComparison(pathValue, resolveBase);
143
+ if (!absolute) return [];
144
+
145
+ return [absolute, ...getCwdRelativePathPolicyValues(absolute, options.cwd)];
146
+ }
147
+
148
+ function getCwdRelativePathPolicyValues(
149
+ absolute: string,
150
+ cwd: string | undefined,
151
+ ): string[] {
152
+ if (!cwd) return [];
153
+
154
+ const normalizedCwd = normalizePathForComparison(cwd, cwd);
155
+ if (!normalizedCwd) return [];
156
+ if (
157
+ absolute !== normalizedCwd &&
158
+ !isPathWithinDirectory(absolute, normalizedCwd)
159
+ ) {
160
+ return [];
161
+ }
162
+
163
+ const relativeValue = relative(normalizedCwd, absolute);
164
+ return relativeValue ? [relativeValue] : [];
165
+ }
166
+
167
+ /**
168
+ * Paths that are universally safe and should never trigger external-directory checks.
169
+ * These are OS device files: read returns EOF or process streams, write discards or goes to process streams.
170
+ */
171
+ export const SAFE_SYSTEM_PATHS: ReadonlySet<string> = new Set([
172
+ "/dev/null",
173
+ "/dev/stdin",
174
+ "/dev/stdout",
175
+ "/dev/stderr",
176
+ ]);
177
+
178
+ /**
179
+ * Returns true if the given normalized path is a safe OS device file
180
+ * that should never trigger external-directory checks.
181
+ */
182
+ export function isSafeSystemPath(normalizedPath: string): boolean {
183
+ return SAFE_SYSTEM_PATHS.has(normalizedPath);
184
+ }
185
+
186
+ /**
187
+ * File tools that only read — never write — the filesystem.
188
+ * Only these tools are eligible for the Pi infrastructure auto-allow.
189
+ */
190
+ export const READ_ONLY_PATH_BEARING_TOOLS: ReadonlySet<string> = new Set([
191
+ "read",
192
+ "find",
193
+ "grep",
194
+ "ls",
195
+ ]);
196
+
197
+ export const PATH_BEARING_TOOLS = new Set([
198
+ "read",
199
+ "write",
200
+ "edit",
201
+ "find",
202
+ "grep",
203
+ "ls",
204
+ ]);
205
+
206
+ /**
207
+ * Surfaces whose patterns are matched against filesystem paths and therefore
208
+ * fold case (and separators) on Windows: the path-bearing tools plus the
209
+ * cross-cutting `path` gate and the `external_directory` boundary gate.
210
+ */
211
+ export const PATH_SURFACES: ReadonlySet<string> = new Set([
212
+ ...PATH_BEARING_TOOLS,
213
+ "external_directory",
214
+ "path",
215
+ ]);
216
+
217
+ export function getPathBearingToolPath(
218
+ toolName: string,
219
+ input: unknown,
220
+ ): string | null {
221
+ if (!PATH_BEARING_TOOLS.has(toolName)) {
222
+ return null;
223
+ }
224
+
225
+ return getNonEmptyString(toRecord(input).path);
226
+ }
227
+
228
+ /**
229
+ * Extract the filesystem path a tool will access, for the cross-cutting `path`
230
+ * and `external_directory` gates.
231
+ *
232
+ * Unlike {@link getPathBearingToolPath} (built-in tools only), this recognizes
233
+ * extension and MCP tools so they are no longer exempt from path gating:
234
+ *
235
+ * - `bash` → `null` (bash has its own token-based path gates).
236
+ * - Built-in path-bearing tools → `input.path`.
237
+ * - `mcp` → `input.arguments.path`.
238
+ * - Any other tool → a registered {@link ToolAccessExtractor}'s path, else the
239
+ * default `input.path` convention.
240
+ */
241
+ export function getToolInputPath(
242
+ toolName: string,
243
+ input: unknown,
244
+ extractors?: ToolAccessExtractorLookup,
245
+ ): string | null {
246
+ if (toolName === "bash") {
247
+ return null;
248
+ }
249
+
250
+ const record = toRecord(input);
251
+
252
+ if (PATH_BEARING_TOOLS.has(toolName)) {
253
+ return getNonEmptyString(record.path);
254
+ }
255
+
256
+ if (toolName === "mcp") {
257
+ return getNonEmptyString(toRecord(record.arguments).path);
258
+ }
259
+
260
+ const custom = extractors?.get(toolName);
261
+ if (custom) {
262
+ return getNonEmptyString(custom(record));
263
+ }
264
+
265
+ return getNonEmptyString(record.path);
266
+ }
267
+
268
+ /**
269
+ * Like {@link normalizePathForComparison} but also resolves symlinks via
270
+ * `realpathSync` (best-effort). Use this for containment decisions where the
271
+ * OS-followed path matters, not for pattern matching.
272
+ */
273
+ export function canonicalNormalizePathForComparison(
274
+ pathValue: string,
275
+ cwd: string,
276
+ ): string {
277
+ const lexical = normalizePathForComparison(pathValue, cwd);
278
+ if (!lexical) return "";
279
+ const canonical = canonicalizePath(lexical);
280
+ return process.platform === "win32" ? canonical.toLowerCase() : canonical;
281
+ }
282
+
283
+ export function isPathOutsideWorkingDirectory(
284
+ pathValue: string,
285
+ cwd: string,
286
+ ): boolean {
287
+ const normalizedCwd = canonicalNormalizePathForComparison(cwd, cwd);
288
+ const normalizedPath = canonicalNormalizePathForComparison(pathValue, cwd);
289
+ if (!normalizedCwd || !normalizedPath) {
290
+ return false;
291
+ }
292
+ if (isSafeSystemPath(normalizedPath)) {
293
+ return false;
294
+ }
295
+ return !isPathWithinDirectory(normalizedPath, normalizedCwd);
296
+ }
297
+
298
+ function containsGlobChars(value: string): boolean {
299
+ return value.includes("*") || value.includes("?");
300
+ }
301
+
302
+ /**
303
+ * Returns true if the given tool + normalized path combination qualifies for
304
+ * automatic allow as a Pi infrastructure read.
305
+ *
306
+ * A path qualifies when:
307
+ * 1. The tool is read-only (in READ_ONLY_PATH_BEARING_TOOLS).
308
+ * 2. The normalized path is within one of the provided `infrastructureDirs`
309
+ * OR within the project-local Pi package directories
310
+ * (`<cwd>/.pi/npm/` or `<cwd>/.pi/git/`).
311
+ *
312
+ * `infrastructureDirs` entries may be absolute paths or patterns containing
313
+ * `~`/`$HOME` (expanded at call time) or glob characters (`*`, `?`).
314
+ * Project-local paths are computed fresh from `cwd` on each call so they
315
+ * follow working-directory changes without a runtime rebuild.
316
+ */
317
+ export function isPiInfrastructureRead(
318
+ toolName: string,
319
+ normalizedPath: string,
320
+ infrastructureDirs: readonly string[],
321
+ cwd: string,
322
+ platform: NodeJS.Platform = process.platform,
323
+ ): boolean {
324
+ if (!READ_ONLY_PATH_BEARING_TOOLS.has(toolName)) {
325
+ return false;
326
+ }
327
+
328
+ // On Windows the path value is canonicalized + lowercased; fold case (and
329
+ // separators) so mixed-case infra dirs and glob patterns still match.
330
+ const matchOptions =
331
+ platform === "win32"
332
+ ? { caseInsensitive: true, windowsSeparators: true }
333
+ : undefined;
334
+
335
+ for (const dir of infrastructureDirs) {
336
+ if (containsGlobChars(dir)) {
337
+ if (wildcardMatch(dir, normalizedPath, matchOptions)) return true;
338
+ } else {
339
+ if (isPathWithinDirectory(normalizedPath, expandHomePath(dir), platform))
340
+ return true;
341
+ }
342
+ }
343
+
344
+ // Project-local Pi packages — checked fresh every call so CWD changes work.
345
+ const projectNpmDir = join(cwd, ".pi", "npm");
346
+ const projectGitDir = join(cwd, ".pi", "git");
347
+ if (isPathWithinDirectory(normalizedPath, projectNpmDir, platform)) {
348
+ return true;
349
+ }
350
+ if (isPathWithinDirectory(normalizedPath, projectGitDir, platform)) {
351
+ return true;
352
+ }
353
+
354
+ return false;
355
+ }
@@ -0,0 +1,132 @@
1
+ import { prefix, stripBashCommentLines } from "./bash-arity";
2
+ import { PATH_BEARING_TOOLS } from "./path-utils";
3
+ import { deriveApprovalPattern } from "./session-rules";
4
+
5
+ /** The suggestion returned for a "Yes, for this session" dialog option. */
6
+ export interface SessionApprovalSuggestion {
7
+ /** The permission surface this approval applies to. */
8
+ surface: string;
9
+ /** The wildcard pattern to store as a session rule. */
10
+ pattern: string;
11
+ /** Human-readable label for the "for session" dialog option. */
12
+ label: string;
13
+ }
14
+
15
+ /**
16
+ * Suggest a bash session-approval pattern from a command string.
17
+ *
18
+ * Uses the arity table (`src/bash-arity.ts`) to identify the semantically
19
+ * meaningful prefix tokens for the command, then produces a wildcard pattern:
20
+ *
21
+ * - Single bare token (no args): exact command (`ls`).
22
+ * - Arity prefix covers all tokens: trailing wildcard (`npm run build*`).
23
+ * - Arity prefix shorter than token list: space + wildcard (`git checkout *`).
24
+ * - Unknown command: first token + space wildcard (`mytool *`).
25
+ */
26
+ export function suggestBashPattern(command: string): string {
27
+ const trimmed = command.trim();
28
+ if (!trimmed) return "";
29
+ // Strip leading shell comment lines so the suggestion is based on the
30
+ // actual command, not a `# description` prefix agents often prepend.
31
+ const stripped = stripBashCommentLines(trimmed);
32
+ if (!stripped) return "";
33
+ const tokens = stripped.split(/\s+/);
34
+ if (tokens.length === 1) return stripped;
35
+ const meaningful = prefix(tokens);
36
+ if (meaningful.length >= tokens.length) {
37
+ return `${stripped}*`;
38
+ }
39
+ return `${meaningful.join(" ")} *`;
40
+ }
41
+
42
+ /**
43
+ * Suggest an MCP session-approval pattern from a resolved target string.
44
+ *
45
+ * - Qualified target (`server:tool`) → `server:*`
46
+ * - Munged target (`server_tool`) → `server_*`
47
+ * - Bare target (no separator) → `*`
48
+ */
49
+ export function suggestMcpPattern(target: string): string {
50
+ const trimmed = target.trim();
51
+
52
+ const colonIndex = trimmed.indexOf(":");
53
+ if (colonIndex > 0) {
54
+ return `${trimmed.slice(0, colonIndex)}:*`;
55
+ }
56
+
57
+ const underscoreIndex = trimmed.indexOf("_");
58
+ if (underscoreIndex > 0) {
59
+ return `${trimmed.slice(0, underscoreIndex)}_*`;
60
+ }
61
+
62
+ return "*";
63
+ }
64
+
65
+ /** Surface-aware human-readable labels for the session-approval option. */
66
+ function buildLabel(pattern: string, surface: string): string {
67
+ switch (surface) {
68
+ case "bash":
69
+ return `Yes, allow bash "${pattern}" for this session`;
70
+ case "mcp":
71
+ return `Yes, allow mcp tool "${pattern}" for this session`;
72
+ case "skill":
73
+ return `Yes, allow skill "${pattern}" for this session`;
74
+ case "external_directory":
75
+ return `Yes, allow access to external directory "${pattern}" for this session`;
76
+ case "path":
77
+ return `Yes, allow path "${pattern}" for this session`;
78
+ default:
79
+ // Path-bearing tools with a specific path pattern show the pattern.
80
+ if (PATH_BEARING_TOOLS.has(surface) && pattern !== "*") {
81
+ return `Yes, allow ${surface} "${pattern}" for this session`;
82
+ }
83
+ // Tool surfaces with catch-all or extension tools.
84
+ return `Yes, allow tool "${surface}" for this session`;
85
+ }
86
+ }
87
+
88
+ /**
89
+ * Suggest a session-approval pattern for the given permission surface and value.
90
+ *
91
+ * Returns a `SessionApprovalSuggestion` with the surface, the wildcard pattern
92
+ * to store in `SessionRules`, and a human-readable dialog label.
93
+ *
94
+ * `value` is expected to be the canonical (cwd-resolved, absolute) path for
95
+ * path surfaces — callers resolve it before suggesting, so the derived pattern
96
+ * matches the policy values a later tool call produces.
97
+ */
98
+ export function suggestSessionPattern(
99
+ surface: string,
100
+ value: string,
101
+ ): SessionApprovalSuggestion {
102
+ let pattern: string;
103
+
104
+ switch (surface) {
105
+ case "bash":
106
+ pattern = suggestBashPattern(value);
107
+ break;
108
+ case "mcp":
109
+ pattern = suggestMcpPattern(value);
110
+ break;
111
+ case "skill":
112
+ pattern = value;
113
+ break;
114
+ case "external_directory":
115
+ pattern = deriveApprovalPattern(value);
116
+ break;
117
+ case "path":
118
+ pattern = deriveApprovalPattern(value);
119
+ break;
120
+ default:
121
+ // Path-bearing tools: derive a directory-scoped pattern from the path.
122
+ if (PATH_BEARING_TOOLS.has(surface) && value !== "*") {
123
+ pattern = deriveApprovalPattern(value);
124
+ break;
125
+ }
126
+ // Extension tools / fallback.
127
+ pattern = "*";
128
+ break;
129
+ }
130
+
131
+ return { surface, pattern, label: buildLabel(pattern, surface) };
132
+ }
@@ -0,0 +1,138 @@
1
+ export type PermissionDecisionState =
2
+ | "approved"
3
+ | "approved_for_session"
4
+ | "approved_for_project"
5
+ | "approved_globally"
6
+ | "denied"
7
+ | "denied_with_reason";
8
+
9
+ export type PermissionPromptDecision = {
10
+ approved: boolean;
11
+ state: PermissionDecisionState;
12
+ denialReason?: string;
13
+ /**
14
+ * True when the decision was made automatically by yolo mode rather than
15
+ * by an interactive user prompt. Used by handlers to emit "auto_approved"
16
+ * rather than "user_approved" in the permissions:decision broadcast.
17
+ */
18
+ autoApproved?: true;
19
+ };
20
+
21
+ export interface PermissionDecisionUi {
22
+ select(title: string, options: string[]): Promise<string | undefined>;
23
+ input(title: string, placeholder?: string): Promise<string | undefined>;
24
+ }
25
+
26
+ const APPROVE_OPTION = "Yes";
27
+ const APPROVE_FOR_SESSION_OPTION = "Yes, for this session";
28
+ const APPROVE_FOR_PROJECT_OPTION = "Yes, always for this project";
29
+ const APPROVE_GLOBALLY_OPTION = "Yes, always for all projects";
30
+ const DENY_OPTION = "No";
31
+ const DENY_WITH_REASON_OPTION = "No, provide reason";
32
+
33
+ export function normalizePermissionDenialReason(
34
+ value: unknown,
35
+ ): string | undefined {
36
+ if (typeof value !== "string") {
37
+ return undefined;
38
+ }
39
+
40
+ const trimmed = value.trim();
41
+ return trimmed.length > 0 ? trimmed : undefined;
42
+ }
43
+
44
+ export function createDeniedPermissionDecision(
45
+ denialReason?: string,
46
+ ): PermissionPromptDecision {
47
+ const normalizedReason = normalizePermissionDenialReason(denialReason);
48
+ return normalizedReason
49
+ ? {
50
+ approved: false,
51
+ state: "denied_with_reason",
52
+ denialReason: normalizedReason,
53
+ }
54
+ : {
55
+ approved: false,
56
+ state: "denied",
57
+ };
58
+ }
59
+
60
+ export function isPermissionDecisionState(
61
+ value: unknown,
62
+ ): value is PermissionDecisionState {
63
+ return (
64
+ value === "approved" ||
65
+ value === "approved_for_session" ||
66
+ value === "approved_for_project" ||
67
+ value === "approved_globally" ||
68
+ value === "denied" ||
69
+ value === "denied_with_reason"
70
+ );
71
+ }
72
+
73
+ export interface RequestPermissionOptions {
74
+ /** Override the "for this session" option label (e.g. to show the suggested pattern). */
75
+ sessionLabel?: string;
76
+ }
77
+
78
+ export async function requestPermissionDecisionFromUi(
79
+ ui: PermissionDecisionUi,
80
+ title: string,
81
+ message: string,
82
+ options?: RequestPermissionOptions,
83
+ ): Promise<PermissionPromptDecision> {
84
+ const sessionOption = options?.sessionLabel ?? APPROVE_FOR_SESSION_OPTION;
85
+ const decisionOptions = [
86
+ APPROVE_OPTION,
87
+ sessionOption,
88
+ APPROVE_FOR_PROJECT_OPTION,
89
+ APPROVE_GLOBALLY_OPTION,
90
+ DENY_OPTION,
91
+ DENY_WITH_REASON_OPTION,
92
+ ] as const;
93
+
94
+ const selected = await ui.select(`${title}\n${message}`, [
95
+ ...decisionOptions,
96
+ ]);
97
+
98
+ if (selected === APPROVE_OPTION) {
99
+ return {
100
+ approved: true,
101
+ state: "approved",
102
+ };
103
+ }
104
+
105
+ if (selected === sessionOption) {
106
+ return {
107
+ approved: true,
108
+ state: "approved_for_session",
109
+ };
110
+ }
111
+
112
+ if (selected === APPROVE_FOR_PROJECT_OPTION) {
113
+ return {
114
+ approved: true,
115
+ state: "approved_for_project",
116
+ };
117
+ }
118
+
119
+ if (selected === APPROVE_GLOBALLY_OPTION) {
120
+ return {
121
+ approved: true,
122
+ state: "approved_globally",
123
+ };
124
+ }
125
+
126
+ if (selected === DENY_WITH_REASON_OPTION) {
127
+ const denialReason = normalizePermissionDenialReason(
128
+ await ui.input(
129
+ `${title}\nShare why this request was denied (optional).`,
130
+ "Reason shown back to the agent",
131
+ ),
132
+ );
133
+
134
+ return createDeniedPermissionDecision(denialReason);
135
+ }
136
+
137
+ return createDeniedPermissionDecision();
138
+ }