@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
@@ -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
@@ -2,6 +2,7 @@ import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
2
2
  import { tmpdir } from "node:os";
3
3
  import { join } from "node:path";
4
4
  import { expect, test, vi } from "vitest";
5
+ import { loadUnifiedConfig } from "#src/config-loader";
5
6
  import { registerPermissionSystemCommand } from "#src/config-modal";
6
7
  import type { CommandConfigStore } from "#src/config-store";
7
8
  import {
@@ -89,8 +90,7 @@ test("permission-system command completions expose top-level config actions", ()
89
90
  const controller = {
90
91
  config: configStore,
91
92
  configPath,
92
- permissionManager: { getComposedConfigRules: () => [] as Ruleset },
93
- session: { lastKnownActiveAgentName: null },
93
+ getActiveAgentConfigRules: () => [] as Ruleset,
94
94
  };
95
95
 
96
96
  let definition: {
@@ -146,7 +146,7 @@ test("permission-system command handlers manage config summary, persistence, and
146
146
  current: () => config,
147
147
  save: (next) => {
148
148
  const currentConfig = normalizePermissionSystemConfig(
149
- JSON.parse(readFileSync(configPath, "utf-8")) as unknown,
149
+ loadUnifiedConfig(configPath).config,
150
150
  );
151
151
  const normalized = normalizePermissionSystemConfig(next);
152
152
  writeFileSync(
@@ -155,7 +155,7 @@ test("permission-system command handlers manage config summary, persistence, and
155
155
  "utf-8",
156
156
  );
157
157
  config = normalizePermissionSystemConfig(
158
- JSON.parse(readFileSync(configPath, "utf-8")) as unknown,
158
+ loadUnifiedConfig(configPath).config,
159
159
  );
160
160
  expect(config).not.toEqual(currentConfig);
161
161
  },
@@ -163,8 +163,7 @@ test("permission-system command handlers manage config summary, persistence, and
163
163
  const controller = {
164
164
  config: configStore,
165
165
  configPath,
166
- permissionManager: { getComposedConfigRules: () => [] as Ruleset },
167
- session: { lastKnownActiveAgentName: null },
166
+ getActiveAgentConfigRules: () => [] as Ruleset,
168
167
  };
169
168
 
170
169
  let registeredName = "";
@@ -262,8 +261,7 @@ test("show output includes rule origins when getComposedRules is provided", asyn
262
261
  const controller = {
263
262
  config: { current: () => config, save: () => {} } as CommandConfigStore,
264
263
  configPath: "/fake/config.json",
265
- permissionManager: { getComposedConfigRules: () => composedRules },
266
- session: { lastKnownActiveAgentName: null },
264
+ getActiveAgentConfigRules: () => composedRules,
267
265
  };
268
266
 
269
267
  let definition: {
@@ -295,8 +293,7 @@ test("show output omits rule summary when getComposedRules is not provided", asy
295
293
  const controller = {
296
294
  config: { current: () => config, save: () => {} } as CommandConfigStore,
297
295
  configPath: "/fake/config.json",
298
- permissionManager: { getComposedConfigRules: () => [] as Ruleset },
299
- session: { lastKnownActiveAgentName: null },
296
+ getActiveAgentConfigRules: () => [] as Ruleset,
300
297
  };
301
298
 
302
299
  let definition: {
@@ -0,0 +1,90 @@
1
+ import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
2
+ import { tmpdir } from "node:os";
3
+ import { join } from "node:path";
4
+ import { afterEach, beforeEach, describe, expect, it } from "vitest";
5
+
6
+ import { loadAndMergeConfigs } from "#src/config-loader";
7
+ import { normalizePermissionSystemConfig } from "#src/extension-config";
8
+
9
+ /**
10
+ * Full-pipeline seam tests: write a temp config.json → loadAndMergeConfigs →
11
+ * normalizePermissionSystemConfig → assert values survive end to end.
12
+ *
13
+ * These tests guard the seam between the two normalizers — the class of bug
14
+ * fixed in #332, where a field declared on PermissionSystemExtensionConfig was
15
+ * silently dropped by the UnifiedPermissionConfig intermediate.
16
+ */
17
+ describe("config pipeline seam", () => {
18
+ let tempDir: string;
19
+ let agentDir: string;
20
+ let cwd: string;
21
+ let extensionRoot: string;
22
+
23
+ beforeEach(() => {
24
+ tempDir = mkdtempSync(join(tmpdir(), "config-pipeline-test-"));
25
+ agentDir = join(tempDir, "agent");
26
+ cwd = join(tempDir, "project");
27
+ extensionRoot = join(tempDir, "ext");
28
+ });
29
+
30
+ afterEach(() => {
31
+ rmSync(tempDir, { recursive: true, force: true });
32
+ });
33
+
34
+ function writeGlobal(content: Record<string, unknown>): void {
35
+ const dir = join(agentDir, "extensions", "pi-permission-system");
36
+ mkdirSync(dir, { recursive: true });
37
+ writeFileSync(join(dir, "config.json"), JSON.stringify(content));
38
+ }
39
+
40
+ it("runtime knob and preview-length field both survive the full pipeline", () => {
41
+ writeGlobal({
42
+ debugLog: true,
43
+ toolInputPreviewMaxLength: 1000,
44
+ });
45
+
46
+ const mergeResult = loadAndMergeConfigs(agentDir, cwd, extensionRoot);
47
+ const config = normalizePermissionSystemConfig(mergeResult.merged);
48
+
49
+ expect(config.debugLog).toBe(true);
50
+ expect(config.toolInputPreviewMaxLength).toBe(1000);
51
+ });
52
+
53
+ it("text summary length field survives the full pipeline", () => {
54
+ writeGlobal({
55
+ toolTextSummaryMaxLength: 250,
56
+ });
57
+
58
+ const mergeResult = loadAndMergeConfigs(agentDir, cwd, extensionRoot);
59
+ const config = normalizePermissionSystemConfig(mergeResult.merged);
60
+
61
+ expect(config.toolTextSummaryMaxLength).toBe(250);
62
+ });
63
+
64
+ it("project config overrides global preview-length field end to end", () => {
65
+ writeGlobal({ toolInputPreviewMaxLength: 200 });
66
+ const projectDir = join(cwd, ".pi", "extensions", "pi-permission-system");
67
+ mkdirSync(projectDir, { recursive: true });
68
+ writeFileSync(
69
+ join(projectDir, "config.json"),
70
+ JSON.stringify({ toolInputPreviewMaxLength: 500 }),
71
+ );
72
+
73
+ const mergeResult = loadAndMergeConfigs(agentDir, cwd, extensionRoot);
74
+ const config = normalizePermissionSystemConfig(mergeResult.merged);
75
+
76
+ expect(config.toolInputPreviewMaxLength).toBe(500);
77
+ });
78
+
79
+ it("defaults apply when config file is absent", () => {
80
+ // No config files written — agentDir and cwd directories don't exist.
81
+ const mergeResult = loadAndMergeConfigs(agentDir, cwd, extensionRoot);
82
+ const config = normalizePermissionSystemConfig(mergeResult.merged);
83
+
84
+ expect(config.debugLog).toBe(false);
85
+ expect(config.permissionReviewLog).toBe(true);
86
+ expect(config.yoloMode).toBe(false);
87
+ expect(config.toolInputPreviewMaxLength).toBeUndefined();
88
+ expect(config.toolTextSummaryMaxLength).toBeUndefined();
89
+ });
90
+ });
@@ -103,26 +103,6 @@ describe("normalizePermissionSystemConfig", () => {
103
103
  expect(result.yoloMode).toBe(false);
104
104
  });
105
105
 
106
- it("coerces non-boolean values to their defaults", () => {
107
- const result = normalizePermissionSystemConfig({
108
- debugLog: "yes",
109
- permissionReviewLog: 1,
110
- yoloMode: null,
111
- });
112
- expect(result.debugLog).toBe(false);
113
- expect(result.permissionReviewLog).toBe(true);
114
- expect(result.yoloMode).toBe(false);
115
- });
116
-
117
- it("handles null/undefined input gracefully", () => {
118
- const result = normalizePermissionSystemConfig(null);
119
- expect(result).toEqual({
120
- debugLog: false,
121
- permissionReviewLog: true,
122
- yoloMode: false,
123
- });
124
- });
125
-
126
106
  it("includes toolInputPreviewMaxLength when a valid positive integer is provided", () => {
127
107
  const result = normalizePermissionSystemConfig({
128
108
  toolInputPreviewMaxLength: 400,
@@ -135,25 +115,6 @@ describe("normalizePermissionSystemConfig", () => {
135
115
  expect("toolInputPreviewMaxLength" in result).toBe(false);
136
116
  });
137
117
 
138
- it("omits toolInputPreviewMaxLength for invalid values", () => {
139
- expect(
140
- normalizePermissionSystemConfig({ toolInputPreviewMaxLength: 0 })
141
- .toolInputPreviewMaxLength,
142
- ).toBeUndefined();
143
- expect(
144
- normalizePermissionSystemConfig({ toolInputPreviewMaxLength: -1 })
145
- .toolInputPreviewMaxLength,
146
- ).toBeUndefined();
147
- expect(
148
- normalizePermissionSystemConfig({ toolInputPreviewMaxLength: 200.5 })
149
- .toolInputPreviewMaxLength,
150
- ).toBeUndefined();
151
- expect(
152
- normalizePermissionSystemConfig({ toolInputPreviewMaxLength: "200" })
153
- .toolInputPreviewMaxLength,
154
- ).toBeUndefined();
155
- });
156
-
157
118
  it("includes toolTextSummaryMaxLength when a valid positive integer is provided", () => {
158
119
  const result = normalizePermissionSystemConfig({
159
120
  toolTextSummaryMaxLength: 120,
@@ -165,23 +126,4 @@ describe("normalizePermissionSystemConfig", () => {
165
126
  const result = normalizePermissionSystemConfig({});
166
127
  expect("toolTextSummaryMaxLength" in result).toBe(false);
167
128
  });
168
-
169
- it("omits toolTextSummaryMaxLength for invalid values", () => {
170
- expect(
171
- normalizePermissionSystemConfig({ toolTextSummaryMaxLength: 0 })
172
- .toolTextSummaryMaxLength,
173
- ).toBeUndefined();
174
- expect(
175
- normalizePermissionSystemConfig({ toolTextSummaryMaxLength: -1 })
176
- .toolTextSummaryMaxLength,
177
- ).toBeUndefined();
178
- expect(
179
- normalizePermissionSystemConfig({ toolTextSummaryMaxLength: 80.1 })
180
- .toolTextSummaryMaxLength,
181
- ).toBeUndefined();
182
- expect(
183
- normalizePermissionSystemConfig({ toolTextSummaryMaxLength: true })
184
- .toolTextSummaryMaxLength,
185
- ).toBeUndefined();
186
- });
187
129
  });
@@ -215,6 +215,49 @@ describe("describeBashPathGate", () => {
215
215
  expect(desc.preCheck?.state).toBe("deny");
216
216
  expect(desc.decision.value).toBe(".env");
217
217
  });
218
+
219
+ it("resolves cd-aware policy values while keeping the raw prompt token", async () => {
220
+ const resolver = makeResolver(
221
+ makeCheckResult({ state: "deny", matchedPattern: "*" }),
222
+ );
223
+ const result = (await describeGate(
224
+ makeTcc({
225
+ input: { command: "cd nested && cat src/file.txt" },
226
+ cwd: "/test/project",
227
+ }),
228
+ resolver,
229
+ )) as GateDescriptor;
230
+
231
+ expect(resolver.resolvePathPolicy).toHaveBeenCalledWith(
232
+ [
233
+ "/test/project/nested/src/file.txt",
234
+ "nested/src/file.txt",
235
+ "src/file.txt",
236
+ ],
237
+ undefined,
238
+ );
239
+ // The raw token drives the prompt, denial context, and session approval.
240
+ expect(result.denialContext).toMatchObject({ pathValue: "src/file.txt" });
241
+ expect(result.decision.value).toBe("src/file.txt");
242
+ });
243
+
244
+ it("does not resolve relative policy values through an unknown cd", async () => {
245
+ const resolver = makeResolver(
246
+ makeCheckResult({ state: "deny", matchedPattern: "*" }),
247
+ );
248
+ await describeGate(
249
+ makeTcc({
250
+ input: { command: 'cd "$DIR" && cat src/foo.ts' },
251
+ cwd: "/test/project",
252
+ }),
253
+ resolver,
254
+ );
255
+
256
+ expect(resolver.resolvePathPolicy).toHaveBeenCalledWith(
257
+ ["src/foo.ts"],
258
+ undefined,
259
+ );
260
+ });
218
261
  });
219
262
 
220
263
  // Home-relative path characterization (#350) ──────────────────────────────
@@ -229,7 +272,7 @@ describe("describeBashPathGate — home-relative paths", () => {
229
272
  // cat ~/.ssh/config → token "~/.ssh/config" extracted.
230
273
  const resolver = makePathDispatchResolver(
231
274
  {
232
- "~/.ssh/config": makeCheckResult({
275
+ "/mock/home/.ssh/config": makeCheckResult({
233
276
  state: "deny",
234
277
  matchedPattern: "~/.ssh/*",
235
278
  }),
@@ -253,7 +296,7 @@ describe("describeBashPathGate — home-relative paths", () => {
253
296
  it("extracts $HOME/... token and builds descriptor on deny", async () => {
254
297
  const resolver = makePathDispatchResolver(
255
298
  {
256
- "$HOME/.ssh/config": makeCheckResult({
299
+ "/mock/home/.ssh/config": makeCheckResult({
257
300
  state: "deny",
258
301
  matchedPattern: "$HOME/.ssh/*",
259
302
  }),
@@ -13,20 +13,50 @@ vi.mock("node:fs", () => ({
13
13
  import { BashProgram } from "#src/handlers/gates/bash-program";
14
14
 
15
15
  describe("BashProgram", () => {
16
- describe("pathTokens", () => {
17
- it("returns dot-files and relative path tokens", async () => {
18
- const program = await BashProgram.parse("cat .env src/foo.ts");
19
- expect(program.pathTokens()).toEqual([".env", "src/foo.ts"]);
16
+ describe("pathRuleCandidates", () => {
17
+ const cwd = "/projects/my-app";
18
+
19
+ it("adds absolute and relative policy values for relative tokens", async () => {
20
+ const program = await BashProgram.parse("cat src/foo.ts");
21
+ expect(program.pathRuleCandidates(cwd)).toEqual([
22
+ {
23
+ token: "src/foo.ts",
24
+ policyValues: ["/projects/my-app/src/foo.ts", "src/foo.ts"],
25
+ },
26
+ ]);
27
+ });
28
+
29
+ it("returns the literal token only when no cwd is provided", async () => {
30
+ const program = await BashProgram.parse("cat src/foo.ts");
31
+ expect(program.pathRuleCandidates()).toEqual([
32
+ { token: "src/foo.ts", policyValues: ["src/foo.ts"] },
33
+ ]);
20
34
  });
21
35
 
22
- it("returns an empty array when there are no path tokens", async () => {
23
- const program = await BashProgram.parse("echo hello");
24
- expect(program.pathTokens()).toEqual([]);
36
+ it("resolves tokens after literal cd against the effective directory", async () => {
37
+ const program = await BashProgram.parse("cd nested && cat src/file.txt");
38
+ const fileCandidate = program
39
+ .pathRuleCandidates(cwd)
40
+ .find((candidate) => candidate.token === "src/file.txt");
41
+ expect(fileCandidate).toEqual({
42
+ token: "src/file.txt",
43
+ policyValues: [
44
+ "/projects/my-app/nested/src/file.txt",
45
+ "nested/src/file.txt",
46
+ "src/file.txt",
47
+ ],
48
+ });
25
49
  });
26
50
 
27
- it("deduplicates repeated tokens across a command chain", async () => {
28
- const program = await BashProgram.parse("cat .env && rm .env");
29
- expect(program.pathTokens()).toEqual([".env"]);
51
+ it("does not absolute-allow relative tokens after unknown cd", async () => {
52
+ const program = await BashProgram.parse('cd "$DIR" && cat src/foo.ts');
53
+ const fileCandidate = program
54
+ .pathRuleCandidates(cwd)
55
+ .find((candidate) => candidate.token === "src/foo.ts");
56
+ expect(fileCandidate).toEqual({
57
+ token: "src/foo.ts",
58
+ policyValues: ["src/foo.ts"],
59
+ });
30
60
  });
31
61
  });
32
62
 
@@ -322,7 +352,10 @@ describe("BashProgram", () => {
322
352
 
323
353
  it("derives both slices from a single parse", async () => {
324
354
  const program = await BashProgram.parse("cat .env /etc/hosts");
325
- expect(program.pathTokens()).toEqual([".env", "/etc/hosts"]);
355
+ expect(program.pathRuleCandidates().map(({ token }) => token)).toEqual([
356
+ ".env",
357
+ "/etc/hosts",
358
+ ]);
326
359
  const external = program.externalPaths("/projects/my-app");
327
360
  expect(external).toContain("/etc/hosts");
328
361
  expect(external).not.toContain(".env");
@@ -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
+ });
@@ -23,7 +23,7 @@ vi.mock("#src/handlers/gates/bash-program", () => ({
23
23
  function makeMockBashProgram() {
24
24
  return {
25
25
  commands: vi.fn<() => []>(() => []),
26
- pathTokens: vi.fn<() => []>(() => []),
26
+ pathRuleCandidates: vi.fn<() => []>(() => []),
27
27
  externalPaths: vi.fn<() => []>(() => []),
28
28
  };
29
29
  }
@@ -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
  });