@gotgenes/pi-permission-system 11.0.0 → 13.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (45) hide show
  1. package/CHANGELOG.md +43 -0
  2. package/README.md +9 -11
  3. package/package.json +1 -1
  4. package/schemas/permissions.schema.json +1 -1
  5. package/src/config-modal.ts +3 -7
  6. package/src/extension-config.ts +11 -25
  7. package/src/handlers/gates/bash-path-extractor.ts +0 -12
  8. package/src/handlers/gates/bash-path.ts +12 -10
  9. package/src/handlers/gates/bash-program.ts +52 -11
  10. package/src/handlers/gates/bash-token-classification.ts +1 -1
  11. package/src/handlers/gates/external-directory.ts +8 -2
  12. package/src/handlers/gates/path.ts +4 -2
  13. package/src/handlers/gates/tool-call-gate-pipeline.ts +5 -2
  14. package/src/index.ts +8 -2
  15. package/src/input-normalizer.ts +17 -11
  16. package/src/path-utils.ts +122 -0
  17. package/src/permission-manager.ts +81 -17
  18. package/src/permission-resolver.ts +24 -0
  19. package/src/permission-session.ts +2 -4
  20. package/src/permissions-service.ts +12 -0
  21. package/src/rule.ts +61 -11
  22. package/src/service.ts +24 -0
  23. package/src/tool-access-extractor-registry.ts +68 -0
  24. package/test/bash-external-directory.test.ts +1 -81
  25. package/test/composition-root.test.ts +36 -0
  26. package/test/config-modal.test.ts +7 -10
  27. package/test/config-pipeline.test.ts +90 -0
  28. package/test/extension-config.test.ts +0 -58
  29. package/test/handlers/gates/bash-path.test.ts +45 -2
  30. package/test/handlers/gates/bash-program.test.ts +44 -11
  31. package/test/handlers/gates/external-directory.test.ts +54 -0
  32. package/test/handlers/gates/path.test.ts +72 -0
  33. package/test/handlers/gates/tool-call-gate-pipeline.test.ts +64 -1
  34. package/test/helpers/gate-fixtures.ts +23 -3
  35. package/test/helpers/handler-fixtures.ts +14 -2
  36. package/test/helpers/session-fixtures.ts +14 -0
  37. package/test/input-normalizer.test.ts +52 -0
  38. package/test/path-utils.test.ts +135 -0
  39. package/test/permission-manager-unified.test.ts +134 -0
  40. package/test/permission-resolver.test.ts +69 -0
  41. package/test/permissions-service.test.ts +35 -1
  42. package/test/rule.test.ts +74 -1
  43. package/test/service-lifecycle.test.ts +1 -0
  44. package/test/service.test.ts +53 -0
  45. package/test/tool-access-extractor-registry.test.ts +77 -0
package/src/path-utils.ts CHANGED
@@ -2,6 +2,7 @@ import {
2
2
  join,
3
3
  normalize,
4
4
  posix as posixPath,
5
+ relative,
5
6
  resolve,
6
7
  win32 as winPath,
7
8
  } from "node:path";
@@ -9,6 +10,7 @@ import {
9
10
  import { canonicalizePath } from "./canonicalize-path";
10
11
  import { getNonEmptyString, toRecord } from "./common";
11
12
  import { expandHomePath } from "./expand-home";
13
+ import type { ToolAccessExtractorLookup } from "./tool-access-extractor-registry";
12
14
  import { wildcardMatch } from "./wildcard-matcher";
13
15
 
14
16
  export function normalizePathForComparison(
@@ -62,6 +64,86 @@ export function isPathWithinDirectory(
62
64
  );
63
65
  }
64
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
+ function getAbsolutePathPolicyValues(
116
+ pathValue: string,
117
+ options: PathPolicyValueOptions,
118
+ ): string[] {
119
+ const resolveBase = options.resolveBase ?? options.cwd;
120
+ if (!resolveBase) return [];
121
+
122
+ const absolute = normalizePathForComparison(pathValue, resolveBase);
123
+ if (!absolute) return [];
124
+
125
+ return [absolute, ...getCwdRelativePathPolicyValues(absolute, options.cwd)];
126
+ }
127
+
128
+ function getCwdRelativePathPolicyValues(
129
+ absolute: string,
130
+ cwd: string | undefined,
131
+ ): string[] {
132
+ if (!cwd) return [];
133
+
134
+ const normalizedCwd = normalizePathForComparison(cwd, cwd);
135
+ if (!normalizedCwd) return [];
136
+ if (
137
+ absolute !== normalizedCwd &&
138
+ !isPathWithinDirectory(absolute, normalizedCwd)
139
+ ) {
140
+ return [];
141
+ }
142
+
143
+ const relativeValue = relative(normalizedCwd, absolute);
144
+ return relativeValue ? [relativeValue] : [];
145
+ }
146
+
65
147
  /**
66
148
  * Paths that are universally safe and should never trigger external-directory checks.
67
149
  * These are OS device files: read returns EOF or process streams, write discards or goes to process streams.
@@ -123,6 +205,46 @@ export function getPathBearingToolPath(
123
205
  return getNonEmptyString(toRecord(input).path);
124
206
  }
125
207
 
208
+ /**
209
+ * Extract the filesystem path a tool will access, for the cross-cutting `path`
210
+ * and `external_directory` gates.
211
+ *
212
+ * Unlike {@link getPathBearingToolPath} (built-in tools only), this recognizes
213
+ * extension and MCP tools so they are no longer exempt from path gating:
214
+ *
215
+ * - `bash` → `null` (bash has its own token-based path gates).
216
+ * - Built-in path-bearing tools → `input.path`.
217
+ * - `mcp` → `input.arguments.path`.
218
+ * - Any other tool → a registered {@link ToolAccessExtractor}'s path, else the
219
+ * default `input.path` convention.
220
+ */
221
+ export function getToolInputPath(
222
+ toolName: string,
223
+ input: unknown,
224
+ extractors?: ToolAccessExtractorLookup,
225
+ ): string | null {
226
+ if (toolName === "bash") {
227
+ return null;
228
+ }
229
+
230
+ const record = toRecord(input);
231
+
232
+ if (PATH_BEARING_TOOLS.has(toolName)) {
233
+ return getNonEmptyString(record.path);
234
+ }
235
+
236
+ if (toolName === "mcp") {
237
+ return getNonEmptyString(toRecord(record.arguments).path);
238
+ }
239
+
240
+ const custom = extractors?.get(toolName);
241
+ if (custom) {
242
+ return getNonEmptyString(custom(record));
243
+ }
244
+
245
+ return getNonEmptyString(record.path);
246
+ }
247
+
126
248
  /**
127
249
  * Like {@link normalizePathForComparison} but also resolves symlinks via
128
250
  * `realpathSync` (best-effort). Use this for containment decisions where the
@@ -3,6 +3,7 @@ import { isPermissionState } from "./common";
3
3
  import { getGlobalConfigPath, getProjectConfigPath } from "./config-paths";
4
4
  import { normalizeInput } from "./input-normalizer";
5
5
  import { normalizeFlatConfig } from "./normalize";
6
+ import { PATH_SURFACES } from "./path-utils";
6
7
  import {
7
8
  FilePolicyLoader,
8
9
  type PolicyLoader,
@@ -10,7 +11,7 @@ import {
10
11
  type ResolvedPolicyPaths,
11
12
  } from "./policy-loader";
12
13
  import type { Rule, RuleOrigin, Ruleset } from "./rule";
13
- import { evaluate, evaluateFirst } from "./rule";
14
+ import { evaluate, evaluateAnyValue, evaluateFirst } from "./rule";
14
15
  import { mergeScopesWithOrigins } from "./scope-merge";
15
16
  import {
16
17
  composeRuleset,
@@ -63,6 +64,17 @@ export interface ScopedPermissionManager {
63
64
  agentName?: string,
64
65
  sessionRules?: Ruleset,
65
66
  ): PermissionCheckResult;
67
+ /**
68
+ * Evaluate the cross-cutting `path` surface against a caller-supplied set of
69
+ * equivalent policy values (e.g. bash tokens already resolved against a
70
+ * preceding literal `cd`). The values are trusted because they are computed
71
+ * internally, never read from a field on raw tool input.
72
+ */
73
+ checkPathPolicy(
74
+ values: readonly string[],
75
+ agentName?: string,
76
+ sessionRules?: Ruleset,
77
+ ): PermissionCheckResult;
66
78
  getToolPermission(toolName: string, agentName?: string): PermissionState;
67
79
  getConfigIssues(agentName?: string): string[];
68
80
  getPolicyCacheStamp(agentName?: string): string;
@@ -79,6 +91,7 @@ export interface PermissionManagerOptions extends PolicyLoaderOptions {
79
91
 
80
92
  export class PermissionManager implements ScopedPermissionManager {
81
93
  private readonly agentDir: string | undefined;
94
+ private currentCwd: string | undefined;
82
95
  private loader: PolicyLoader;
83
96
  private readonly resolvedPermissionsCache = new Map<
84
97
  string,
@@ -104,6 +117,8 @@ export class PermissionManager implements ScopedPermissionManager {
104
117
  * built with explicit paths), only the cache is cleared.
105
118
  */
106
119
  configureForCwd(cwd: string | undefined | null): void {
120
+ this.currentCwd =
121
+ typeof cwd === "string" && cwd.trim().length > 0 ? cwd : undefined;
107
122
  if (this.agentDir !== undefined) {
108
123
  this.loader = new FilePolicyLoader(
109
124
  derivePolicyLoaderOptions(this.agentDir, cwd),
@@ -245,29 +260,78 @@ export class PermissionManager implements ScopedPermissionManager {
245
260
  normalizedToolName,
246
261
  input,
247
262
  this.loader.getConfiguredMcpServerNames(),
263
+ this.currentCwd,
248
264
  );
249
265
 
250
- const { rule, value } = evaluateFirst(surface, values, fullRules);
266
+ return buildCheckResult(
267
+ surface,
268
+ values,
269
+ resultExtras,
270
+ normalizedToolName,
271
+ toolName,
272
+ fullRules,
273
+ );
274
+ }
251
275
 
252
- // For MCP, replace the normalizer's fallback target with the actual
253
- // matched candidate value so PermissionCheckResult.target is accurate.
254
- const extras =
255
- surface === "mcp" ? { ...resultExtras, target: value } : resultExtras;
276
+ checkPathPolicy(
277
+ values: readonly string[],
278
+ agentName?: string,
279
+ sessionRules?: Ruleset,
280
+ ): PermissionCheckResult {
281
+ const { composedRules } = this.resolvePermissions(agentName);
282
+ const fullRules: Ruleset = sessionRules?.length
283
+ ? [...composedRules, ...sessionRules]
284
+ : composedRules;
256
285
 
257
- return {
258
- toolName,
259
- state: rule.action,
260
- matchedPattern:
261
- rule.layer === "config" || rule.layer === "session"
262
- ? rule.pattern
263
- : undefined,
264
- source: deriveSource(rule, normalizedToolName),
265
- origin: rule.origin,
266
- ...extras,
267
- };
286
+ const lookupValues = values.length > 0 ? [...values] : ["*"];
287
+ return buildCheckResult(
288
+ "path",
289
+ lookupValues,
290
+ {},
291
+ "path",
292
+ "path",
293
+ fullRules,
294
+ );
268
295
  }
269
296
  }
270
297
 
298
+ /**
299
+ * Evaluate a normalized surface/values triple and shape the result.
300
+ *
301
+ * Path surfaces use {@link evaluateAnyValue} (last-match-wins across equivalent
302
+ * aliases); every other surface keeps {@link evaluateFirst}. Shared by
303
+ * `checkPermission` and `checkPathPolicy`.
304
+ */
305
+ function buildCheckResult(
306
+ surface: string,
307
+ values: string[],
308
+ resultExtras: Record<string, unknown>,
309
+ normalizedToolName: string,
310
+ toolName: string,
311
+ fullRules: Ruleset,
312
+ ): PermissionCheckResult {
313
+ const { rule, value } = PATH_SURFACES.has(surface)
314
+ ? evaluateAnyValue(surface, values, fullRules)
315
+ : evaluateFirst(surface, values, fullRules);
316
+
317
+ // For MCP, replace the normalizer's fallback target with the actual
318
+ // matched candidate value so PermissionCheckResult.target is accurate.
319
+ const extras =
320
+ surface === "mcp" ? { ...resultExtras, target: value } : resultExtras;
321
+
322
+ return {
323
+ toolName,
324
+ state: rule.action,
325
+ matchedPattern:
326
+ rule.layer === "config" || rule.layer === "session"
327
+ ? rule.pattern
328
+ : undefined,
329
+ source: deriveSource(rule, normalizedToolName),
330
+ origin: rule.origin,
331
+ ...extras,
332
+ };
333
+ }
334
+
271
335
  /**
272
336
  * Derive `PolicyLoaderOptions` from an agentDir + an optional cwd.
273
337
  * Setting agentsDir explicitly from agentDir removes the hidden
@@ -17,6 +17,15 @@ export interface ScopedPermissionResolver {
17
17
  input: unknown,
18
18
  agentName?: string,
19
19
  ): PermissionCheckResult;
20
+ /**
21
+ * Resolve the cross-cutting `path` surface against a caller-supplied set of
22
+ * equivalent policy values, applying the current session rules. Used by the
23
+ * bash path gate, which computes cd-aware policy values per token.
24
+ */
25
+ resolvePathPolicy(
26
+ values: readonly string[],
27
+ agentName?: string,
28
+ ): PermissionCheckResult;
20
29
  }
21
30
 
22
31
  /**
@@ -53,6 +62,21 @@ export class PermissionResolver implements ScopedPermissionResolver {
53
62
  );
54
63
  }
55
64
 
65
+ /**
66
+ * Resolve the `path` surface for precomputed policy values, composing the
67
+ * current session ruleset so callers never thread it by hand.
68
+ */
69
+ resolvePathPolicy(
70
+ values: readonly string[],
71
+ agentName?: string,
72
+ ): PermissionCheckResult {
73
+ return this.permissionManager.checkPathPolicy(
74
+ values,
75
+ agentName,
76
+ this.sessionRules.getRuleset(),
77
+ );
78
+ }
79
+
56
80
  checkPermission(
57
81
  surface: string,
58
82
  input: unknown,
@@ -150,10 +150,8 @@ export class PermissionSession implements ToolCallGateInputs {
150
150
  return this.knownAgentName;
151
151
  }
152
152
 
153
- // Read by config-modal (`controller.session.lastKnownActiveAgentName`).
154
- // fallow cannot trace the getter through the command's object-literal
155
- // wiring, so it reports a false positive here.
156
- // fallow-ignore-next-line unused-class-member
153
+ // Read by the `index.ts` config-modal adapter closure:
154
+ // `permissionManager.getComposedConfigRules(session.lastKnownActiveAgentName ?? undefined)`.
157
155
  get lastKnownActiveAgentName(): string | null {
158
156
  return this.knownAgentName;
159
157
  }
@@ -2,6 +2,10 @@ import { buildInputForSurface } from "./input-normalizer";
2
2
  import type { ScopedPermissionManager } from "./permission-manager";
3
3
  import type { PermissionsService } from "./service";
4
4
  import type { SessionRules } from "./session-rules";
5
+ import type {
6
+ ToolAccessExtractor,
7
+ ToolAccessExtractorRegistrar,
8
+ } from "./tool-access-extractor-registry";
5
9
  import type {
6
10
  ToolInputFormatter,
7
11
  ToolInputFormatterRegistrar,
@@ -19,6 +23,7 @@ export class LocalPermissionsService implements PermissionsService {
19
23
  private readonly permissionManager: ScopedPermissionManager,
20
24
  private readonly sessionRules: Pick<SessionRules, "getRuleset">,
21
25
  private readonly formatterRegistry: ToolInputFormatterRegistrar,
26
+ private readonly accessExtractorRegistry: ToolAccessExtractorRegistrar,
22
27
  ) {}
23
28
 
24
29
  checkPermission(
@@ -48,4 +53,11 @@ export class LocalPermissionsService implements PermissionsService {
48
53
  ): ReturnType<PermissionsService["registerToolInputFormatter"]> {
49
54
  return this.formatterRegistry.register(toolName, formatter);
50
55
  }
56
+
57
+ registerToolAccessExtractor(
58
+ toolName: string,
59
+ extractor: ToolAccessExtractor,
60
+ ): ReturnType<PermissionsService["registerToolAccessExtractor"]> {
61
+ return this.accessExtractorRegistry.register(toolName, extractor);
62
+ }
51
63
  }
package/src/rule.ts CHANGED
@@ -55,17 +55,8 @@ export function evaluate(
55
55
  defaultAction?: PermissionState,
56
56
  platform: NodeJS.Platform = process.platform,
57
57
  ): Rule {
58
- // On Windows, path-surface values are canonicalized + lowercased; fold the
59
- // pattern→value match (case and separators) so mixed-case / forward-slash
60
- // overrides still match. The surface→surface match stays exact.
61
- const matchOptions =
62
- platform === "win32" && PATH_SURFACES.has(surface)
63
- ? { caseInsensitive: true, windowsSeparators: true }
64
- : undefined;
65
- const rule = rules.findLast(
66
- (r) =>
67
- wildcardMatch(r.surface, surface) &&
68
- wildcardMatch(r.pattern, pattern, matchOptions),
58
+ const rule = rules.findLast((r) =>
59
+ ruleMatches(r, surface, pattern, platform),
69
60
  );
70
61
  if (rule !== undefined) return rule;
71
62
  return {
@@ -76,6 +67,33 @@ export function evaluate(
76
67
  };
77
68
  }
78
69
 
70
+ /**
71
+ * On Windows, path-surface values are canonicalized + lowercased; fold the
72
+ * pattern→value match (case and separators) so mixed-case / forward-slash
73
+ * overrides still match. The surface→surface match stays exact.
74
+ */
75
+ function pathMatchOptions(
76
+ surface: string,
77
+ platform: NodeJS.Platform,
78
+ ): { caseInsensitive: true; windowsSeparators: true } | undefined {
79
+ return platform === "win32" && PATH_SURFACES.has(surface)
80
+ ? { caseInsensitive: true, windowsSeparators: true }
81
+ : undefined;
82
+ }
83
+
84
+ function ruleMatches(
85
+ rule: Rule,
86
+ surface: string,
87
+ value: string,
88
+ platform: NodeJS.Platform,
89
+ ): boolean {
90
+ const matchOptions = pathMatchOptions(surface, platform);
91
+ return (
92
+ wildcardMatch(rule.surface, surface) &&
93
+ wildcardMatch(rule.pattern, value, matchOptions)
94
+ );
95
+ }
96
+
79
97
  /**
80
98
  * Evaluate a surface against an ordered list of candidate values, stopping at
81
99
  * the first candidate that matches a non-default rule (last-match-wins within
@@ -134,3 +152,35 @@ export function evaluateFirst(
134
152
  value: fallbackValue,
135
153
  };
136
154
  }
155
+
156
+ /**
157
+ * Evaluate equivalent lookup values as aliases of the same path.
158
+ *
159
+ * Unlike `evaluateFirst()`, this preserves rule ordering across aliases: the
160
+ * last rule that matches any alias wins. This lets absolute allowlists and
161
+ * legacy relative rules coexist without a catch-all match on the first alias
162
+ * masking a later, more specific rule on another alias.
163
+ */
164
+ export function evaluateAnyValue(
165
+ surface: string,
166
+ values: string[],
167
+ rules: Ruleset,
168
+ platform: NodeJS.Platform = process.platform,
169
+ ): { rule: Rule; value: string } {
170
+ const fallbackValue = values[0] ?? "*";
171
+ const rule = rules.findLast((r) =>
172
+ values.some((value) => ruleMatches(r, surface, value, platform)),
173
+ );
174
+ if (rule !== undefined) {
175
+ return {
176
+ rule,
177
+ value:
178
+ values.find((value) => ruleMatches(rule, surface, value, platform)) ??
179
+ fallbackValue,
180
+ };
181
+ }
182
+ return {
183
+ rule: evaluate(surface, fallbackValue, rules),
184
+ value: fallbackValue,
185
+ };
186
+ }
package/src/service.ts CHANGED
@@ -11,6 +11,7 @@
11
11
  * reference — this ensures resilience across `/reload` and load-order edge cases.
12
12
  */
13
13
 
14
+ import type { ToolAccessExtractor } from "./tool-access-extractor-registry";
14
15
  import type { ToolInputFormatter } from "./tool-input-formatter-registry";
15
16
  import type { PermissionCheckResult, PermissionState } from "./types";
16
17
 
@@ -80,6 +81,29 @@ export interface PermissionsService {
80
81
  formatter: ToolInputFormatter,
81
82
  ): () => void;
82
83
 
84
+ /**
85
+ * Register a custom access-intent extractor for a specific tool name.
86
+ *
87
+ * The extractor declares the filesystem path a tool will access so the
88
+ * cross-cutting `path` and `external_directory` gates can see it. Use it for
89
+ * tools whose path lives under a non-standard key — built-in file tools and
90
+ * any tool exposing `input.path` (plus MCP via `input.arguments.path`) are
91
+ * already covered by convention without registration.
92
+ *
93
+ * The extractor receives the raw `input` record and returns the path string,
94
+ * or `undefined` to decline. Only one extractor may be registered per tool
95
+ * name — a second call for the same name throws. The returned disposer
96
+ * unregisters the extractor.
97
+ *
98
+ * @param toolName - Exact tool name to register for (e.g. `"ffgrep"`).
99
+ * @param extractor - Receives the raw `input` record; return the path string,
100
+ * or `undefined` to decline.
101
+ */
102
+ registerToolAccessExtractor(
103
+ toolName: string,
104
+ extractor: ToolAccessExtractor,
105
+ ): () => void;
106
+
83
107
  /**
84
108
  * Query the tool-level permission state for pre-filtering tools before
85
109
  * creating a child session.
@@ -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
+ }
@@ -18,10 +18,7 @@ vi.mock("node:fs", () => ({
18
18
  }));
19
19
 
20
20
  import { formatDenyReason } from "#src/denial-messages";
21
- import {
22
- extractExternalPathsFromBashCommand,
23
- extractTokensForPathRules,
24
- } from "#src/handlers/gates/bash-path-extractor";
21
+ import { extractExternalPathsFromBashCommand } from "#src/handlers/gates/bash-path-extractor";
25
22
  import { formatBashExternalDirectoryAskPrompt } from "#src/handlers/gates/external-directory-messages";
26
23
 
27
24
  afterEach(() => {
@@ -957,80 +954,3 @@ describe("bash external-directory denial messages (centralized)", () => {
957
954
  expect(result).not.toContain("Hard stop");
958
955
  });
959
956
  });
960
-
961
- describe("extractTokensForPathRules", () => {
962
- test("extracts dot-files: cat .env", async () => {
963
- const tokens = await extractTokensForPathRules("cat .env");
964
- expect(tokens).toContain(".env");
965
- });
966
-
967
- test("extracts relative dot-paths: git add src/.env", async () => {
968
- const tokens = await extractTokensForPathRules("git add src/.env");
969
- expect(tokens).toContain("src/.env");
970
- });
971
-
972
- test("extracts nothing from plain words: echo hello", async () => {
973
- const tokens = await extractTokensForPathRules("echo hello");
974
- expect(tokens).toHaveLength(0);
975
- });
976
-
977
- test("extracts ./src and skips flags: rm -rf ./src", async () => {
978
- const tokens = await extractTokensForPathRules("rm -rf ./src");
979
- expect(tokens).toContain("./src");
980
- expect(tokens).not.toContain("-rf");
981
- });
982
-
983
- test("extracts absolute paths: cat /etc/hosts", async () => {
984
- const tokens = await extractTokensForPathRules("cat /etc/hosts");
985
- expect(tokens).toContain("/etc/hosts");
986
- });
987
-
988
- test("skips URLs: curl https://example.com", async () => {
989
- const tokens = await extractTokensForPathRules("curl https://example.com");
990
- expect(tokens).not.toContain("https://example.com");
991
- });
992
-
993
- test("extracts slash-containing tokens: cat src/foo.ts", async () => {
994
- const tokens = await extractTokensForPathRules("cat src/foo.ts");
995
- expect(tokens).toContain("src/foo.ts");
996
- });
997
-
998
- test("skips heredoc content", async () => {
999
- const tokens = await extractTokensForPathRules("cat <<EOF\n.env\nEOF");
1000
- expect(tokens).not.toContain(".env");
1001
- });
1002
-
1003
- test("skips @scope/package patterns", async () => {
1004
- const tokens = await extractTokensForPathRules(
1005
- "npm install @scope/package",
1006
- );
1007
- expect(tokens).not.toContain("@scope/package");
1008
- });
1009
-
1010
- test("skips env assignments", async () => {
1011
- const tokens = await extractTokensForPathRules("FOO=/bar command");
1012
- expect(tokens).not.toContain("FOO=/bar");
1013
- });
1014
-
1015
- test("skips bare-slash tokens", async () => {
1016
- const tokens = await extractTokensForPathRules("ls /");
1017
- expect(tokens).not.toContain("/");
1018
- });
1019
-
1020
- test("extracts redirect targets: echo test > .env", async () => {
1021
- const tokens = await extractTokensForPathRules("echo test > .env");
1022
- expect(tokens).toContain(".env");
1023
- });
1024
-
1025
- test("extracts multiple path tokens: cp .env .env.backup", async () => {
1026
- const tokens = await extractTokensForPathRules("cp .env .env.backup");
1027
- expect(tokens).toContain(".env");
1028
- expect(tokens).toContain(".env.backup");
1029
- });
1030
-
1031
- test("deduplicates repeated tokens", async () => {
1032
- const tokens = await extractTokensForPathRules("cat .env && rm .env");
1033
- const envCount = tokens.filter((t) => t === ".env").length;
1034
- expect(envCount).toBe(1);
1035
- });
1036
- });