@gotgenes/pi-permission-system 10.10.1 → 12.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.
package/CHANGELOG.md CHANGED
@@ -5,6 +5,46 @@ All notable changes to this project will be documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [12.0.0](https://github.com/gotgenes/pi-packages/compare/pi-permission-system-v11.0.0...pi-permission-system-v12.0.0) (2026-06-12)
9
+
10
+
11
+ ### ⚠ BREAKING CHANGES
12
+
13
+ * extension and MCP tools that expose a filesystem path (input.path, or input.arguments.path for MCP) are now subject to the path and external_directory permission gates. Tools previously ungated may now prompt or be denied under existing path rules.
14
+
15
+ ### Features
16
+
17
+ * add extensible tool input path extraction ([#352](https://github.com/gotgenes/pi-packages/issues/352)) ([3a54ea1](https://github.com/gotgenes/pi-packages/commit/3a54ea16be4d621bd7474f7a728d97ce9781a994))
18
+ * add tool access extractor registry ([#352](https://github.com/gotgenes/pi-packages/issues/352)) ([7a34f01](https://github.com/gotgenes/pi-packages/commit/7a34f0187f6b3fbb75e056082f04d3b805a37c8a))
19
+ * expose registerToolAccessExtractor via permissions service ([#352](https://github.com/gotgenes/pi-packages/issues/352)) ([5e02c16](https://github.com/gotgenes/pi-packages/commit/5e02c163b212adf9648a2631e4788f030539a36a))
20
+ * gate extension and MCP path tools by default ([#352](https://github.com/gotgenes/pi-packages/issues/352)) ([1d53f4f](https://github.com/gotgenes/pi-packages/commit/1d53f4ffa1a08e953b96437e9adf0214c6ca7465))
21
+
22
+
23
+ ### Documentation
24
+
25
+ * document path-aware extension/MCP gating and registerToolAccessExtractor ([#352](https://github.com/gotgenes/pi-packages/issues/352)) ([a2f825f](https://github.com/gotgenes/pi-packages/commit/a2f825f031ec26f1c47dbf13d056e724fda87021))
26
+
27
+ ## [11.0.0](https://github.com/gotgenes/pi-packages/compare/pi-permission-system-v10.10.1...pi-permission-system-v11.0.0) (2026-06-11)
28
+
29
+
30
+ ### ⚠ BREAKING CHANGES
31
+
32
+ * The permission system no longer auto-activates pi's off-by-default tools (`find`, `grep`, `ls`) in the main session. Users who want them active should enable them via pi's own `activeTools` configuration rather than relying on the permission system to expose every non-denied tool.
33
+
34
+ ### Features
35
+
36
+ * add getActive to ToolRegistry wired to pi.getActiveTools ([#385](https://github.com/gotgenes/pi-packages/issues/385)) ([79c4594](https://github.com/gotgenes/pi-packages/commit/79c459443294c1b58643b746e3511fc17c9f8961))
37
+
38
+
39
+ ### Bug Fixes
40
+
41
+ * respect pi's default active tool set in before_agent_start ([#385](https://github.com/gotgenes/pi-packages/issues/385)) ([bf5be48](https://github.com/gotgenes/pi-packages/commit/bf5be48ca8b06e8cb08f66d08eccb85af0673987))
42
+
43
+
44
+ ### Documentation
45
+
46
+ * clarify before_agent_start filters pi's active tool set ([#385](https://github.com/gotgenes/pi-packages/issues/385)) ([bdb5a6a](https://github.com/gotgenes/pi-packages/commit/bdb5a6a08e3c1bb611c8b1795c4d46856104b3b0))
47
+
8
48
  ## [10.10.1](https://github.com/gotgenes/pi-packages/compare/pi-permission-system-v10.10.0...pi-permission-system-v10.10.1) (2026-06-11)
9
49
 
10
50
 
package/README.md CHANGED
@@ -65,8 +65,8 @@ All permissions use one of three states:
65
65
  When the dialog prompts, you can approve once or approve a pattern for the rest of the session.
66
66
  See [docs/session-approvals.md](docs/session-approvals.md) for details on session-scoped rules and pattern suggestions.
67
67
 
68
- The `path` surface is a cross-cutting gate that applies to **all** file access — both Pi tools and bash commands.
69
- A `path` deny cannot be overridden by a per-tool allow, making it the right place to protect sensitive files like `.env` or `~/.ssh/*` from every tool at once.
68
+ The `path` surface is a cross-cutting gate that applies to **all** file access — Pi tools, bash commands, MCP calls, and extension tools alike.
69
+ Extension and MCP tools that operate on paths (via `input.path`, MCP's `input.arguments.path`, or a registered access extractor) are gated by default, so a `path` deny cannot be overridden by a per-tool allow making it the right place to protect sensitive files like `.env` or `~/.ssh/*` from every tool at once.
70
70
 
71
71
  For per-tool path patterns (`read`, `write`, `edit`, `find`, `grep`, `ls`), patterns are matched against the file path from `input.path`.
72
72
  This lets you express rules like "allow reads but deny `.env` files" at the individual tool level.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gotgenes/pi-permission-system",
3
- "version": "10.10.1",
3
+ "version": "12.0.0",
4
4
  "description": "Permission enforcement extension for the Pi coding agent.",
5
5
  "type": "module",
6
6
  "exports": {
@@ -53,7 +53,7 @@
53
53
  },
54
54
  "permission": {
55
55
  "description": "Flat permission policy. Each key is a surface name; values are a PermissionState string (catch-all) or a pattern→action map.",
56
- "markdownDescription": "Flat permission policy.\n\nEach top-level key is a surface name:\n- `\"*\"` — universal fallback (replaces `defaultPolicy.tools` from the legacy format)\n- Tool names (`read`, `write`, `bash`, `mcp`, `skill`, `external_directory`, `path`, etc.)\n\nA **string** value is shorthand for `{ \"*\": action }` (surface-level catch-all).\nAn **object** value maps wildcard patterns to actions — last matching pattern wins.\n\nFor path-bearing tools (`read`, `write`, `edit`, `find`, `grep`, `ls`), patterns are matched against the file path from `input.path`. For example, `\"read\": { \"*\": \"allow\", \"*.env\": \"deny\" }` allows reads but denies `.env` files.\n\nThe `path` surface is a cross-cutting gate that applies to **all** file access — both Pi tools and bash commands. A `path` deny cannot be overridden by a per-tool allow. Use it to protect sensitive files (`.env`, `~/.ssh/*`) from all tools at once.\n\n**Merge order (lowest → highest precedence):** global → project → per-agent frontmatter.",
56
+ "markdownDescription": "Flat permission policy.\n\nEach top-level key is a surface name:\n- `\"*\"` — universal fallback (replaces `defaultPolicy.tools` from the legacy format)\n- Tool names (`read`, `write`, `bash`, `mcp`, `skill`, `external_directory`, `path`, etc.)\n\nA **string** value is shorthand for `{ \"*\": action }` (surface-level catch-all).\nAn **object** value maps wildcard patterns to actions — last matching pattern wins.\n\nFor built-in file tools (`read`, `write`, `edit`, `find`, `grep`, `ls`), patterns are matched against the file path from `input.path`. For example, `\"read\": { \"*\": \"allow\", \"*.env\": \"deny\" }` allows reads but denies `.env` files.\n\nThe `path` surface is a cross-cutting gate that applies to **all** file access: Pi tools, bash commands, MCP calls (via `input.arguments.path`), and extension tools (via `input.path` or a registered access extractor). A `path` deny cannot be overridden by a per-tool allow. Use it to protect sensitive files (`.env`, `~/.ssh/*`) from all path-aware tools at once.\n\n**Merge order (lowest → highest precedence):** global → project → per-agent frontmatter.",
57
57
  "type": "object",
58
58
  "propertyNames": {
59
59
  "description": "A surface name or the universal fallback key '*'.",
@@ -38,7 +38,7 @@ export function shouldExposeTool(
38
38
  * Constructor deps:
39
39
  * - `session` — encapsulates all mutable session state and lifecycle operations
40
40
  * - `resolver` — owns permission-query surface: `getToolPermission`, `getPolicyCacheStamp`, skill check
41
- * - `toolRegistry` — Pi tool API subset (getAll + setActive)
41
+ * - `toolRegistry` — Pi tool API subset (getActive + setActive)
42
42
  */
43
43
  export class AgentPrepHandler {
44
44
  constructor(
@@ -56,10 +56,10 @@ export class AgentPrepHandler {
56
56
  this.session.refreshConfig(ctx);
57
57
 
58
58
  const agentName = this.session.resolveAgentName(ctx, event.systemPrompt);
59
- const allTools = this.toolRegistry.getAll();
59
+ const activeTools = this.toolRegistry.getActive();
60
60
  const allowedTools: string[] = [];
61
61
 
62
- for (const tool of allTools) {
62
+ for (const tool of activeTools) {
63
63
  const toolName = getToolNameFromValue(tool);
64
64
  if (!toolName) {
65
65
  continue;
@@ -1,11 +1,12 @@
1
1
  import {
2
2
  canonicalNormalizePathForComparison,
3
- getPathBearingToolPath,
3
+ getToolInputPath,
4
4
  isPathOutsideWorkingDirectory,
5
5
  isPiInfrastructureRead,
6
6
  } from "#src/path-utils";
7
7
  import { SessionApproval } from "#src/session-approval";
8
8
  import { deriveApprovalPattern } from "#src/session-rules";
9
+ import type { ToolAccessExtractorLookup } from "#src/tool-access-extractor-registry";
9
10
  import type { GateResult } from "./descriptor";
10
11
  import { formatExternalDirectoryAskPrompt } from "./external-directory-messages";
11
12
  import type { ToolCallContext } from "./types";
@@ -21,10 +22,15 @@ import type { ToolCallContext } from "./types";
21
22
  export function describeExternalDirectoryGate(
22
23
  tcc: ToolCallContext,
23
24
  infraDirs: string[],
25
+ extractors?: ToolAccessExtractorLookup,
24
26
  ): GateResult {
25
27
  if (!tcc.cwd) return null;
26
28
 
27
- const externalDirectoryPath = getPathBearingToolPath(tcc.toolName, tcc.input);
29
+ const externalDirectoryPath = getToolInputPath(
30
+ tcc.toolName,
31
+ tcc.input,
32
+ extractors,
33
+ );
28
34
  if (!externalDirectoryPath) return null;
29
35
 
30
36
  if (!isPathOutsideWorkingDirectory(externalDirectoryPath, tcc.cwd)) {
@@ -1,7 +1,8 @@
1
- import { getPathBearingToolPath } from "#src/path-utils";
1
+ import { getToolInputPath } from "#src/path-utils";
2
2
  import type { ScopedPermissionResolver } from "#src/permission-resolver";
3
3
  import { SessionApproval } from "#src/session-approval";
4
4
  import { deriveApprovalPattern } from "#src/session-rules";
5
+ import type { ToolAccessExtractorLookup } from "#src/tool-access-extractor-registry";
5
6
  import type { GateDescriptor, GateResult } from "./descriptor";
6
7
  import type { ToolCallContext } from "./types";
7
8
 
@@ -16,8 +17,9 @@ import type { ToolCallContext } from "./types";
16
17
  export function describePathGate(
17
18
  tcc: ToolCallContext,
18
19
  resolver: ScopedPermissionResolver,
20
+ extractors?: ToolAccessExtractorLookup,
19
21
  ): GateResult {
20
- const filePath = getPathBearingToolPath(tcc.toolName, tcc.input);
22
+ const filePath = getToolInputPath(tcc.toolName, tcc.input, extractors);
21
23
  if (!filePath) return null;
22
24
 
23
25
  const check = resolver.resolve(
@@ -1,6 +1,7 @@
1
1
  import { getNonEmptyString, toRecord } from "#src/common";
2
2
  import type { ScopedPermissionResolver } from "#src/permission-resolver";
3
3
  import type { SkillPromptEntry } from "#src/skill-prompt-sanitizer";
4
+ import type { ToolAccessExtractorLookup } from "#src/tool-access-extractor-registry";
4
5
  import type { ToolInputFormatterLookup } from "#src/tool-input-formatter-registry";
5
6
  import {
6
7
  ToolPreviewFormatter,
@@ -53,6 +54,7 @@ export class ToolCallGatePipeline {
53
54
  private readonly resolver: ScopedPermissionResolver,
54
55
  private readonly inputs: ToolCallGateInputs,
55
56
  private readonly customFormatters?: ToolInputFormatterLookup,
57
+ private readonly customExtractors?: ToolAccessExtractorLookup,
56
58
  ) {}
57
59
 
58
60
  async evaluate(
@@ -77,8 +79,9 @@ export class ToolCallGatePipeline {
77
79
  const gateProducers: Array<() => GateResult | Promise<GateResult>> = [
78
80
  () =>
79
81
  describeSkillReadGate(tcc, () => this.inputs.getActiveSkillEntries()),
80
- () => describePathGate(tcc, this.resolver),
81
- () => describeExternalDirectoryGate(tcc, infraDirs),
82
+ () => describePathGate(tcc, this.resolver, this.customExtractors),
83
+ () =>
84
+ describeExternalDirectoryGate(tcc, infraDirs, this.customExtractors),
82
85
  () => describeBashExternalDirectoryGate(tcc, bashProgram, this.resolver),
83
86
  () => describeBashPathGate(tcc, bashProgram, this.resolver),
84
87
  () => {
package/src/index.ts CHANGED
@@ -32,6 +32,7 @@ import { PermissionSessionLogger } from "./session-logger";
32
32
  import { SessionRules } from "./session-rules";
33
33
  import { subscribeSubagentLifecycle } from "./subagent-lifecycle-events";
34
34
  import { getSubagentSessionRegistry } from "./subagent-registry";
35
+ import { ToolAccessExtractorRegistry } from "./tool-access-extractor-registry";
35
36
  import { ToolInputFormatterRegistry } from "./tool-input-formatter-registry";
36
37
 
37
38
  export default function piPermissionSystemExtension(pi: ExtensionAPI): void {
@@ -44,6 +45,7 @@ export default function piPermissionSystemExtension(pi: ExtensionAPI): void {
44
45
  const subagentRegistry = getSubagentSessionRegistry();
45
46
  const formatterRegistry = new ToolInputFormatterRegistry();
46
47
  registerBuiltinToolInputFormatters(formatterRegistry);
48
+ const accessExtractorRegistry = new ToolAccessExtractorRegistry();
47
49
 
48
50
  // Both `configStore` and `session` are forward-declared so the logger's
49
51
  // lazy thunks can close over them without a cast or null-init holder.
@@ -129,6 +131,7 @@ export default function piPermissionSystemExtension(pi: ExtensionAPI): void {
129
131
  permissionManager,
130
132
  sessionRules,
131
133
  formatterRegistry,
134
+ accessExtractorRegistry,
132
135
  );
133
136
 
134
137
  // Subscribe to @gotgenes/pi-subagents' child lifecycle events so child
@@ -152,6 +155,7 @@ export default function piPermissionSystemExtension(pi: ExtensionAPI): void {
152
155
 
153
156
  const toolRegistry = {
154
157
  getAll: () => pi.getAllTools(),
158
+ getActive: () => pi.getActiveTools(),
155
159
  setActive: (names: string[]) => pi.setActiveTools(names),
156
160
  };
157
161
 
@@ -171,6 +175,7 @@ export default function piPermissionSystemExtension(pi: ExtensionAPI): void {
171
175
  resolver,
172
176
  session,
173
177
  formatterRegistry,
178
+ accessExtractorRegistry,
174
179
  );
175
180
  const skillInputGatePipeline = new SkillInputGatePipeline(resolver);
176
181
  const gates = new PermissionGateHandler(
package/src/path-utils.ts CHANGED
@@ -9,6 +9,7 @@ import {
9
9
  import { canonicalizePath } from "./canonicalize-path";
10
10
  import { getNonEmptyString, toRecord } from "./common";
11
11
  import { expandHomePath } from "./expand-home";
12
+ import type { ToolAccessExtractorLookup } from "./tool-access-extractor-registry";
12
13
  import { wildcardMatch } from "./wildcard-matcher";
13
14
 
14
15
  export function normalizePathForComparison(
@@ -123,6 +124,46 @@ export function getPathBearingToolPath(
123
124
  return getNonEmptyString(toRecord(input).path);
124
125
  }
125
126
 
127
+ /**
128
+ * Extract the filesystem path a tool will access, for the cross-cutting `path`
129
+ * and `external_directory` gates.
130
+ *
131
+ * Unlike {@link getPathBearingToolPath} (built-in tools only), this recognizes
132
+ * extension and MCP tools so they are no longer exempt from path gating:
133
+ *
134
+ * - `bash` → `null` (bash has its own token-based path gates).
135
+ * - Built-in path-bearing tools → `input.path`.
136
+ * - `mcp` → `input.arguments.path`.
137
+ * - Any other tool → a registered {@link ToolAccessExtractor}'s path, else the
138
+ * default `input.path` convention.
139
+ */
140
+ export function getToolInputPath(
141
+ toolName: string,
142
+ input: unknown,
143
+ extractors?: ToolAccessExtractorLookup,
144
+ ): string | null {
145
+ if (toolName === "bash") {
146
+ return null;
147
+ }
148
+
149
+ const record = toRecord(input);
150
+
151
+ if (PATH_BEARING_TOOLS.has(toolName)) {
152
+ return getNonEmptyString(record.path);
153
+ }
154
+
155
+ if (toolName === "mcp") {
156
+ return getNonEmptyString(toRecord(record.arguments).path);
157
+ }
158
+
159
+ const custom = extractors?.get(toolName);
160
+ if (custom) {
161
+ return getNonEmptyString(custom(record));
162
+ }
163
+
164
+ return getNonEmptyString(record.path);
165
+ }
166
+
126
167
  /**
127
168
  * Like {@link normalizePathForComparison} but also resolves symlinks via
128
169
  * `realpathSync` (best-effort). Use this for containment decisions where the
@@ -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/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
+ }
@@ -2,7 +2,10 @@ import { getNonEmptyString, toRecord } from "./common";
2
2
 
3
3
  /** Narrow interface for the Pi tool API subset used by handler classes. */
4
4
  export interface ToolRegistry {
5
+ /** All registered tools (`pi.getAllTools()` — `ToolInfo[]`); kept defensively wide. */
5
6
  getAll(): unknown[];
7
+ /** Currently active tool names (`pi.getActiveTools()`). */
8
+ getActive(): string[];
6
9
  setActive(names: string[]): void;
7
10
  }
8
11
 
@@ -337,6 +337,42 @@ describe("service and gate share one formatter registry", () => {
337
337
  });
338
338
  });
339
339
 
340
+ describe("service and gate share one access extractor registry", () => {
341
+ // An extractor registered through the published service must be consulted by
342
+ // the live gate handler — proving both reference the same
343
+ // ToolAccessExtractorRegistry instance the factory created once (#352).
344
+ it("path-gates a custom-shaped tool via a service-registered extractor", async () => {
345
+ writeGlobalConfig({
346
+ permission: { "*": "allow", path: { "*.env": "deny" } },
347
+ });
348
+
349
+ const cwd = mkdtempSync(join(tmpdir(), "pi-perm-ext-cwd-"));
350
+ const pi = makeFakePi({ toolNames: ["ffgrep"] });
351
+ piPermissionSystemExtension(pi as unknown as ExtensionAPI);
352
+
353
+ const { ctx } = makeUiCtx(cwd, []);
354
+ await fireSessionStart(pi, ctx);
355
+
356
+ // ffgrep carries its path under a non-standard key; without the extractor
357
+ // the default input.path convention would miss it.
358
+ getPermissionsService()!.registerToolAccessExtractor("ffgrep", (input) =>
359
+ typeof input.target === "string" ? input.target : undefined,
360
+ );
361
+
362
+ const result = (await pi.fire(
363
+ "tool_call",
364
+ { toolName: "ffgrep", toolCallId: "ff-1", input: { target: ".env" } },
365
+ ctx,
366
+ )) as { block?: true };
367
+
368
+ // The path deny fired — so the gate extracted ffgrep's path through the
369
+ // same registry the service wrote to.
370
+ expect(result.block).toBe(true);
371
+
372
+ rmSync(cwd, { recursive: true, force: true });
373
+ });
374
+ });
375
+
340
376
  describe("ready emitted after service publication", () => {
341
377
  // Ordering contracts exist only at the composition root: a consumer reacting
342
378
  // to permissions:ready must be able to resolve the service immediately. The
@@ -31,6 +31,7 @@ function makeEvent(systemPrompt = "You are an assistant.") {
31
31
  function makeToolRegistry(overrides: Partial<ToolRegistry> = {}): ToolRegistry {
32
32
  return {
33
33
  getAll: vi.fn().mockReturnValue([]),
34
+ getActive: vi.fn().mockReturnValue([]),
34
35
  setActive: vi.fn(),
35
36
  ...overrides,
36
37
  };
@@ -126,7 +127,7 @@ describe("AgentPrepHandler.handle", () => {
126
127
  const { handler, toolRegistry } = makeSetup({
127
128
  toolPermission: "deny",
128
129
  toolRegistry: {
129
- getAll: vi.fn().mockReturnValue([{ name: "write" }, { name: "read" }]),
130
+ getActive: vi.fn().mockReturnValue(["write", "read"]),
130
131
  },
131
132
  });
132
133
  await handler.handle(makeEvent(), makeCtx());
@@ -136,17 +137,44 @@ describe("AgentPrepHandler.handle", () => {
136
137
  it("includes allowed and ask tools in the active list", async () => {
137
138
  const { handler, toolRegistry } = makeSetup({
138
139
  toolRegistry: {
139
- getAll: vi.fn().mockReturnValue([{ name: "read" }, { name: "write" }]),
140
+ getActive: vi.fn().mockReturnValue(["read", "write"]),
140
141
  },
141
142
  });
142
143
  await handler.handle(makeEvent(), makeCtx());
143
144
  expect(toolRegistry.setActive).toHaveBeenCalledWith(["read", "write"]);
144
145
  });
145
146
 
147
+ it("does not activate registered tools pi left inactive (find/grep/ls)", async () => {
148
+ // Regression for #385: the active set is the base, not the full registry.
149
+ const { handler, toolRegistry } = makeSetup({
150
+ toolRegistry: {
151
+ getActive: vi.fn().mockReturnValue(["read", "bash", "edit", "write"]),
152
+ getAll: vi
153
+ .fn()
154
+ .mockReturnValue([
155
+ { name: "read" },
156
+ { name: "bash" },
157
+ { name: "edit" },
158
+ { name: "write" },
159
+ { name: "find" },
160
+ { name: "grep" },
161
+ { name: "ls" },
162
+ ]),
163
+ },
164
+ });
165
+ await handler.handle(makeEvent(), makeCtx());
166
+ expect(toolRegistry.setActive).toHaveBeenCalledWith([
167
+ "read",
168
+ "bash",
169
+ "edit",
170
+ "write",
171
+ ]);
172
+ });
173
+
146
174
  it("calls setActive once across repeated calls with the same allowed tools", async () => {
147
175
  const { handler, toolRegistry } = makeSetup({
148
176
  toolRegistry: {
149
- getAll: vi.fn().mockReturnValue([{ name: "read" }]),
177
+ getActive: vi.fn().mockReturnValue(["read"]),
150
178
  },
151
179
  });
152
180
  await handler.handle(makeEvent(), makeCtx());
@@ -168,3 +168,57 @@ describe("describeExternalDirectoryGate", () => {
168
168
  expect(result.logContext.message).toBeDefined();
169
169
  });
170
170
  });
171
+
172
+ // Extension and MCP tools are now external-directory gated (#352) ───────────
173
+
174
+ describe("describeExternalDirectoryGate — extension and MCP tools (#352)", () => {
175
+ it("gates an extension tool with an external input.path", () => {
176
+ const result = describeExternalDirectoryGate(
177
+ makeTcc({
178
+ toolName: "my-ext",
179
+ input: { path: "/outside/project/file.ts" },
180
+ }),
181
+ ["/test/agent"],
182
+ );
183
+ expect(isGateDescriptor(result)).toBe(true);
184
+ expect((result as GateDescriptor).surface).toBe("external_directory");
185
+ });
186
+
187
+ it("gates an MCP tool with an external arguments.path", () => {
188
+ const result = describeExternalDirectoryGate(
189
+ makeTcc({
190
+ toolName: "mcp",
191
+ input: { arguments: { path: "/outside/project/file.ts" } },
192
+ }),
193
+ ["/test/agent"],
194
+ );
195
+ expect(isGateDescriptor(result)).toBe(true);
196
+ });
197
+
198
+ it("uses a registered extractor's external path for a custom-shaped tool", () => {
199
+ const extractors = {
200
+ get: (name: string) =>
201
+ name === "ffgrep"
202
+ ? (input: Record<string, unknown>) =>
203
+ typeof input.target === "string" ? input.target : undefined
204
+ : undefined,
205
+ };
206
+ const result = describeExternalDirectoryGate(
207
+ makeTcc({ toolName: "ffgrep", input: { target: "/outside/project/x" } }),
208
+ ["/test/agent"],
209
+ extractors,
210
+ );
211
+ expect(isGateDescriptor(result)).toBe(true);
212
+ });
213
+
214
+ it("returns null for an extension tool whose path is inside cwd", () => {
215
+ const result = describeExternalDirectoryGate(
216
+ makeTcc({
217
+ toolName: "my-ext",
218
+ input: { path: "/test/project/src/x.ts" },
219
+ }),
220
+ ["/test/agent"],
221
+ );
222
+ expect(result).toBeNull();
223
+ });
224
+ });
@@ -206,3 +206,75 @@ describe("describePathGate — home-relative paths", () => {
206
206
  expect(result).toBeNull();
207
207
  });
208
208
  });
209
+
210
+ // Extension and MCP tools are now path-gated (#352) ──────────────────────────
211
+
212
+ describe("describePathGate — extension and MCP tools (#352)", () => {
213
+ function extractorLookup(toolName: string, key: string) {
214
+ return {
215
+ get: (name: string) =>
216
+ name === toolName
217
+ ? (input: Record<string, unknown>) =>
218
+ typeof input[key] === "string" ? input[key] : undefined
219
+ : undefined,
220
+ };
221
+ }
222
+
223
+ it("gates an extension tool that exposes input.path", () => {
224
+ const resolver = makeResolver(
225
+ makeCheckResult({ state: "deny", matchedPattern: "*.env" }),
226
+ );
227
+ const result = describePathGate(
228
+ makeTcc({ toolName: "my-ext", input: { path: ".env" } }),
229
+ resolver,
230
+ );
231
+ expect(isGateDescriptor(result)).toBe(true);
232
+ expect(resolver.resolve).toHaveBeenCalledWith(
233
+ "path",
234
+ { path: ".env" },
235
+ undefined,
236
+ );
237
+ });
238
+
239
+ it("gates an MCP tool via arguments.path", () => {
240
+ const resolver = makeResolver(
241
+ makeCheckResult({ state: "deny", matchedPattern: "*.env" }),
242
+ );
243
+ const result = describePathGate(
244
+ makeTcc({ toolName: "mcp", input: { arguments: { path: ".env" } } }),
245
+ resolver,
246
+ );
247
+ expect(isGateDescriptor(result)).toBe(true);
248
+ expect(resolver.resolve).toHaveBeenCalledWith(
249
+ "path",
250
+ { path: ".env" },
251
+ undefined,
252
+ );
253
+ });
254
+
255
+ it("uses a registered extractor's path for a custom-shaped tool", () => {
256
+ const resolver = makeResolver(
257
+ makeCheckResult({ state: "deny", matchedPattern: "*" }),
258
+ );
259
+ describePathGate(
260
+ makeTcc({ toolName: "ffgrep", input: { target: "/etc/passwd" } }),
261
+ resolver,
262
+ extractorLookup("ffgrep", "target"),
263
+ );
264
+ expect(resolver.resolve).toHaveBeenCalledWith(
265
+ "path",
266
+ { path: "/etc/passwd" },
267
+ undefined,
268
+ );
269
+ });
270
+
271
+ it("returns null for an extension tool without a path", () => {
272
+ const resolver = makeResolver();
273
+ const result = describePathGate(
274
+ makeTcc({ toolName: "my-ext", input: { other: true } }),
275
+ resolver,
276
+ );
277
+ expect(result).toBeNull();
278
+ expect(resolver.resolve).not.toHaveBeenCalled();
279
+ });
280
+ });
@@ -186,4 +186,67 @@ describe("ToolCallGatePipeline", () => {
186
186
  expect(mockBashProgramParse).not.toHaveBeenCalled();
187
187
  });
188
188
  });
189
+
190
+ // ── customExtractors threading (#352) ────────────────────────────────────
191
+
192
+ describe("evaluate — customExtractors threading (#352)", () => {
193
+ // Deny only the cross-cutting `path` surface; allow everything else, so a
194
+ // block can only come from the path gate seeing the extracted path.
195
+ function pathDenyingResolver() {
196
+ const resolver = makeResolver();
197
+ resolver.resolve.mockImplementation((surface) =>
198
+ surface === "path"
199
+ ? makeCheckResult({ state: "deny", matchedPattern: "*" })
200
+ : makeCheckResult(),
201
+ );
202
+ return resolver;
203
+ }
204
+
205
+ const extractors = {
206
+ get: (name: string) =>
207
+ name === "ffgrep"
208
+ ? (input: Record<string, unknown>) =>
209
+ typeof input.target === "string" ? input.target : undefined
210
+ : undefined,
211
+ };
212
+
213
+ it("forwards extractors so a custom-shaped tool is path-gated", async () => {
214
+ const resolver = pathDenyingResolver();
215
+ const inputs = makeGateInputs();
216
+ const { runner } = makeGateRunner();
217
+ const pipeline = new ToolCallGatePipeline(
218
+ resolver,
219
+ inputs,
220
+ undefined,
221
+ extractors,
222
+ );
223
+
224
+ const result = await pipeline.evaluate(
225
+ makeTcc({
226
+ toolName: "ffgrep",
227
+ input: { target: "/test/project/secret.env" },
228
+ }),
229
+ runner,
230
+ );
231
+
232
+ expect(result).toMatchObject({ action: "block" });
233
+ });
234
+
235
+ it("without extractors the custom-shaped tool is not path-gated", async () => {
236
+ const resolver = pathDenyingResolver();
237
+ const inputs = makeGateInputs();
238
+ const { runner } = makeGateRunner();
239
+ const pipeline = new ToolCallGatePipeline(resolver, inputs);
240
+
241
+ const result = await pipeline.evaluate(
242
+ makeTcc({
243
+ toolName: "ffgrep",
244
+ input: { target: "/test/project/secret.env" },
245
+ }),
246
+ runner,
247
+ );
248
+
249
+ expect(result).toEqual({ action: "allow" });
250
+ });
251
+ });
189
252
  });
@@ -123,6 +123,7 @@ export function makeToolRegistry(
123
123
  ): ToolRegistry {
124
124
  return {
125
125
  getAll: vi.fn().mockReturnValue([{ name: "read" }, { name: "bash" }]),
126
+ getActive: vi.fn().mockReturnValue(["read", "bash"]),
126
127
  setActive: vi.fn(),
127
128
  ...overrides,
128
129
  };
@@ -42,6 +42,8 @@ export interface FakePi {
42
42
  fire(event: string, input?: unknown, ctx?: unknown): Promise<unknown>;
43
43
  /** Minimal tool registry — returns the configured tool names. */
44
44
  getAllTools(): { name: string }[];
45
+ /** Active tool names (`pi.getActiveTools()` shape — bare strings). */
46
+ getActiveTools(): string[];
45
47
  setActiveTools(names: string[]): void;
46
48
  }
47
49
 
@@ -80,6 +82,9 @@ export function makeFakePi(options: MakeFakePiOptions = {}): FakePi {
80
82
  getAllTools(): { name: string }[] {
81
83
  return toolNames.map((name) => ({ name }));
82
84
  },
85
+ getActiveTools(): string[] {
86
+ return [...toolNames];
87
+ },
83
88
  setActiveTools: vi.fn(),
84
89
  // ── ExtensionAPI methods the factory touches (recorded) ────────────────
85
90
  on(event: string, handler: RecordedHandler): void {
@@ -23,6 +23,7 @@ vi.mock("node:fs", () => ({
23
23
  import {
24
24
  canonicalNormalizePathForComparison,
25
25
  getPathBearingToolPath,
26
+ getToolInputPath,
26
27
  isPathOutsideWorkingDirectory,
27
28
  isPathWithinDirectory,
28
29
  isPiInfrastructureRead,
@@ -32,6 +33,7 @@ import {
32
33
  READ_ONLY_PATH_BEARING_TOOLS,
33
34
  SAFE_SYSTEM_PATHS,
34
35
  } from "#src/path-utils";
36
+ import type { ToolAccessExtractorLookup } from "#src/tool-access-extractor-registry";
35
37
 
36
38
  describe("normalizePathForComparison", () => {
37
39
  const cwd = "/projects/my-app";
@@ -244,6 +246,67 @@ describe("getPathBearingToolPath", () => {
244
246
  });
245
247
  });
246
248
 
249
+ describe("getToolInputPath", () => {
250
+ function lookupOf(
251
+ toolName: string,
252
+ extractor: (input: Record<string, unknown>) => string | undefined,
253
+ ): ToolAccessExtractorLookup {
254
+ return {
255
+ get: (name) => (name === toolName ? extractor : undefined),
256
+ };
257
+ }
258
+
259
+ test("returns input.path for a built-in path-bearing tool", () => {
260
+ expect(getToolInputPath("read", { path: "/src/foo.ts" })).toBe(
261
+ "/src/foo.ts",
262
+ );
263
+ expect(getToolInputPath("write", { path: "/src/bar.ts" })).toBe(
264
+ "/src/bar.ts",
265
+ );
266
+ });
267
+
268
+ test("returns null for bash", () => {
269
+ expect(getToolInputPath("bash", { path: "/src/foo.ts" })).toBeNull();
270
+ });
271
+
272
+ test("returns the MCP arguments.path for an mcp call", () => {
273
+ expect(getToolInputPath("mcp", { arguments: { path: "/etc/hosts" } })).toBe(
274
+ "/etc/hosts",
275
+ );
276
+ });
277
+
278
+ test("returns null for an mcp call without an arguments.path", () => {
279
+ expect(getToolInputPath("mcp", { arguments: { query: "x" } })).toBeNull();
280
+ expect(getToolInputPath("mcp", {})).toBeNull();
281
+ });
282
+
283
+ test("defaults to input.path for an unregistered extension tool", () => {
284
+ expect(getToolInputPath("my-ext", { path: "/work/file.txt" })).toBe(
285
+ "/work/file.txt",
286
+ );
287
+ });
288
+
289
+ test("returns null for an extension tool without a path", () => {
290
+ expect(getToolInputPath("my-ext", { other: true })).toBeNull();
291
+ expect(getToolInputPath("my-ext", { path: "" })).toBeNull();
292
+ expect(getToolInputPath("my-ext", null)).toBeNull();
293
+ });
294
+
295
+ test("uses a registered extractor's path over the default convention", () => {
296
+ const extractors = lookupOf("ffgrep", (input) =>
297
+ typeof input.target === "string" ? input.target : undefined,
298
+ );
299
+ expect(
300
+ getToolInputPath("ffgrep", { target: "/etc/passwd" }, extractors),
301
+ ).toBe("/etc/passwd");
302
+ });
303
+
304
+ test("returns null when a registered extractor declines", () => {
305
+ const extractors = lookupOf("ffgrep", () => undefined);
306
+ expect(getToolInputPath("ffgrep", { target: "x" }, extractors)).toBeNull();
307
+ });
308
+ });
309
+
247
310
  describe("isPathOutsideWorkingDirectory", () => {
248
311
  const cwd = "/projects/my-app";
249
312
 
@@ -363,6 +363,7 @@ describe("piPermissionSystemExtension ready event wiring", () => {
363
363
  ),
364
364
  registerCommand: vi.fn(),
365
365
  getAllTools: vi.fn().mockReturnValue([]),
366
+ getActiveTools: vi.fn().mockReturnValue([]),
366
367
  setActiveTools: vi.fn(),
367
368
  registerProvider: vi.fn(),
368
369
  events: { emit: emitSpy, on: vi.fn().mockReturnValue(() => undefined) },
@@ -3,6 +3,7 @@ import type { ScopedPermissionManager } from "#src/permission-manager";
3
3
  import { LocalPermissionsService } from "#src/permissions-service";
4
4
  import type { Ruleset } from "#src/rule";
5
5
  import type { SessionRules } from "#src/session-rules";
6
+ import type { ToolAccessExtractorRegistrar } from "#src/tool-access-extractor-registry";
6
7
  import type {
7
8
  ToolInputFormatter,
8
9
  ToolInputFormatterRegistrar,
@@ -39,22 +40,40 @@ function makeFormatterRegistry(): ToolInputFormatterRegistrar {
39
40
  };
40
41
  }
41
42
 
43
+ function makeAccessExtractorRegistry(): ToolAccessExtractorRegistrar {
44
+ return {
45
+ register: vi
46
+ .fn<ToolAccessExtractorRegistrar["register"]>()
47
+ .mockReturnValue(vi.fn()),
48
+ };
49
+ }
50
+
42
51
  function makeService(overrides?: {
43
52
  permissionManager?: ScopedPermissionManager;
44
53
  sessionRules?: Pick<SessionRules, "getRuleset">;
45
54
  formatterRegistry?: ToolInputFormatterRegistrar;
55
+ accessExtractorRegistry?: ToolAccessExtractorRegistrar;
46
56
  }) {
47
57
  const permissionManager =
48
58
  overrides?.permissionManager ?? makeFakePermissionManager();
49
59
  const sessionRules = overrides?.sessionRules ?? makeSessionRules();
50
60
  const formatterRegistry =
51
61
  overrides?.formatterRegistry ?? makeFormatterRegistry();
62
+ const accessExtractorRegistry =
63
+ overrides?.accessExtractorRegistry ?? makeAccessExtractorRegistry();
52
64
  const service = new LocalPermissionsService(
53
65
  permissionManager,
54
66
  sessionRules,
55
67
  formatterRegistry,
68
+ accessExtractorRegistry,
56
69
  );
57
- return { service, permissionManager, sessionRules, formatterRegistry };
70
+ return {
71
+ service,
72
+ permissionManager,
73
+ sessionRules,
74
+ formatterRegistry,
75
+ accessExtractorRegistry,
76
+ };
58
77
  }
59
78
 
60
79
  // ── tests ──────────────────────────────────────────────────────────────────
@@ -141,3 +160,18 @@ describe("registerToolInputFormatter", () => {
141
160
  expect(result).toBe(unsub);
142
161
  });
143
162
  });
163
+
164
+ describe("registerToolAccessExtractor", () => {
165
+ it("delegates to accessExtractorRegistry.register and returns the unsubscribe function", () => {
166
+ const unsub = vi.fn();
167
+ const { service, accessExtractorRegistry } = makeService();
168
+ vi.mocked(accessExtractorRegistry.register).mockReturnValue(unsub);
169
+ const extractor = vi.fn();
170
+ const result = service.registerToolAccessExtractor("ffgrep", extractor);
171
+ expect(accessExtractorRegistry.register).toHaveBeenCalledWith(
172
+ "ffgrep",
173
+ extractor,
174
+ );
175
+ expect(result).toBe(unsub);
176
+ });
177
+ });
@@ -35,6 +35,7 @@ function makeService(): PermissionsService {
35
35
  checkPermission: vi.fn(),
36
36
  getToolPermission: vi.fn(),
37
37
  registerToolInputFormatter: vi.fn(),
38
+ registerToolAccessExtractor: vi.fn(),
38
39
  };
39
40
  }
40
41
 
@@ -6,6 +6,7 @@ import {
6
6
  publishPermissionsService,
7
7
  unpublishPermissionsService,
8
8
  } from "#src/service";
9
+ import { ToolAccessExtractorRegistry } from "#src/tool-access-extractor-registry";
9
10
  import { ToolInputFormatterRegistry } from "#src/tool-input-formatter-registry";
10
11
  import type { PermissionCheckResult } from "#src/types";
11
12
 
@@ -18,6 +19,7 @@ function makeService(
18
19
  checkPermission: vi.fn(),
19
20
  getToolPermission: vi.fn(),
20
21
  registerToolInputFormatter: vi.fn(),
22
+ registerToolAccessExtractor: vi.fn(),
21
23
  ...overrides,
22
24
  };
23
25
  }
@@ -155,6 +157,7 @@ describe("service adapter delegation", () => {
155
157
  return getToolPermissionFn(toolName, agentName);
156
158
  },
157
159
  registerToolInputFormatter: vi.fn(),
160
+ registerToolAccessExtractor: vi.fn(),
158
161
  };
159
162
 
160
163
  publishPermissionsService(service);
@@ -177,6 +180,7 @@ describe("service adapter delegation", () => {
177
180
  return getToolPermissionFn(toolName, agentName);
178
181
  },
179
182
  registerToolInputFormatter: vi.fn(),
183
+ registerToolAccessExtractor: vi.fn(),
180
184
  };
181
185
 
182
186
  publishPermissionsService(service);
@@ -253,3 +257,52 @@ describe("registerToolInputFormatter delegation", () => {
253
257
  ).toThrow("my-tool");
254
258
  });
255
259
  });
260
+
261
+ // ── registerToolAccessExtractor delegation (#352) ────────────────────────
262
+
263
+ describe("registerToolAccessExtractor delegation", () => {
264
+ afterEach(() => {
265
+ const current = getPermissionsService();
266
+ if (current) {
267
+ unpublishPermissionsService(current);
268
+ }
269
+ });
270
+
271
+ it("delegates to the registry and returns its disposer", () => {
272
+ const registry = new ToolAccessExtractorRegistry();
273
+ const extractor = () => "/etc/hosts";
274
+
275
+ const service = makeService({
276
+ registerToolAccessExtractor(toolName, ext) {
277
+ return registry.register(toolName, ext);
278
+ },
279
+ });
280
+
281
+ publishPermissionsService(service);
282
+ const dispose = getPermissionsService()!.registerToolAccessExtractor(
283
+ "ffgrep",
284
+ extractor,
285
+ );
286
+
287
+ expect(registry.get("ffgrep")).toBe(extractor);
288
+
289
+ dispose();
290
+ expect(registry.get("ffgrep")).toBeUndefined();
291
+ });
292
+
293
+ it("throws when an extractor is already registered for the tool name", () => {
294
+ const registry = new ToolAccessExtractorRegistry();
295
+ registry.register("ffgrep", () => undefined);
296
+
297
+ const service = makeService({
298
+ registerToolAccessExtractor(toolName, ext) {
299
+ return registry.register(toolName, ext);
300
+ },
301
+ });
302
+
303
+ publishPermissionsService(service);
304
+ expect(() =>
305
+ getPermissionsService()!.registerToolAccessExtractor("ffgrep", () => ""),
306
+ ).toThrow("ffgrep");
307
+ });
308
+ });
@@ -56,6 +56,7 @@ describe("session_start handler consolidation", () => {
56
56
  },
57
57
  registerCommand: (): void => {},
58
58
  getAllTools: (): Array<{ name: string }> => [],
59
+ getActiveTools: (): string[] => [],
59
60
  setActiveTools: (): void => {},
60
61
  registerProvider: (): void => {},
61
62
  events: {
@@ -79,6 +80,7 @@ describe("session_start handler consolidation", () => {
79
80
  },
80
81
  registerCommand: (): void => {},
81
82
  getAllTools: (): Array<{ name: string }> => [],
83
+ getActiveTools: (): string[] => [],
82
84
  setActiveTools: (): void => {},
83
85
  registerProvider: (): void => {},
84
86
  events: {
@@ -0,0 +1,77 @@
1
+ import { describe, expect, test } from "vitest";
2
+
3
+ import {
4
+ type ToolAccessExtractor,
5
+ ToolAccessExtractorRegistry,
6
+ } from "#src/tool-access-extractor-registry";
7
+
8
+ const noopExtractor: ToolAccessExtractor = () => "/tmp/x";
9
+
10
+ describe("ToolAccessExtractorRegistry", () => {
11
+ describe("register", () => {
12
+ test("stores an extractor so get() returns it", () => {
13
+ const registry = new ToolAccessExtractorRegistry();
14
+ registry.register("my-tool", noopExtractor);
15
+ expect(registry.get("my-tool")).toBe(noopExtractor);
16
+ });
17
+
18
+ test("returns a disposer that removes the extractor", () => {
19
+ const registry = new ToolAccessExtractorRegistry();
20
+ const dispose = registry.register("my-tool", noopExtractor);
21
+ dispose();
22
+ expect(registry.get("my-tool")).toBeUndefined();
23
+ });
24
+
25
+ test("throws when an extractor is already registered for the same tool name", () => {
26
+ const registry = new ToolAccessExtractorRegistry();
27
+ registry.register("my-tool", noopExtractor);
28
+ expect(() => registry.register("my-tool", () => undefined)).toThrow(
29
+ "my-tool",
30
+ );
31
+ });
32
+
33
+ test("allows registering different tool names independently", () => {
34
+ const registry = new ToolAccessExtractorRegistry();
35
+ const extractorA: ToolAccessExtractor = () => "/a";
36
+ const extractorB: ToolAccessExtractor = () => "/b";
37
+ registry.register("tool-a", extractorA);
38
+ registry.register("tool-b", extractorB);
39
+ expect(registry.get("tool-a")).toBe(extractorA);
40
+ expect(registry.get("tool-b")).toBe(extractorB);
41
+ });
42
+ });
43
+
44
+ describe("disposer identity guard", () => {
45
+ test("stale disposer does not evict a later registration", () => {
46
+ const registry = new ToolAccessExtractorRegistry();
47
+ const first: ToolAccessExtractor = () => "/first";
48
+ const second: ToolAccessExtractor = () => "/second";
49
+
50
+ const disposeFirst = registry.register("my-tool", first);
51
+ disposeFirst(); // removes first
52
+
53
+ registry.register("my-tool", second); // second registration is now valid
54
+ disposeFirst(); // calling stale disposer again — must not remove second
55
+
56
+ expect(registry.get("my-tool")).toBe(second);
57
+ });
58
+ });
59
+
60
+ describe("get", () => {
61
+ test("returns undefined for an unregistered tool name", () => {
62
+ const registry = new ToolAccessExtractorRegistry();
63
+ expect(registry.get("unknown")).toBeUndefined();
64
+ });
65
+
66
+ test("the registered extractor is callable and returns its path", () => {
67
+ const registry = new ToolAccessExtractorRegistry();
68
+ const extractor: ToolAccessExtractor = (input) =>
69
+ typeof input.target === "string" ? input.target : undefined;
70
+ registry.register("ffgrep", extractor);
71
+ expect(registry.get("ffgrep")?.({ target: "/etc/hosts" })).toBe(
72
+ "/etc/hosts",
73
+ );
74
+ expect(registry.get("ffgrep")?.({ other: true })).toBeUndefined();
75
+ });
76
+ });
77
+ });