@gotgenes/pi-permission-system 16.0.2 → 16.2.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 (44) hide show
  1. package/CHANGELOG.md +20 -0
  2. package/package.json +1 -1
  3. package/src/access-intent/access-intent.ts +57 -0
  4. package/src/access-intent/access-path.ts +88 -0
  5. package/src/access-intent/bash/cwd-projection.ts +9 -4
  6. package/src/access-intent/bash/program.ts +8 -8
  7. package/src/handlers/gates/bash-command.ts +18 -3
  8. package/src/handlers/gates/bash-external-directory.ts +16 -30
  9. package/src/handlers/gates/bash-path-extractor.ts +9 -3
  10. package/src/handlers/gates/bash-path.ts +6 -4
  11. package/src/handlers/gates/external-directory-policy.ts +66 -0
  12. package/src/handlers/gates/external-directory.ts +8 -9
  13. package/src/handlers/gates/path.ts +6 -5
  14. package/src/handlers/gates/runner.ts +6 -5
  15. package/src/handlers/gates/tool-call-gate-pipeline.ts +6 -5
  16. package/src/path-utils.ts +0 -20
  17. package/src/permission-event-rpc.ts +4 -6
  18. package/src/permission-manager.ts +39 -55
  19. package/src/permission-resolver.ts +45 -57
  20. package/src/permissions-service.ts +2 -4
  21. package/src/skill-prompt-sanitizer.ts +2 -2
  22. package/test/access-intent/access-path.test.ts +107 -0
  23. package/test/access-intent/bash/program.test.ts +30 -12
  24. package/test/handlers/before-agent-start.test.ts +6 -9
  25. package/test/handlers/external-directory-session-dedup.test.ts +16 -40
  26. package/test/handlers/gates/bash-command-metamorphic.test.ts +5 -3
  27. package/test/handlers/gates/bash-command.test.ts +26 -23
  28. package/test/handlers/gates/bash-external-directory.test.ts +36 -32
  29. package/test/handlers/gates/bash-path.test.ts +12 -8
  30. package/test/handlers/gates/external-directory-policy.test.ts +124 -0
  31. package/test/handlers/gates/external-directory.test.ts +11 -5
  32. package/test/handlers/gates/path.test.ts +30 -25
  33. package/test/handlers/gates/runner.test.ts +6 -1
  34. package/test/handlers/gates/tool-call-gate-pipeline.test.ts +4 -3
  35. package/test/handlers/input.test.ts +1 -1
  36. package/test/helpers/gate-fixtures.ts +12 -24
  37. package/test/helpers/handler-fixtures.ts +21 -15
  38. package/test/helpers/session-fixtures.ts +3 -18
  39. package/test/path-utils.test.ts +0 -34
  40. package/test/permission-event-rpc.test.ts +24 -20
  41. package/test/permission-manager-unified.test.ts +445 -209
  42. package/test/permission-resolver.test.ts +112 -89
  43. package/test/permissions-service.test.ts +10 -7
  44. package/test/skill-prompt-sanitizer.test.ts +30 -7
@@ -8,6 +8,7 @@ import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
8
8
  import { homedir, tmpdir } from "node:os";
9
9
  import { dirname, join } from "node:path";
10
10
  import { describe, expect, it, test } from "vitest";
11
+ import type { ResolvedAccessIntent } from "#src/access-intent/access-intent";
11
12
  import {
12
13
  getGlobalConfigPath,
13
14
  getProjectAgentsDir,
@@ -70,6 +71,35 @@ const sessionAllow = (surface: string, pattern: string): Rule => ({
70
71
  origin: "session",
71
72
  });
72
73
 
74
+ // Adapters that build an AccessIntent and call the unified `check` entry point,
75
+ // so these tests exercise the single resolution path (#478) without a
76
+ // production-class wrapper used only by tests.
77
+ function checkTool(
78
+ manager: PermissionManager,
79
+ toolName: string,
80
+ input: unknown,
81
+ agentName?: string,
82
+ sessionRules?: Ruleset,
83
+ ): PermissionCheckResult {
84
+ return manager.check(
85
+ { kind: "tool", surface: toolName, input, agentName },
86
+ sessionRules,
87
+ );
88
+ }
89
+
90
+ function checkPathValues(
91
+ manager: PermissionManager,
92
+ values: readonly string[],
93
+ agentName?: string,
94
+ sessionRules?: Ruleset,
95
+ surface = "path",
96
+ ): PermissionCheckResult {
97
+ return manager.check(
98
+ { kind: "path-values", surface, values, agentName },
99
+ sessionRules,
100
+ );
101
+ }
102
+
73
103
  // ---------------------------------------------------------------------------
74
104
  // Step 5: session rules concatenated — wins over config/default
75
105
  // ---------------------------------------------------------------------------
@@ -80,7 +110,8 @@ describe("checkPermission — session rules", () => {
80
110
  const sessionRules: Ruleset = [
81
111
  sessionAllow("external_directory", "/other/project"),
82
112
  ];
83
- const result = manager.checkPermission(
113
+ const result = checkTool(
114
+ manager,
84
115
  "external_directory",
85
116
  { path: "/other/project" },
86
117
  undefined,
@@ -94,7 +125,8 @@ describe("checkPermission — session rules", () => {
94
125
  it("session rule wins over the universal default (skill)", () => {
95
126
  const manager = makeManager();
96
127
  const sessionRules: Ruleset = [sessionAllow("skill", "librarian")];
97
- const result = manager.checkPermission(
128
+ const result = checkTool(
129
+ manager,
98
130
  "skill",
99
131
  { name: "librarian" },
100
132
  undefined,
@@ -108,7 +140,8 @@ describe("checkPermission — session rules", () => {
108
140
  it("session rule wins over the universal default (bash)", () => {
109
141
  const manager = makeManager();
110
142
  const sessionRules: Ruleset = [sessionAllow("bash", "git status")];
111
- const result = manager.checkPermission(
143
+ const result = checkTool(
144
+ manager,
112
145
  "bash",
113
146
  { command: "git status" },
114
147
  undefined,
@@ -122,7 +155,7 @@ describe("checkPermission — session rules", () => {
122
155
  it("session rule wins over the universal default (tool — read)", () => {
123
156
  const manager = makeManager();
124
157
  const sessionRules: Ruleset = [sessionAllow("read", "*")];
125
- const result = manager.checkPermission("read", {}, undefined, sessionRules);
158
+ const result = checkTool(manager, "read", {}, undefined, sessionRules);
126
159
  expect(result.state).toBe("allow");
127
160
  expect(result.source).toBe("session");
128
161
  });
@@ -130,14 +163,14 @@ describe("checkPermission — session rules", () => {
130
163
  it("session rule wins over the universal default (mcp)", () => {
131
164
  const manager = makeManager();
132
165
  const sessionRules: Ruleset = [sessionAllow("mcp", "mcp_status")];
133
- const result = manager.checkPermission("mcp", {}, undefined, sessionRules);
166
+ const result = checkTool(manager, "mcp", {}, undefined, sessionRules);
134
167
  expect(result.state).toBe("allow");
135
168
  expect(result.source).toBe("session");
136
169
  });
137
170
 
138
171
  it("no session rules — falls through to default (ask)", () => {
139
172
  const manager = makeManager();
140
- const result = manager.checkPermission("read", {}, undefined, []);
173
+ const result = checkTool(manager, "read", {}, undefined, []);
141
174
  expect(result.state).toBe("ask");
142
175
  expect(result.source).not.toBe("session");
143
176
  });
@@ -146,7 +179,8 @@ describe("checkPermission — session rules", () => {
146
179
  const manager = makeManager();
147
180
  // Only "git status" is session-approved; "git push" should fall through to default.
148
181
  const sessionRules: Ruleset = [sessionAllow("bash", "git status")];
149
- const result = manager.checkPermission(
182
+ const result = checkTool(
183
+ manager,
150
184
  "bash",
151
185
  { command: "git push origin main" },
152
186
  undefined,
@@ -159,13 +193,15 @@ describe("checkPermission — session rules", () => {
159
193
  it("session wildcard pattern matches multiple commands", () => {
160
194
  const manager = makeManager();
161
195
  const sessionRules: Ruleset = [sessionAllow("bash", "git *")];
162
- const push = manager.checkPermission(
196
+ const push = checkTool(
197
+ manager,
163
198
  "bash",
164
199
  { command: "git push origin main" },
165
200
  undefined,
166
201
  sessionRules,
167
202
  );
168
- const status = manager.checkPermission(
203
+ const status = checkTool(
204
+ manager,
169
205
  "bash",
170
206
  { command: "git status" },
171
207
  undefined,
@@ -190,7 +226,7 @@ describe("checkPermission — source derivation and matchedPattern", () => {
190
226
  external_directory: { "/trusted/*": "allow" },
191
227
  });
192
228
  try {
193
- const result = manager.checkPermission("external_directory", {
229
+ const result = checkTool(manager, "external_directory", {
194
230
  path: "/trusted/repo",
195
231
  });
196
232
  expect(result.state).toBe("allow");
@@ -203,7 +239,7 @@ describe("checkPermission — source derivation and matchedPattern", () => {
203
239
 
204
240
  it("source is 'special' even for a default match (no config rule)", () => {
205
241
  const manager = makeManager();
206
- const result = manager.checkPermission("external_directory", {
242
+ const result = checkTool(manager, "external_directory", {
207
243
  path: "/some/path",
208
244
  });
209
245
  expect(result.state).toBe("ask");
@@ -213,7 +249,7 @@ describe("checkPermission — source derivation and matchedPattern", () => {
213
249
 
214
250
  it("matchedPattern is undefined for a default match", () => {
215
251
  const manager = makeManager();
216
- const result = manager.checkPermission("external_directory", {
252
+ const result = checkTool(manager, "external_directory", {
217
253
  path: "/unknown",
218
254
  });
219
255
  expect(result.matchedPattern).toBeUndefined();
@@ -227,7 +263,7 @@ describe("checkPermission — source derivation and matchedPattern", () => {
227
263
  skill: { librarian: "allow" },
228
264
  });
229
265
  try {
230
- const result = manager.checkPermission("skill", { name: "librarian" });
266
+ const result = checkTool(manager, "skill", { name: "librarian" });
231
267
  expect(result.state).toBe("allow");
232
268
  expect(result.source).toBe("skill");
233
269
  expect(result.matchedPattern).toBe("librarian");
@@ -238,7 +274,7 @@ describe("checkPermission — source derivation and matchedPattern", () => {
238
274
 
239
275
  it("source is 'skill' even for a default match", () => {
240
276
  const manager = makeManager();
241
- const result = manager.checkPermission("skill", { name: "unknown" });
277
+ const result = checkTool(manager, "skill", { name: "unknown" });
242
278
  expect(result.state).toBe("ask");
243
279
  expect(result.source).toBe("skill");
244
280
  });
@@ -251,7 +287,7 @@ describe("checkPermission — source derivation and matchedPattern", () => {
251
287
  bash: { "git *": "allow" },
252
288
  });
253
289
  try {
254
- const result = manager.checkPermission("bash", {
290
+ const result = checkTool(manager, "bash", {
255
291
  command: "git status",
256
292
  });
257
293
  expect(result.state).toBe("allow");
@@ -265,7 +301,7 @@ describe("checkPermission — source derivation and matchedPattern", () => {
265
301
 
266
302
  it("source is 'bash' even for a default match, command is empty string", () => {
267
303
  const manager = makeManager();
268
- const result = manager.checkPermission("bash", {});
304
+ const result = checkTool(manager, "bash", {});
269
305
  expect(result.source).toBe("bash");
270
306
  expect(result.command).toBe("");
271
307
  expect(result.matchedPattern).toBeUndefined();
@@ -279,7 +315,7 @@ describe("checkPermission — source derivation and matchedPattern", () => {
279
315
  ["exa"],
280
316
  );
281
317
  try {
282
- const result = manager.checkPermission("mcp", {
318
+ const result = checkTool(manager, "mcp", {
283
319
  tool: "exa:search",
284
320
  server: "exa",
285
321
  });
@@ -294,7 +330,7 @@ describe("checkPermission — source derivation and matchedPattern", () => {
294
330
 
295
331
  it("source is 'default' when all targets match only the synthesized default", () => {
296
332
  const manager = makeManager();
297
- const result = manager.checkPermission("mcp", { tool: "exa:search" });
333
+ const result = checkTool(manager, "mcp", { tool: "exa:search" });
298
334
  expect(result.state).toBe("ask");
299
335
  expect(result.source).toBe("default");
300
336
  expect(result.matchedPattern).toBeUndefined();
@@ -306,7 +342,7 @@ describe("checkPermission — source derivation and matchedPattern", () => {
306
342
  [],
307
343
  );
308
344
  try {
309
- const result = manager.checkPermission("mcp", {});
345
+ const result = checkTool(manager, "mcp", {});
310
346
  expect(result.target).toBeDefined();
311
347
  expect(result.source).toBe("mcp");
312
348
  } finally {
@@ -322,7 +358,7 @@ describe("checkPermission — source derivation and matchedPattern", () => {
322
358
  read: "allow",
323
359
  });
324
360
  try {
325
- const result = manager.checkPermission("read", {});
361
+ const result = checkTool(manager, "read", {});
326
362
  expect(result.state).toBe("allow");
327
363
  expect(result.source).toBe("tool");
328
364
  } finally {
@@ -332,14 +368,14 @@ describe("checkPermission — source derivation and matchedPattern", () => {
332
368
 
333
369
  it("built-in tool: source is 'tool' even for a default match", () => {
334
370
  const manager = makeManager();
335
- const result = manager.checkPermission("read", {});
371
+ const result = checkTool(manager, "read", {});
336
372
  expect(result.state).toBe("ask");
337
373
  expect(result.source).toBe("tool");
338
374
  });
339
375
 
340
376
  it("extension tool: source is 'default' when no config rule matches", () => {
341
377
  const manager = makeManager();
342
- const result = manager.checkPermission("my_custom_tool", {});
378
+ const result = checkTool(manager, "my_custom_tool", {});
343
379
  expect(result.state).toBe("ask");
344
380
  expect(result.source).toBe("default");
345
381
  });
@@ -350,7 +386,7 @@ describe("checkPermission — source derivation and matchedPattern", () => {
350
386
  my_custom_tool: "allow",
351
387
  });
352
388
  try {
353
- const result = manager.checkPermission("my_custom_tool", {});
389
+ const result = checkTool(manager, "my_custom_tool", {});
354
390
  expect(result.state).toBe("allow");
355
391
  expect(result.source).toBe("tool");
356
392
  } finally {
@@ -363,7 +399,8 @@ describe("checkPermission — source derivation and matchedPattern", () => {
363
399
  it("matchedPattern is the session rule pattern for a session match (bash)", () => {
364
400
  const manager = makeManager();
365
401
  const sessionRules: Ruleset = [sessionAllow("bash", "git *")];
366
- const result = manager.checkPermission(
402
+ const result = checkTool(
403
+ manager,
367
404
  "bash",
368
405
  { command: "git status" },
369
406
  undefined,
@@ -376,7 +413,8 @@ describe("checkPermission — source derivation and matchedPattern", () => {
376
413
  it("matchedPattern is the session rule pattern for a session match (skill)", () => {
377
414
  const manager = makeManager();
378
415
  const sessionRules: Ruleset = [sessionAllow("skill", "librarian")];
379
- const result = manager.checkPermission(
416
+ const result = checkTool(
417
+ manager,
380
418
  "skill",
381
419
  { name: "librarian" },
382
420
  undefined,
@@ -398,7 +436,7 @@ describe("checkPermission — home path expansion in external_directory rules",
398
436
  external_directory: { "~/trusted/*": "allow" },
399
437
  });
400
438
  try {
401
- const result = manager.checkPermission("external_directory", {
439
+ const result = checkTool(manager, "external_directory", {
402
440
  path: join(homedir(), "trusted/repo"),
403
441
  });
404
442
  expect(result.state).toBe("allow");
@@ -415,7 +453,7 @@ describe("checkPermission — home path expansion in external_directory rules",
415
453
  external_directory: { "$HOME/trusted/*": "allow" },
416
454
  });
417
455
  try {
418
- const result = manager.checkPermission("external_directory", {
456
+ const result = checkTool(manager, "external_directory", {
419
457
  path: join(homedir(), "trusted/repo"),
420
458
  });
421
459
  expect(result.state).toBe("allow");
@@ -432,7 +470,7 @@ describe("checkPermission — home path expansion in external_directory rules",
432
470
  external_directory: { "~/private/*": "deny" },
433
471
  });
434
472
  try {
435
- const result = manager.checkPermission("external_directory", {
473
+ const result = checkTool(manager, "external_directory", {
436
474
  path: join(homedir(), "private/secrets.txt"),
437
475
  });
438
476
  expect(result.state).toBe("deny");
@@ -448,7 +486,7 @@ describe("checkPermission — home path expansion in external_directory rules",
448
486
  external_directory: { "~/trusted/*": "allow" },
449
487
  });
450
488
  try {
451
- const result = manager.checkPermission("external_directory", {
489
+ const result = checkTool(manager, "external_directory", {
452
490
  path: "/tmp/not-home/file",
453
491
  });
454
492
  // Falls back to the "*": "ask" default — no allow from the ~/trusted/* rule.
@@ -505,7 +543,7 @@ describe("checkPermission — rule origin provenance", () => {
505
543
  it("single-scope global: config rule has origin 'global'", () => {
506
544
  const { manager, cleanup } = makeManagerWithScopes({ read: "allow" });
507
545
  try {
508
- const result = manager.checkPermission("read", {});
546
+ const result = checkTool(manager, "read", {});
509
547
  expect(result.state).toBe("allow");
510
548
  expect(result.origin).toBe("global");
511
549
  } finally {
@@ -518,7 +556,7 @@ describe("checkPermission — rule origin provenance", () => {
518
556
  bash: { "git *": "allow" },
519
557
  });
520
558
  try {
521
- const result = manager.checkPermission("bash", { command: "git status" });
559
+ const result = checkTool(manager, "bash", { command: "git status" });
522
560
  expect(result.state).toBe("allow");
523
561
  expect(result.origin).toBe("global");
524
562
  } finally {
@@ -532,7 +570,7 @@ describe("checkPermission — rule origin provenance", () => {
532
570
  { read: "allow" },
533
571
  );
534
572
  try {
535
- const result = manager.checkPermission("read", {});
573
+ const result = checkTool(manager, "read", {});
536
574
  expect(result.state).toBe("allow");
537
575
  expect(result.origin).toBe("project");
538
576
  } finally {
@@ -548,13 +586,13 @@ describe("checkPermission — rule origin provenance", () => {
548
586
  { bash: { "rm *": "deny" } },
549
587
  );
550
588
  try {
551
- const gitResult = manager.checkPermission("bash", {
589
+ const gitResult = checkTool(manager, "bash", {
552
590
  command: "git status",
553
591
  });
554
592
  expect(gitResult.state).toBe("allow");
555
593
  expect(gitResult.origin).toBe("global");
556
594
 
557
- const rmResult = manager.checkPermission("bash", {
595
+ const rmResult = checkTool(manager, "bash", {
558
596
  command: "rm -rf /",
559
597
  });
560
598
  expect(rmResult.state).toBe("deny");
@@ -571,7 +609,7 @@ describe("checkPermission — rule origin provenance", () => {
571
609
  { bash: { "git *": "allow" } },
572
610
  );
573
611
  try {
574
- const result = manager.checkPermission("bash", {
612
+ const result = checkTool(manager, "bash", {
575
613
  command: "git status",
576
614
  });
577
615
  expect(result.state).toBe("allow");
@@ -589,7 +627,7 @@ describe("checkPermission — rule origin provenance", () => {
589
627
  );
590
628
  try {
591
629
  // The catch-all "*" now comes from the project scope.
592
- const result = manager.checkPermission("bash", {
630
+ const result = checkTool(manager, "bash", {
593
631
  command: "anything",
594
632
  });
595
633
  expect(result.state).toBe("allow");
@@ -606,7 +644,7 @@ describe("checkPermission — rule origin provenance", () => {
606
644
  { read: { "*": "allow" } },
607
645
  );
608
646
  try {
609
- const result = manager.checkPermission("read", {});
647
+ const result = checkTool(manager, "read", {});
610
648
  expect(result.state).toBe("allow");
611
649
  expect(result.origin).toBe("project");
612
650
  } finally {
@@ -617,7 +655,7 @@ describe("checkPermission — rule origin provenance", () => {
617
655
  it("no config match: origin is 'builtin' (default layer)", () => {
618
656
  // No config — falls back to synthesized default.
619
657
  const manager = makeManager();
620
- const result = manager.checkPermission("read", {});
658
+ const result = checkTool(manager, "read", {});
621
659
  expect(result.state).toBe("ask");
622
660
  expect(result.origin).toBe("builtin");
623
661
  });
@@ -633,7 +671,7 @@ describe("checkPermission — rule origin provenance", () => {
633
671
  origin: "session",
634
672
  },
635
673
  ];
636
- const result = manager.checkPermission("read", {}, undefined, sessionRules);
674
+ const result = checkTool(manager, "read", {}, undefined, sessionRules);
637
675
  expect(result.state).toBe("allow");
638
676
  expect(result.source).toBe("session");
639
677
  expect(result.origin).toBe("session");
@@ -643,7 +681,7 @@ describe("checkPermission — rule origin provenance", () => {
643
681
  const { manager, cleanup } = makeManagerWithScopes({ "*": "allow" });
644
682
  try {
645
683
  // No explicit surface rule — hits the synthesized default derived from "*".
646
- const result = manager.checkPermission("read", {});
684
+ const result = checkTool(manager, "read", {});
647
685
  expect(result.state).toBe("allow");
648
686
  expect(result.origin).toBe("global");
649
687
  } finally {
@@ -657,7 +695,7 @@ describe("checkPermission — rule origin provenance", () => {
657
695
  { "*": "allow" },
658
696
  );
659
697
  try {
660
- const result = manager.checkPermission("read", {});
698
+ const result = checkTool(manager, "read", {});
661
699
  expect(result.state).toBe("allow");
662
700
  expect(result.origin).toBe("project");
663
701
  } finally {
@@ -668,7 +706,7 @@ describe("checkPermission — rule origin provenance", () => {
668
706
  it("built-in fallback (no * in any config): origin is 'builtin'", () => {
669
707
  // Manager with no config file — built-in "ask" default.
670
708
  const manager = makeManager();
671
- const result = manager.checkPermission("read", {});
709
+ const result = checkTool(manager, "read", {});
672
710
  expect(result.state).toBe("ask");
673
711
  expect(result.origin).toBe("builtin");
674
712
  });
@@ -734,7 +772,7 @@ describe("PermissionManager with in-memory PolicyLoader", () => {
734
772
  describe("universal fallback", () => {
735
773
  it("defaults to ask when no config is provided", () => {
736
774
  const manager = makeInMemoryManager();
737
- const result = manager.checkPermission("read", {});
775
+ const result = checkTool(manager, "read", {});
738
776
  expect(result.state).toBe("ask");
739
777
  expect(result.origin).toBe("builtin");
740
778
  });
@@ -743,7 +781,7 @@ describe("PermissionManager with in-memory PolicyLoader", () => {
743
781
  const manager = makeInMemoryManager({
744
782
  global: { permission: { "*": "allow" } },
745
783
  });
746
- const result = manager.checkPermission("read", {});
784
+ const result = checkTool(manager, "read", {});
747
785
  expect(result.state).toBe("allow");
748
786
  expect(result.origin).toBe("global");
749
787
  });
@@ -752,7 +790,7 @@ describe("PermissionManager with in-memory PolicyLoader", () => {
752
790
  const manager = makeInMemoryManager({
753
791
  global: { permission: { "*": "deny" } },
754
792
  });
755
- const result = manager.checkPermission("write", {});
793
+ const result = checkTool(manager, "write", {});
756
794
  expect(result.state).toBe("deny");
757
795
  });
758
796
  });
@@ -764,7 +802,7 @@ describe("PermissionManager with in-memory PolicyLoader", () => {
764
802
  permission: { "*": "ask", bash: { "git *": "allow" } },
765
803
  },
766
804
  });
767
- const result = manager.checkPermission("bash", {
805
+ const result = checkTool(manager, "bash", {
768
806
  command: "git status",
769
807
  });
770
808
  expect(result.state).toBe("allow");
@@ -776,7 +814,7 @@ describe("PermissionManager with in-memory PolicyLoader", () => {
776
814
  const manager = makeInMemoryManager({
777
815
  global: { permission: { "*": "deny", read: "allow" } },
778
816
  });
779
- const result = manager.checkPermission("read", {});
817
+ const result = checkTool(manager, "read", {});
780
818
  expect(result.state).toBe("allow");
781
819
  expect(result.source).toBe("tool");
782
820
  });
@@ -787,7 +825,7 @@ describe("PermissionManager with in-memory PolicyLoader", () => {
787
825
  permission: { "*": "ask", skill: { librarian: "allow" } },
788
826
  },
789
827
  });
790
- const result = manager.checkPermission("skill", { name: "librarian" });
828
+ const result = checkTool(manager, "skill", { name: "librarian" });
791
829
  expect(result.state).toBe("allow");
792
830
  expect(result.source).toBe("skill");
793
831
  });
@@ -801,7 +839,7 @@ describe("PermissionManager with in-memory PolicyLoader", () => {
801
839
  },
802
840
  ["exa"],
803
841
  );
804
- const result = manager.checkPermission("mcp", {
842
+ const result = checkTool(manager, "mcp", {
805
843
  tool: "exa:search",
806
844
  server: "exa",
807
845
  });
@@ -818,7 +856,7 @@ describe("PermissionManager with in-memory PolicyLoader", () => {
818
856
  },
819
857
  },
820
858
  });
821
- const result = manager.checkPermission("external_directory", {
859
+ const result = checkTool(manager, "external_directory", {
822
860
  path: "/trusted/repo",
823
861
  });
824
862
  expect(result.state).toBe("allow");
@@ -829,7 +867,7 @@ describe("PermissionManager with in-memory PolicyLoader", () => {
829
867
  const manager = makeInMemoryManager({
830
868
  global: { permission: { "*": "ask" } },
831
869
  });
832
- const result = manager.checkPermission("my_custom_tool", {});
870
+ const result = checkTool(manager, "my_custom_tool", {});
833
871
  expect(result.state).toBe("ask");
834
872
  expect(result.source).toBe("default");
835
873
  });
@@ -841,7 +879,7 @@ describe("PermissionManager with in-memory PolicyLoader", () => {
841
879
  global: { permission: { read: "ask" } },
842
880
  project: { permission: { read: "allow" } },
843
881
  });
844
- const result = manager.checkPermission("read", {});
882
+ const result = checkTool(manager, "read", {});
845
883
  expect(result.state).toBe("allow");
846
884
  expect(result.origin).toBe("project");
847
885
  });
@@ -852,7 +890,7 @@ describe("PermissionManager with in-memory PolicyLoader", () => {
852
890
  project: { permission: { read: "allow" } },
853
891
  agent: { coder: { permission: { read: "deny" } } },
854
892
  });
855
- const result = manager.checkPermission("read", {}, "coder");
893
+ const result = checkTool(manager, "read", {}, "coder");
856
894
  expect(result.state).toBe("deny");
857
895
  expect(result.origin).toBe("agent");
858
896
  });
@@ -863,7 +901,7 @@ describe("PermissionManager with in-memory PolicyLoader", () => {
863
901
  agent: { coder: { permission: { read: "deny" } } },
864
902
  projectAgent: { coder: { permission: { read: "allow" } } },
865
903
  });
866
- const result = manager.checkPermission("read", {}, "coder");
904
+ const result = checkTool(manager, "read", {}, "coder");
867
905
  expect(result.state).toBe("allow");
868
906
  expect(result.origin).toBe("project-agent");
869
907
  });
@@ -873,13 +911,13 @@ describe("PermissionManager with in-memory PolicyLoader", () => {
873
911
  global: { permission: { bash: { "git *": "allow" } } },
874
912
  project: { permission: { bash: { "rm *": "deny" } } },
875
913
  });
876
- const gitResult = manager.checkPermission("bash", {
914
+ const gitResult = checkTool(manager, "bash", {
877
915
  command: "git status",
878
916
  });
879
917
  expect(gitResult.state).toBe("allow");
880
918
  expect(gitResult.origin).toBe("global");
881
919
 
882
- const rmResult = manager.checkPermission("bash", {
920
+ const rmResult = checkTool(manager, "bash", {
883
921
  command: "rm -rf /",
884
922
  });
885
923
  expect(rmResult.state).toBe("deny");
@@ -893,7 +931,7 @@ describe("PermissionManager with in-memory PolicyLoader", () => {
893
931
  },
894
932
  project: { permission: { bash: "allow" } },
895
933
  });
896
- const result = manager.checkPermission("bash", { command: "anything" });
934
+ const result = checkTool(manager, "bash", { command: "anything" });
897
935
  expect(result.state).toBe("allow");
898
936
  expect(result.origin).toBe("project");
899
937
  });
@@ -905,12 +943,7 @@ describe("PermissionManager with in-memory PolicyLoader", () => {
905
943
  global: { permission: { "*": "deny" } },
906
944
  });
907
945
  const sessionRules: Ruleset = [sessionAllow("read", "*")];
908
- const result = manager.checkPermission(
909
- "read",
910
- {},
911
- undefined,
912
- sessionRules,
913
- );
946
+ const result = checkTool(manager, "read", {}, undefined, sessionRules);
914
947
  expect(result.state).toBe("allow");
915
948
  expect(result.source).toBe("session");
916
949
  });
@@ -920,7 +953,8 @@ describe("PermissionManager with in-memory PolicyLoader", () => {
920
953
  global: { permission: { "*": "ask" } },
921
954
  });
922
955
  const sessionRules: Ruleset = [sessionAllow("bash", "git *")];
923
- const bashResult = manager.checkPermission(
956
+ const bashResult = checkTool(
957
+ manager,
924
958
  "bash",
925
959
  { command: "git status" },
926
960
  undefined,
@@ -928,7 +962,8 @@ describe("PermissionManager with in-memory PolicyLoader", () => {
928
962
  );
929
963
  expect(bashResult.state).toBe("allow");
930
964
 
931
- const readResult = manager.checkPermission(
965
+ const readResult = checkTool(
966
+ manager,
932
967
  "read",
933
968
  {},
934
969
  undefined,
@@ -944,7 +979,7 @@ describe("PermissionManager with in-memory PolicyLoader", () => {
944
979
  global: { permission: { "*": "ask" } },
945
980
  project: { permission: { "*": "allow" } },
946
981
  });
947
- const result = manager.checkPermission("read", {});
982
+ const result = checkTool(manager, "read", {});
948
983
  expect(result.state).toBe("allow");
949
984
  expect(result.origin).toBe("project");
950
985
  });
@@ -952,12 +987,7 @@ describe("PermissionManager with in-memory PolicyLoader", () => {
952
987
  it("session origin is 'session'", () => {
953
988
  const manager = makeInMemoryManager();
954
989
  const sessionRules: Ruleset = [sessionAllow("read", "*")];
955
- const result = manager.checkPermission(
956
- "read",
957
- {},
958
- undefined,
959
- sessionRules,
960
- );
990
+ const result = checkTool(manager, "read", {}, undefined, sessionRules);
961
991
  expect(result.origin).toBe("session");
962
992
  });
963
993
  });
@@ -1005,7 +1035,7 @@ describe("checkPermission — per-tool path patterns", () => {
1005
1035
  read: { "*": "allow", "*.env": "deny" },
1006
1036
  });
1007
1037
  try {
1008
- const result = manager.checkPermission("read", { path: ".env" });
1038
+ const result = checkTool(manager, "read", { path: ".env" });
1009
1039
  expect(result.state).toBe("deny");
1010
1040
  expect(result.matchedPattern).toBe("*.env");
1011
1041
  } finally {
@@ -1018,7 +1048,7 @@ describe("checkPermission — per-tool path patterns", () => {
1018
1048
  read: { "*": "allow", "*.env": "deny" },
1019
1049
  });
1020
1050
  try {
1021
- const result = manager.checkPermission("read", {
1051
+ const result = checkTool(manager, "read", {
1022
1052
  path: "src/main.ts",
1023
1053
  });
1024
1054
  expect(result.state).toBe("allow");
@@ -1032,7 +1062,7 @@ describe("checkPermission — per-tool path patterns", () => {
1032
1062
  write: { "*": "deny", "src/*": "allow" },
1033
1063
  });
1034
1064
  try {
1035
- const result = manager.checkPermission("write", {
1065
+ const result = checkTool(manager, "write", {
1036
1066
  path: "src/main.ts",
1037
1067
  });
1038
1068
  expect(result.state).toBe("allow");
@@ -1047,7 +1077,7 @@ describe("checkPermission — per-tool path patterns", () => {
1047
1077
  write: { "*": "deny", "src/*": "allow" },
1048
1078
  });
1049
1079
  try {
1050
- const result = manager.checkPermission("write", {
1080
+ const result = checkTool(manager, "write", {
1051
1081
  path: "vendor/lib.ts",
1052
1082
  });
1053
1083
  expect(result.state).toBe("deny");
@@ -1061,7 +1091,7 @@ describe("checkPermission — per-tool path patterns", () => {
1061
1091
  read: "allow",
1062
1092
  });
1063
1093
  try {
1064
- const result = manager.checkPermission("read", { path: ".env" });
1094
+ const result = checkTool(manager, "read", { path: ".env" });
1065
1095
  expect(result.state).toBe("allow");
1066
1096
  } finally {
1067
1097
  cleanup();
@@ -1073,7 +1103,7 @@ describe("checkPermission — per-tool path patterns", () => {
1073
1103
  read: "deny",
1074
1104
  });
1075
1105
  try {
1076
- const result = manager.checkPermission("read", {
1106
+ const result = checkTool(manager, "read", {
1077
1107
  path: "src/main.ts",
1078
1108
  });
1079
1109
  expect(result.state).toBe("deny");
@@ -1088,7 +1118,8 @@ describe("checkPermission — per-tool path patterns", () => {
1088
1118
  });
1089
1119
  try {
1090
1120
  const sessionRules: Ruleset = [sessionAllow("read", ".env")];
1091
- const result = manager.checkPermission(
1121
+ const result = checkTool(
1122
+ manager,
1092
1123
  "read",
1093
1124
  { path: ".env" },
1094
1125
  undefined,
@@ -1106,7 +1137,7 @@ describe("checkPermission — per-tool path patterns", () => {
1106
1137
  read: { "*": "allow", "*.env": "deny" },
1107
1138
  });
1108
1139
  try {
1109
- const result = manager.checkPermission("read", {});
1140
+ const result = checkTool(manager, "read", {});
1110
1141
  expect(result.state).toBe("allow");
1111
1142
  } finally {
1112
1143
  cleanup();
@@ -1137,7 +1168,7 @@ describe("cross-cutting path surface", () => {
1137
1168
  read: "allow",
1138
1169
  });
1139
1170
  try {
1140
- const result = manager.checkPermission("path", { path: ".env" });
1171
+ const result = checkTool(manager, "path", { path: ".env" });
1141
1172
  expect(result.state).toBe("deny");
1142
1173
  expect(result.matchedPattern).toBe("*.env");
1143
1174
  } finally {
@@ -1151,7 +1182,7 @@ describe("cross-cutting path surface", () => {
1151
1182
  read: "allow",
1152
1183
  });
1153
1184
  try {
1154
- const result = manager.checkPermission("path", { path: "README.md" });
1185
+ const result = checkTool(manager, "path", { path: "README.md" });
1155
1186
  expect(result.state).toBe("allow");
1156
1187
  } finally {
1157
1188
  cleanup();
@@ -1165,12 +1196,12 @@ describe("cross-cutting path surface", () => {
1165
1196
  });
1166
1197
  try {
1167
1198
  // path surface allows, per-tool denies
1168
- const readResult = manager.checkPermission("read", {
1199
+ const readResult = checkTool(manager, "read", {
1169
1200
  path: "data.secret",
1170
1201
  });
1171
1202
  expect(readResult.state).toBe("deny");
1172
1203
  // path surface also allows
1173
- const pathResult = manager.checkPermission("path", {
1204
+ const pathResult = checkTool(manager, "path", {
1174
1205
  path: "data.secret",
1175
1206
  });
1176
1207
  expect(pathResult.state).toBe("allow");
@@ -1197,7 +1228,8 @@ describe("cross-cutting path surface", () => {
1197
1228
  });
1198
1229
  try {
1199
1230
  const sessionRules: Ruleset = [sessionAllow("path", "/project/.env")];
1200
- const result = manager.checkPermission(
1231
+ const result = checkTool(
1232
+ manager,
1201
1233
  "path",
1202
1234
  { path: "/project/.env" },
1203
1235
  undefined,
@@ -1216,7 +1248,7 @@ describe("cross-cutting path surface", () => {
1216
1248
  });
1217
1249
  try {
1218
1250
  // path surface falls through to universal default
1219
- const result = manager.checkPermission("path", { path: ".env" });
1251
+ const result = checkTool(manager, "path", { path: ".env" });
1220
1252
  expect(result.state).toBe("ask");
1221
1253
  } finally {
1222
1254
  cleanup();
@@ -1232,14 +1264,14 @@ describe("cross-cutting path surface", () => {
1232
1264
  try {
1233
1265
  // No explicit "path" key → matchedPattern must be undefined so the
1234
1266
  // path gate skips (describePathGate returns null).
1235
- const result = manager.checkPermission("path", {
1267
+ const result = checkTool(manager, "path", {
1236
1268
  path: "src/main.ts",
1237
1269
  });
1238
1270
  expect(result.state).toBe("ask");
1239
1271
  expect(result.matchedPattern).toBeUndefined();
1240
1272
 
1241
1273
  // Meanwhile the tool-level check should allow read.
1242
- const readResult = manager.checkPermission("read", {
1274
+ const readResult = checkTool(manager, "read", {
1243
1275
  path: "src/main.ts",
1244
1276
  });
1245
1277
  expect(readResult.state).toBe("allow");
@@ -1256,7 +1288,7 @@ describe("cross-cutting path surface", () => {
1256
1288
  bash: { "npm *": { action: "deny", reason: "Use pnpm instead" } },
1257
1289
  });
1258
1290
  try {
1259
- const result = manager.checkPermission("bash", {
1291
+ const result = checkTool(manager, "bash", {
1260
1292
  command: "npm install",
1261
1293
  });
1262
1294
  expect(result.state).toBe("deny");
@@ -1272,7 +1304,7 @@ describe("cross-cutting path surface", () => {
1272
1304
  bash: { "rm -rf *": "deny" },
1273
1305
  });
1274
1306
  try {
1275
- const result = manager.checkPermission("bash", { command: "rm -rf /" });
1307
+ const result = checkTool(manager, "bash", { command: "rm -rf /" });
1276
1308
  expect(result.state).toBe("deny");
1277
1309
  expect(result.reason).toBeUndefined();
1278
1310
  } finally {
@@ -1290,7 +1322,7 @@ describe("cross-cutting path surface", () => {
1290
1322
  },
1291
1323
  });
1292
1324
  try {
1293
- const result = manager.checkPermission("read", { path: ".env" });
1325
+ const result = checkTool(manager, "read", { path: ".env" });
1294
1326
  expect(result.state).toBe("deny");
1295
1327
  expect(result.reason).toBe("Environment files contain secrets");
1296
1328
  expect(result.matchedPattern).toBe("*.env");
@@ -1304,7 +1336,7 @@ describe("cross-cutting path surface", () => {
1304
1336
  bash: { "npm *": { action: "deny", reason: 42 } },
1305
1337
  });
1306
1338
  try {
1307
- const result = manager.checkPermission("bash", {
1339
+ const result = checkTool(manager, "bash", {
1308
1340
  command: "npm install",
1309
1341
  });
1310
1342
  expect(result.state).toBe("ask");
@@ -1322,7 +1354,7 @@ describe("cross-cutting path surface", () => {
1322
1354
  path: { "*.env": "deny", "*": "allow" },
1323
1355
  });
1324
1356
  try {
1325
- const result = manager.checkPermission("path", { path: ".env" });
1357
+ const result = checkTool(manager, "path", { path: ".env" });
1326
1358
  // "*" is last and matches .env → allow (the deny is shadowed)
1327
1359
  expect(result.state).toBe("allow");
1328
1360
  } finally {
@@ -1336,7 +1368,7 @@ describe("cross-cutting path surface", () => {
1336
1368
  path: { "*": "allow", "*.env": "deny" },
1337
1369
  });
1338
1370
  try {
1339
- const result = manager.checkPermission("path", { path: ".env" });
1371
+ const result = checkTool(manager, "path", { path: ".env" });
1340
1372
  expect(result.state).toBe("deny");
1341
1373
  } finally {
1342
1374
  cleanup();
@@ -1355,22 +1387,20 @@ describe("cross-cutting path surface", () => {
1355
1387
  },
1356
1388
  });
1357
1389
  try {
1358
- expect(manager.checkPermission("path", { path: ".env" }).state).toBe(
1390
+ expect(checkTool(manager, "path", { path: ".env" }).state).toBe("deny");
1391
+ expect(checkTool(manager, "path", { path: ".env.local" }).state).toBe(
1359
1392
  "deny",
1360
1393
  );
1361
1394
  expect(
1362
- manager.checkPermission("path", { path: ".env.local" }).state,
1363
- ).toBe("deny");
1364
- expect(
1365
- manager.checkPermission("path", { path: ".env.production" }).state,
1395
+ checkTool(manager, "path", { path: ".env.production" }).state,
1366
1396
  ).toBe("deny");
1367
- expect(manager.checkPermission("path", { path: "src/.env" }).state).toBe(
1397
+ expect(checkTool(manager, "path", { path: "src/.env" }).state).toBe(
1368
1398
  "deny",
1369
1399
  );
1370
- expect(
1371
- manager.checkPermission("path", { path: ".env.example" }).state,
1372
- ).toBe("allow");
1373
- expect(manager.checkPermission("path", { path: "README.md" }).state).toBe(
1400
+ expect(checkTool(manager, "path", { path: ".env.example" }).state).toBe(
1401
+ "allow",
1402
+ );
1403
+ expect(checkTool(manager, "path", { path: "README.md" }).state).toBe(
1374
1404
  "allow",
1375
1405
  );
1376
1406
  } finally {
@@ -1385,7 +1415,7 @@ describe("cross-cutting path surface", () => {
1385
1415
  "*": "allow",
1386
1416
  });
1387
1417
  try {
1388
- const result = manager.checkPermission("path", { path: ".env" });
1418
+ const result = checkTool(manager, "path", { path: ".env" });
1389
1419
  expect(result.state).toBe("allow");
1390
1420
  } finally {
1391
1421
  cleanup();
@@ -1397,7 +1427,7 @@ describe("cross-cutting path surface", () => {
1397
1427
  "*": "deny",
1398
1428
  });
1399
1429
  try {
1400
- const result = manager.checkPermission("path", { path: ".env" });
1430
+ const result = checkTool(manager, "path", { path: ".env" });
1401
1431
  expect(result.state).toBe("deny");
1402
1432
  } finally {
1403
1433
  cleanup();
@@ -1413,11 +1443,11 @@ describe("cross-cutting path surface", () => {
1413
1443
  });
1414
1444
  try {
1415
1445
  // path gate passes (allow), but tool gate denies
1416
- const pathResult = manager.checkPermission("path", {
1446
+ const pathResult = checkTool(manager, "path", {
1417
1447
  path: "secret.txt",
1418
1448
  });
1419
1449
  expect(pathResult.state).toBe("allow");
1420
- const readResult = manager.checkPermission("read", {
1450
+ const readResult = checkTool(manager, "read", {
1421
1451
  path: "secret.txt",
1422
1452
  });
1423
1453
  expect(readResult.state).toBe("deny");
@@ -1437,7 +1467,7 @@ describe("cross-cutting path surface — home-expanded values", () => {
1437
1467
  path: { "*": "allow", "~/.ssh/*": "deny" },
1438
1468
  });
1439
1469
  try {
1440
- const result = manager.checkPermission("path", {
1470
+ const result = checkTool(manager, "path", {
1441
1471
  path: "~/.ssh/config",
1442
1472
  });
1443
1473
  expect(result.state).toBe("deny");
@@ -1452,7 +1482,7 @@ describe("cross-cutting path surface — home-expanded values", () => {
1452
1482
  path: { "*": "allow", "~/.ssh/*": "deny" },
1453
1483
  });
1454
1484
  try {
1455
- const result = manager.checkPermission("path", {
1485
+ const result = checkTool(manager, "path", {
1456
1486
  path: `${homedir()}/.ssh/config`,
1457
1487
  });
1458
1488
  expect(result.state).toBe("deny");
@@ -1467,7 +1497,7 @@ describe("cross-cutting path surface — home-expanded values", () => {
1467
1497
  path: { "*": "allow", "$HOME/.ssh/*": "deny" },
1468
1498
  });
1469
1499
  try {
1470
- const result = manager.checkPermission("path", {
1500
+ const result = checkTool(manager, "path", {
1471
1501
  path: "$HOME/.ssh/config",
1472
1502
  });
1473
1503
  expect(result.state).toBe("deny");
@@ -1482,7 +1512,7 @@ describe("cross-cutting path surface — home-expanded values", () => {
1482
1512
  path: { "*": "allow", "~/.ssh/*": "deny" },
1483
1513
  });
1484
1514
  try {
1485
- const result = manager.checkPermission("path", {
1515
+ const result = checkTool(manager, "path", {
1486
1516
  path: `${homedir()}/.ssh/config`,
1487
1517
  });
1488
1518
  expect(result.state).toBe("deny");
@@ -1496,7 +1526,7 @@ describe("cross-cutting path surface — home-expanded values", () => {
1496
1526
  path: { "*": "allow", "*.env": "deny" },
1497
1527
  });
1498
1528
  try {
1499
- const result = manager.checkPermission("path", { path: ".env" });
1529
+ const result = checkTool(manager, "path", { path: ".env" });
1500
1530
  expect(result.state).toBe("deny");
1501
1531
  expect(result.matchedPattern).toBe("*.env");
1502
1532
  } finally {
@@ -1510,7 +1540,7 @@ describe("cross-cutting path surface — home-expanded values", () => {
1510
1540
  read: { "*": "allow", "~/.ssh/*": "deny" },
1511
1541
  });
1512
1542
  try {
1513
- const result = manager.checkPermission("read", {
1543
+ const result = checkTool(manager, "read", {
1514
1544
  path: "~/.ssh/config",
1515
1545
  });
1516
1546
  expect(result.state).toBe("deny");
@@ -1583,7 +1613,7 @@ describe("PermissionManager — configureForCwd and agentDir option", () => {
1583
1613
  });
1584
1614
  const scoped: ScopedPermissionManager = manager;
1585
1615
  expect(typeof scoped.configureForCwd).toBe("function");
1586
- expect(typeof scoped.checkPermission).toBe("function");
1616
+ expect(typeof scoped.check).toBe("function");
1587
1617
  expect(typeof scoped.getToolPermission).toBe("function");
1588
1618
  expect(typeof scoped.getConfigIssues).toBe("function");
1589
1619
  });
@@ -1594,7 +1624,7 @@ describe("PermissionManager — configureForCwd and agentDir option", () => {
1594
1624
  });
1595
1625
  try {
1596
1626
  const manager = new PermissionManager({ agentDir });
1597
- const result = manager.checkPermission("read", { path: "foo.txt" });
1627
+ const result = checkTool(manager, "read", { path: "foo.txt" });
1598
1628
  expect(result.state).toBe("deny");
1599
1629
  } finally {
1600
1630
  cleanup();
@@ -1609,14 +1639,14 @@ describe("PermissionManager — configureForCwd and agentDir option", () => {
1609
1639
  try {
1610
1640
  const manager = new PermissionManager({ agentDir });
1611
1641
  // Before configureForCwd: global policy applies
1612
- expect(manager.checkPermission("read", { path: "foo.txt" }).state).toBe(
1642
+ expect(checkTool(manager, "read", { path: "foo.txt" }).state).toBe(
1613
1643
  "deny",
1614
1644
  );
1615
1645
 
1616
1646
  manager.configureForCwd(cwd);
1617
1647
 
1618
1648
  // After configureForCwd: project override applies (last-match-wins)
1619
- expect(manager.checkPermission("read", { path: "foo.txt" }).state).toBe(
1649
+ expect(checkTool(manager, "read", { path: "foo.txt" }).state).toBe(
1620
1650
  "allow",
1621
1651
  );
1622
1652
  } finally {
@@ -1632,14 +1662,14 @@ describe("PermissionManager — configureForCwd and agentDir option", () => {
1632
1662
  try {
1633
1663
  const manager = new PermissionManager({ agentDir });
1634
1664
  manager.configureForCwd(cwd);
1635
- expect(manager.checkPermission("read", { path: "foo.txt" }).state).toBe(
1665
+ expect(checkTool(manager, "read", { path: "foo.txt" }).state).toBe(
1636
1666
  "allow",
1637
1667
  );
1638
1668
 
1639
1669
  manager.configureForCwd(undefined);
1640
1670
 
1641
1671
  // After reverting: global policy applies again
1642
- expect(manager.checkPermission("read", { path: "foo.txt" }).state).toBe(
1672
+ expect(checkTool(manager, "read", { path: "foo.txt" }).state).toBe(
1643
1673
  "deny",
1644
1674
  );
1645
1675
  } finally {
@@ -1654,7 +1684,7 @@ describe("PermissionManager — configureForCwd and agentDir option", () => {
1654
1684
  try {
1655
1685
  const manager = new PermissionManager({ agentDir });
1656
1686
  // Warm the cache
1657
- expect(manager.checkPermission("read", { path: "foo.txt" }).state).toBe(
1687
+ expect(checkTool(manager, "read", { path: "foo.txt" }).state).toBe(
1658
1688
  "allow",
1659
1689
  );
1660
1690
  // Update global config on disk to deny read
@@ -1665,7 +1695,7 @@ describe("PermissionManager — configureForCwd and agentDir option", () => {
1665
1695
  // configureForCwd clears cache + rebuilds loader
1666
1696
  manager.configureForCwd(undefined);
1667
1697
  // Should pick up the changed global config
1668
- expect(manager.checkPermission("read", { path: "foo.txt" }).state).toBe(
1698
+ expect(checkTool(manager, "read", { path: "foo.txt" }).state).toBe(
1669
1699
  "deny",
1670
1700
  );
1671
1701
  } finally {
@@ -1708,12 +1738,12 @@ describe("PermissionManager — configureForCwd and agentDir option", () => {
1708
1738
  manager.configureForCwd(cwd);
1709
1739
 
1710
1740
  // Without an agent name: global allow applies.
1711
- expect(manager.checkPermission("read", { path: "foo.txt" }).state).toBe(
1741
+ expect(checkTool(manager, "read", { path: "foo.txt" }).state).toBe(
1712
1742
  "allow",
1713
1743
  );
1714
1744
  // With the "coder" agent: project-agent deny overrides global allow.
1715
1745
  expect(
1716
- manager.checkPermission("read", { path: "foo.txt" }, "coder").state,
1746
+ checkTool(manager, "read", { path: "foo.txt" }, "coder").state,
1717
1747
  ).toBe("deny");
1718
1748
  } finally {
1719
1749
  cleanup();
@@ -1742,13 +1772,13 @@ test("Project-level config overrides base bash patterns", () => {
1742
1772
  );
1743
1773
 
1744
1774
  try {
1745
- const allowed = manager.checkPermission("bash", {
1775
+ const allowed = checkTool(manager, "bash", {
1746
1776
  command: "rm -rf build",
1747
1777
  });
1748
1778
  expect(allowed.state).toBe("allow");
1749
1779
  expect(allowed.matchedPattern).toBe("rm -rf build");
1750
1780
 
1751
- const denied = manager.checkPermission("bash", {
1781
+ const denied = checkTool(manager, "bash", {
1752
1782
  command: "rm -rf node_modules",
1753
1783
  });
1754
1784
  expect(denied.state).toBe("deny");
@@ -1780,7 +1810,8 @@ permission:
1780
1810
  );
1781
1811
 
1782
1812
  try {
1783
- const allowed = manager.checkPermission(
1813
+ const allowed = checkTool(
1814
+ manager,
1784
1815
  "bash",
1785
1816
  { command: "git log --oneline" },
1786
1817
  "reviewer",
@@ -1788,7 +1819,8 @@ permission:
1788
1819
  expect(allowed.state).toBe("allow");
1789
1820
  expect(allowed.matchedPattern).toBe("git log *");
1790
1821
 
1791
- const denied = manager.checkPermission(
1822
+ const denied = checkTool(
1823
+ manager,
1792
1824
  "bash",
1793
1825
  { command: "git status" },
1794
1826
  "reviewer",
@@ -1826,7 +1858,7 @@ permission:
1826
1858
  );
1827
1859
 
1828
1860
  try {
1829
- const result = manager.checkPermission("read", {}, "reviewer");
1861
+ const result = checkTool(manager, "read", {}, "reviewer");
1830
1862
  expect(result.state).toBe("allow");
1831
1863
  expect(result.source).toBe("tool");
1832
1864
  } finally {
@@ -1863,7 +1895,8 @@ permission:
1863
1895
  );
1864
1896
 
1865
1897
  try {
1866
- const reviewerResult = manager.checkPermission(
1898
+ const reviewerResult = checkTool(
1899
+ manager,
1867
1900
  "custom_extension_tool",
1868
1901
  {},
1869
1902
  "reviewer",
@@ -1871,7 +1904,7 @@ permission:
1871
1904
  expect(reviewerResult.state).toBe("deny");
1872
1905
  expect(reviewerResult.source).toBe("default");
1873
1906
 
1874
- const globalResult = manager.checkPermission("custom_extension_tool", {});
1907
+ const globalResult = checkTool(manager, "custom_extension_tool", {});
1875
1908
  expect(globalResult.state).toBe("allow");
1876
1909
  expect(globalResult.source).toBe("default");
1877
1910
  } finally {
@@ -1898,11 +1931,11 @@ permission:
1898
1931
  );
1899
1932
 
1900
1933
  try {
1901
- const agentResult = manager.checkPermission("read", {}, "reviewer");
1934
+ const agentResult = checkTool(manager, "read", {}, "reviewer");
1902
1935
  expect(agentResult.state).toBe("deny");
1903
1936
  expect(agentResult.source).toBe("tool");
1904
1937
 
1905
- const globalResult = manager.checkPermission("read", {});
1938
+ const globalResult = checkTool(manager, "read", {});
1906
1939
  expect(globalResult.state).toBe("allow");
1907
1940
  expect(globalResult.source).toBe("tool");
1908
1941
  } finally {
@@ -1920,11 +1953,11 @@ test("PermissionManager canonical built-in permission checking", () => {
1920
1953
  });
1921
1954
 
1922
1955
  try {
1923
- const readResult = manager.checkPermission("read", {});
1956
+ const readResult = checkTool(manager, "read", {});
1924
1957
  expect(readResult.state).toBe("allow");
1925
1958
  expect(readResult.source).toBe("tool");
1926
1959
 
1927
- const writeResult = manager.checkPermission("write", {});
1960
+ const writeResult = checkTool(manager, "write", {});
1928
1961
  expect(writeResult.state).toBe("deny");
1929
1962
  expect(writeResult.source).toBe("tool");
1930
1963
  } finally {
@@ -1946,7 +1979,7 @@ test("multiline bash command resolves to allow via universal fallback", () => {
1946
1979
  try {
1947
1980
  const command =
1948
1981
  "node -e \"\nimport('x').then(() => {\n console.log('done');\n});\n\"";
1949
- const result = manager.checkPermission("bash", { command });
1982
+ const result = checkTool(manager, "bash", { command });
1950
1983
  expect(result.state).toBe("allow");
1951
1984
  } finally {
1952
1985
  cleanup();
@@ -1964,14 +1997,14 @@ test("Bash specific deny patterns override catch-all within the same config", ()
1964
1997
  });
1965
1998
 
1966
1999
  try {
1967
- const denied = manager.checkPermission("bash", {
2000
+ const denied = checkTool(manager, "bash", {
1968
2001
  command: "rm -rf build",
1969
2002
  });
1970
2003
  expect(denied.state).toBe("deny");
1971
2004
  expect(denied.source).toBe("bash");
1972
2005
  expect(denied.matchedPattern).toBe("rm -rf *");
1973
2006
 
1974
- const allowed = manager.checkPermission("bash", { command: "echo hello" });
2007
+ const allowed = checkTool(manager, "bash", { command: "echo hello" });
1975
2008
  expect(allowed.state).toBe("allow");
1976
2009
  expect(allowed.source).toBe("bash");
1977
2010
  expect(allowed.matchedPattern).toBe("*");
@@ -1989,7 +2022,7 @@ test("MCP wildcard matching uses the registered mcp tool", () => {
1989
2022
  });
1990
2023
 
1991
2024
  try {
1992
- const queryDocs = manager.checkPermission("mcp", {
2025
+ const queryDocs = checkTool(manager, "mcp", {
1993
2026
  tool: "research:query-docs",
1994
2027
  });
1995
2028
  expect(queryDocs.state).toBe("allow");
@@ -1997,14 +2030,14 @@ test("MCP wildcard matching uses the registered mcp tool", () => {
1997
2030
  expect(queryDocs.matchedPattern).toBe("research_query-*");
1998
2031
  expect(queryDocs.target).toBe("research_query-docs");
1999
2032
 
2000
- const resolve2 = manager.checkPermission("mcp", {
2033
+ const resolve2 = checkTool(manager, "mcp", {
2001
2034
  tool: "research:resolve-context",
2002
2035
  });
2003
2036
  expect(resolve2.state).toBe("ask");
2004
2037
  expect(resolve2.matchedPattern).toBe("research_*");
2005
2038
  expect(resolve2.target).toBe("research_resolve-context");
2006
2039
 
2007
- const unknown = manager.checkPermission("mcp", {
2040
+ const unknown = checkTool(manager, "mcp", {
2008
2041
  tool: "search:provider",
2009
2042
  });
2010
2043
  expect(unknown.state).toBe("deny");
@@ -2025,13 +2058,13 @@ test("Arbitrary extension tools use exact-name tool permissions instead of MCP f
2025
2058
  });
2026
2059
 
2027
2060
  try {
2028
- const allowed = manager.checkPermission("third_party_tool", {});
2061
+ const allowed = checkTool(manager, "third_party_tool", {});
2029
2062
  expect(allowed.state).toBe("allow");
2030
2063
  expect(allowed.source).toBe("tool");
2031
2064
 
2032
2065
  // another_extension_tool has no explicit rule — falls through to the
2033
2066
  // universal default (permission["*"] = "deny") with source "default".
2034
- const fallback = manager.checkPermission("another_extension_tool", {});
2067
+ const fallback = checkTool(manager, "another_extension_tool", {});
2035
2068
  expect(fallback.state).toBe("deny");
2036
2069
  expect(fallback.source).toBe("default");
2037
2070
  } finally {
@@ -2052,20 +2085,20 @@ test("Skill permission matching", () => {
2052
2085
  });
2053
2086
 
2054
2087
  try {
2055
- const allowed = manager.checkPermission("skill", {
2088
+ const allowed = checkTool(manager, "skill", {
2056
2089
  name: "requesting-code-review",
2057
2090
  });
2058
2091
  expect(allowed.state).toBe("allow");
2059
2092
  expect(allowed.matchedPattern).toBe("requesting-code-review");
2060
2093
  expect(allowed.source).toBe("skill");
2061
2094
 
2062
- const denied = manager.checkPermission("skill", {
2095
+ const denied = checkTool(manager, "skill", {
2063
2096
  name: "web-design-guidelines",
2064
2097
  });
2065
2098
  expect(denied.state).toBe("deny");
2066
2099
  expect(denied.matchedPattern).toBe("web-*");
2067
2100
 
2068
- const fallback = manager.checkPermission("skill", {
2101
+ const fallback = checkTool(manager, "skill", {
2069
2102
  name: "unknown-skill",
2070
2103
  });
2071
2104
  expect(fallback.state).toBe("ask");
@@ -2088,7 +2121,7 @@ test("MCP proxy tool infers server-prefixed aliases from configured server names
2088
2121
  );
2089
2122
 
2090
2123
  try {
2091
- const result = manager.checkPermission("mcp", {
2124
+ const result = checkTool(manager, "mcp", {
2092
2125
  tool: "get_code_context_exa",
2093
2126
  });
2094
2127
  expect(result.state).toBe("allow");
@@ -2131,7 +2164,7 @@ test("MCP server names in settings.json are not used — only mcp.json is consul
2131
2164
  });
2132
2165
 
2133
2166
  try {
2134
- const result = manager.checkPermission("mcp", {
2167
+ const result = checkTool(manager, "mcp", {
2135
2168
  tool: "some_tool_legacy-server",
2136
2169
  });
2137
2170
  expect(result.state).toBe("ask");
@@ -2153,7 +2186,7 @@ test("MCP describe mode normalizes qualified tool names without duplicating serv
2153
2186
  );
2154
2187
 
2155
2188
  try {
2156
- const result = manager.checkPermission("mcp", {
2189
+ const result = checkTool(manager, "mcp", {
2157
2190
  describe: "exa:web_search_exa",
2158
2191
  server: "exa",
2159
2192
  });
@@ -2172,11 +2205,11 @@ test("Canonical tools map directly without legacy aliases", () => {
2172
2205
  });
2173
2206
 
2174
2207
  try {
2175
- const findResult = manager.checkPermission("find", {});
2208
+ const findResult = checkTool(manager, "find", {});
2176
2209
  expect(findResult.state).toBe("allow");
2177
2210
  expect(findResult.source).toBe("tool");
2178
2211
 
2179
- const lsResult = manager.checkPermission("ls", {});
2212
+ const lsResult = checkTool(manager, "ls", {});
2180
2213
  expect(lsResult.state).toBe("deny");
2181
2214
  expect(lsResult.source).toBe("tool");
2182
2215
  } finally {
@@ -2200,7 +2233,8 @@ permission:
2200
2233
  );
2201
2234
 
2202
2235
  try {
2203
- const result = manager.checkPermission(
2236
+ const result = checkTool(
2237
+ manager,
2204
2238
  "mcp",
2205
2239
  { tool: "exa:web_search_exa" },
2206
2240
  "reviewer",
@@ -2232,7 +2266,8 @@ permission:
2232
2266
  );
2233
2267
 
2234
2268
  try {
2235
- const result = manager.checkPermission(
2269
+ const result = checkTool(
2270
+ manager,
2236
2271
  "mcp",
2237
2272
  { tool: "web_search_exa" },
2238
2273
  "reviewer",
@@ -2265,7 +2300,8 @@ permission:
2265
2300
  );
2266
2301
 
2267
2302
  try {
2268
- const allowed = manager.checkPermission(
2303
+ const allowed = checkTool(
2304
+ manager,
2269
2305
  "mcp",
2270
2306
  { tool: "web_search_exa" },
2271
2307
  "reviewer",
@@ -2275,7 +2311,8 @@ permission:
2275
2311
  expect(allowed.matchedPattern).toBe("exa_web_search_exa");
2276
2312
  expect(allowed.target).toBe("exa_web_search_exa");
2277
2313
 
2278
- const fallback = manager.checkPermission(
2314
+ const fallback = checkTool(
2315
+ manager,
2279
2316
  "mcp",
2280
2317
  { tool: "other_exa" },
2281
2318
  "reviewer",
@@ -2304,11 +2341,12 @@ permission:
2304
2341
  );
2305
2342
 
2306
2343
  try {
2307
- const readResult = manager.checkPermission("read", {}, "reviewer");
2344
+ const readResult = checkTool(manager, "read", {}, "reviewer");
2308
2345
  expect(readResult.state).toBe("deny");
2309
2346
  expect(readResult.source).toBe("tool");
2310
2347
 
2311
- const mcpResult = manager.checkPermission(
2348
+ const mcpResult = checkTool(
2349
+ manager,
2312
2350
  "mcp",
2313
2351
  { tool: "exa:web_search_exa" },
2314
2352
  "reviewer",
@@ -2337,11 +2375,11 @@ permission:
2337
2375
  );
2338
2376
 
2339
2377
  try {
2340
- const findResult = manager.checkPermission("find", {}, "reviewer");
2378
+ const findResult = checkTool(manager, "find", {}, "reviewer");
2341
2379
  expect(findResult.state).toBe("allow");
2342
2380
  expect(findResult.source).toBe("tool");
2343
2381
 
2344
- const lsResult = manager.checkPermission("ls", {}, "reviewer");
2382
+ const lsResult = checkTool(manager, "ls", {}, "reviewer");
2345
2383
  expect(lsResult.state).toBe("deny");
2346
2384
  expect(lsResult.source).toBe("tool");
2347
2385
  } finally {
@@ -2367,15 +2405,16 @@ permission:
2367
2405
  );
2368
2406
 
2369
2407
  try {
2370
- const findResult = manager.checkPermission("find", {}, "reviewer");
2408
+ const findResult = checkTool(manager, "find", {}, "reviewer");
2371
2409
  expect(findResult.state).toBe("allow");
2372
2410
  expect(findResult.source).toBe("tool");
2373
2411
 
2374
- const taskResult = manager.checkPermission("task", {}, "reviewer");
2412
+ const taskResult = checkTool(manager, "task", {}, "reviewer");
2375
2413
  expect(taskResult.state).toBe("allow");
2376
2414
  expect(taskResult.source).toBe("tool");
2377
2415
 
2378
- const mcpResult = manager.checkPermission(
2416
+ const mcpResult = checkTool(
2417
+ manager,
2379
2418
  "mcp",
2380
2419
  { tool: "exa:web_search_exa" },
2381
2420
  "reviewer",
@@ -2392,7 +2431,7 @@ test("task uses exact-name tool permissions like any registered extension tool",
2392
2431
  });
2393
2432
 
2394
2433
  try {
2395
- const taskResult = manager.checkPermission("task", {});
2434
+ const taskResult = checkTool(manager, "task", {});
2396
2435
  expect(taskResult.state).toBe("allow");
2397
2436
  expect(taskResult.source).toBe("tool");
2398
2437
  } finally {
@@ -2471,7 +2510,7 @@ test("external_directory permission falls back to universal default when not exp
2471
2510
  const { manager, cleanup } = createManager({ permission: {} });
2472
2511
 
2473
2512
  try {
2474
- const result = manager.checkPermission("external_directory", {});
2513
+ const result = checkTool(manager, "external_directory", {});
2475
2514
  expect(result.state).toBe("ask");
2476
2515
  expect(result.source).toBe("special");
2477
2516
  expect(result.matchedPattern).toBe(undefined);
@@ -2486,7 +2525,7 @@ test("external_directory permission respects explicit deny", () => {
2486
2525
  });
2487
2526
 
2488
2527
  try {
2489
- const result = manager.checkPermission("external_directory", {});
2528
+ const result = checkTool(manager, "external_directory", {});
2490
2529
  expect(result.state).toBe("deny");
2491
2530
  expect(result.source).toBe("special");
2492
2531
  expect(result.matchedPattern).toBe("*");
@@ -2501,7 +2540,7 @@ test("external_directory permission can be explicitly allowed", () => {
2501
2540
  });
2502
2541
 
2503
2542
  try {
2504
- const result = manager.checkPermission("external_directory", {});
2543
+ const result = checkTool(manager, "external_directory", {});
2505
2544
  expect(result.state).toBe("allow");
2506
2545
  expect(result.source).toBe("special");
2507
2546
  expect(result.matchedPattern).toBe("*");
@@ -2526,14 +2565,10 @@ permission:
2526
2565
  );
2527
2566
 
2528
2567
  try {
2529
- const globalResult = manager.checkPermission("external_directory", {});
2568
+ const globalResult = checkTool(manager, "external_directory", {});
2530
2569
  expect(globalResult.state).toBe("deny");
2531
2570
 
2532
- const agentResult = manager.checkPermission(
2533
- "external_directory",
2534
- {},
2535
- "trusted",
2536
- );
2571
+ const agentResult = checkTool(manager, "external_directory", {}, "trusted");
2537
2572
  expect(agentResult.state).toBe("allow");
2538
2573
  expect(agentResult.source).toBe("special");
2539
2574
  } finally {
@@ -2547,7 +2582,7 @@ test("external_directory permission is not affected by unrelated surface keys",
2547
2582
  });
2548
2583
 
2549
2584
  try {
2550
- const extResult = manager.checkPermission("external_directory", {});
2585
+ const extResult = checkTool(manager, "external_directory", {});
2551
2586
  expect(extResult.state).toBe("allow");
2552
2587
  expect(extResult.matchedPattern).toBe("*");
2553
2588
  } finally {
@@ -2573,7 +2608,8 @@ permission:
2573
2608
  );
2574
2609
 
2575
2610
  try {
2576
- const allowed = manager.checkPermission(
2611
+ const allowed = checkTool(
2612
+ manager,
2577
2613
  "skill",
2578
2614
  { name: "pi-code-review" },
2579
2615
  "reviewer",
@@ -2582,7 +2618,8 @@ permission:
2582
2618
  expect(allowed.matchedPattern).toBe("pi-*");
2583
2619
  expect(allowed.source).toBe("skill");
2584
2620
 
2585
- const asked = manager.checkPermission(
2621
+ const asked = checkTool(
2622
+ manager,
2586
2623
  "skill",
2587
2624
  { name: "other-skill" },
2588
2625
  "reviewer",
@@ -2590,7 +2627,7 @@ permission:
2590
2627
  expect(asked.state).toBe("ask");
2591
2628
  expect(asked.matchedPattern).toBe("*");
2592
2629
 
2593
- const denied = manager.checkPermission("skill", { name: "pi-code-review" });
2630
+ const denied = checkTool(manager, "skill", { name: "pi-code-review" });
2594
2631
  expect(denied.state).toBe("deny");
2595
2632
  expect(denied.source).toBe("skill");
2596
2633
  } finally {
@@ -2616,7 +2653,8 @@ permission:
2616
2653
  );
2617
2654
 
2618
2655
  try {
2619
- const allowed = manager.checkPermission(
2656
+ const allowed = checkTool(
2657
+ manager,
2620
2658
  "external_directory",
2621
2659
  { path: `${homedir()}/Downloads/file.txt` },
2622
2660
  "trusted",
@@ -2625,7 +2663,8 @@ permission:
2625
2663
  expect(allowed.matchedPattern).toBe("~/Downloads/*");
2626
2664
  expect(allowed.source).toBe("special");
2627
2665
 
2628
- const denied = manager.checkPermission(
2666
+ const denied = checkTool(
2667
+ manager,
2629
2668
  "external_directory",
2630
2669
  { path: `${homedir()}/Documents/secret.txt` },
2631
2670
  "trusted",
@@ -2633,7 +2672,7 @@ permission:
2633
2672
  expect(denied.state).toBe("deny");
2634
2673
  expect(denied.matchedPattern).toBe("*");
2635
2674
 
2636
- const globalDenied = manager.checkPermission("external_directory", {});
2675
+ const globalDenied = checkTool(manager, "external_directory", {});
2637
2676
  expect(globalDenied.state).toBe("deny");
2638
2677
  expect(globalDenied.source).toBe("special");
2639
2678
  } finally {
@@ -2670,7 +2709,8 @@ permission:
2670
2709
  );
2671
2710
 
2672
2711
  try {
2673
- const allowed = manager.checkPermission(
2712
+ const allowed = checkTool(
2713
+ manager,
2674
2714
  "skill",
2675
2715
  { name: "pi-code-review" },
2676
2716
  "analyst",
@@ -2678,7 +2718,8 @@ permission:
2678
2718
  expect(allowed.state).toBe("allow");
2679
2719
  expect(allowed.matchedPattern).toBe("pi-*");
2680
2720
 
2681
- const denied = manager.checkPermission(
2721
+ const denied = checkTool(
2722
+ manager,
2682
2723
  "skill",
2683
2724
  { name: "other-skill" },
2684
2725
  "analyst",
@@ -2716,11 +2757,11 @@ permission:
2716
2757
  );
2717
2758
 
2718
2759
  try {
2719
- const result = manager.checkPermission("external_directory", {}, "analyst");
2760
+ const result = checkTool(manager, "external_directory", {}, "analyst");
2720
2761
  expect(result.state).toBe("allow");
2721
2762
  expect(result.source).toBe("special");
2722
2763
 
2723
- const globalResult = manager.checkPermission("external_directory", {});
2764
+ const globalResult = checkTool(manager, "external_directory", {});
2724
2765
  expect(globalResult.state).toBe("deny");
2725
2766
  } finally {
2726
2767
  cleanup();
@@ -2747,10 +2788,10 @@ test("PermissionManager reads config from PI_CODING_AGENT_DIR when set", () => {
2747
2788
  process.env.PI_CODING_AGENT_DIR = baseDir;
2748
2789
  try {
2749
2790
  const manager = new PermissionManager();
2750
- const result = manager.checkPermission("read", {});
2791
+ const result = checkTool(manager, "read", {});
2751
2792
  expect(result.state).toBe("allow");
2752
2793
 
2753
- const result2 = manager.checkPermission("write", {});
2794
+ const result2 = checkTool(manager, "write", {});
2754
2795
  expect(result2.state).toBe("deny");
2755
2796
  } finally {
2756
2797
  if (original !== undefined) {
@@ -2809,7 +2850,8 @@ test("checkPermission returns source 'session' when session rules cover the exte
2809
2850
  },
2810
2851
  ];
2811
2852
 
2812
- const result = manager.checkPermission(
2853
+ const result = checkTool(
2854
+ manager,
2813
2855
  "external_directory",
2814
2856
  { path: "/other/project/src/foo.ts" },
2815
2857
  undefined,
@@ -2839,7 +2881,8 @@ test("checkPermission falls back to config policy when session rules do not cove
2839
2881
  },
2840
2882
  ];
2841
2883
 
2842
- const result = manager.checkPermission(
2884
+ const result = checkTool(
2885
+ manager,
2843
2886
  "external_directory",
2844
2887
  { path: "/completely/different/path.ts" },
2845
2888
  undefined,
@@ -2858,13 +2901,14 @@ test("checkPermission with empty session rules is identical to call without sess
2858
2901
  });
2859
2902
 
2860
2903
  try {
2861
- const withEmpty = manager.checkPermission(
2904
+ const withEmpty = checkTool(
2905
+ manager,
2862
2906
  "external_directory",
2863
2907
  { path: "/other/project/foo.ts" },
2864
2908
  undefined,
2865
2909
  [],
2866
2910
  );
2867
- const withoutArg = manager.checkPermission("external_directory", {
2911
+ const withoutArg = checkTool(manager, "external_directory", {
2868
2912
  path: "/other/project/foo.ts",
2869
2913
  });
2870
2914
  const expected: PermissionCheckResult = {
@@ -2895,7 +2939,8 @@ test("session rules for one surface do not affect checks on other surfaces", ()
2895
2939
  },
2896
2940
  ];
2897
2941
 
2898
- const bashResult = manager.checkPermission(
2942
+ const bashResult = checkTool(
2943
+ manager,
2899
2944
  "bash",
2900
2945
  { command: "git status" },
2901
2946
  undefined,
@@ -2904,7 +2949,8 @@ test("session rules for one surface do not affect checks on other surfaces", ()
2904
2949
  expect(bashResult.state).toBe("ask");
2905
2950
  expect(bashResult.source).toBe("bash");
2906
2951
 
2907
- const mcpResult = manager.checkPermission(
2952
+ const mcpResult = checkTool(
2953
+ manager,
2908
2954
  "mcp",
2909
2955
  { tool: "exa:search" },
2910
2956
  undefined,
@@ -2933,7 +2979,8 @@ test("session rules override config deny for external_directory", () => {
2933
2979
  },
2934
2980
  ];
2935
2981
 
2936
- const result = manager.checkPermission(
2982
+ const result = checkTool(
2983
+ manager,
2937
2984
  "external_directory",
2938
2985
  { path: "/other/project/src/foo.ts" },
2939
2986
  undefined,
@@ -2960,7 +3007,8 @@ test("checkPermission returns source 'session' for bash when session rules match
2960
3007
  },
2961
3008
  ];
2962
3009
 
2963
- const result = manager.checkPermission(
3010
+ const result = checkTool(
3011
+ manager,
2964
3012
  "bash",
2965
3013
  { command: "git status --short" },
2966
3014
  undefined,
@@ -2988,7 +3036,8 @@ test("checkPermission returns source 'session' for bash when session rule is exa
2988
3036
  },
2989
3037
  ];
2990
3038
 
2991
- const result = manager.checkPermission(
3039
+ const result = checkTool(
3040
+ manager,
2992
3041
  "bash",
2993
3042
  { command: "ls" },
2994
3043
  undefined,
@@ -3015,7 +3064,8 @@ test("checkPermission falls back to config for bash when session rules do not ma
3015
3064
  },
3016
3065
  ];
3017
3066
 
3018
- const result = manager.checkPermission(
3067
+ const result = checkTool(
3068
+ manager,
3019
3069
  "bash",
3020
3070
  { command: "npm run build" },
3021
3071
  undefined,
@@ -3042,7 +3092,8 @@ test("checkPermission returns source 'session' for mcp when session rules match
3042
3092
  },
3043
3093
  ];
3044
3094
 
3045
- const result = manager.checkPermission(
3095
+ const result = checkTool(
3096
+ manager,
3046
3097
  "mcp",
3047
3098
  { tool: "exa:search" },
3048
3099
  undefined,
@@ -3069,7 +3120,8 @@ test("checkPermission returns source 'session' for skill when session rules matc
3069
3120
  },
3070
3121
  ];
3071
3122
 
3072
- const result = manager.checkPermission(
3123
+ const result = checkTool(
3124
+ manager,
3073
3125
  "skill",
3074
3126
  { name: "librarian" },
3075
3127
  undefined,
@@ -3097,7 +3149,7 @@ test("checkPermission returns source 'session' for tool surface when session rul
3097
3149
  },
3098
3150
  ];
3099
3151
 
3100
- const result = manager.checkPermission("read", {}, undefined, sessionRules);
3152
+ const result = checkTool(manager, "read", {}, undefined, sessionRules);
3101
3153
  expect(result.state).toBe("allow");
3102
3154
  expect(result.source).toBe("session");
3103
3155
  } finally {
@@ -3119,7 +3171,8 @@ test("bash session rules do not bleed into mcp checks", () => {
3119
3171
  },
3120
3172
  ];
3121
3173
 
3122
- const result = manager.checkPermission(
3174
+ const result = checkTool(
3175
+ manager,
3123
3176
  "mcp",
3124
3177
  { tool: "exa:search" },
3125
3178
  undefined,
@@ -3206,7 +3259,7 @@ describe("checkPermission — cwd-aware path policy values", () => {
3206
3259
  });
3207
3260
  try {
3208
3261
  manager.configureForCwd(cwd);
3209
- const result = manager.checkPermission("read", { path: "src/App.jsx" });
3262
+ const result = checkTool(manager, "read", { path: "src/App.jsx" });
3210
3263
  expect(result.state).toBe("allow");
3211
3264
  expect(result.matchedPattern).toBe(`${cwd}/*`);
3212
3265
  } finally {
@@ -3220,7 +3273,7 @@ describe("checkPermission — cwd-aware path policy values", () => {
3220
3273
  });
3221
3274
  try {
3222
3275
  manager.configureForCwd(cwd);
3223
- const result = manager.checkPermission("read", { path: "src/App.jsx" });
3276
+ const result = checkTool(manager, "read", { path: "src/App.jsx" });
3224
3277
  expect(result.state).toBe("deny");
3225
3278
  expect(result.matchedPattern).toBe("src/*");
3226
3279
  } finally {
@@ -3238,7 +3291,7 @@ describe("checkPermission — cwd-aware path policy values", () => {
3238
3291
  });
3239
3292
  try {
3240
3293
  manager.configureForCwd(cwd);
3241
- const result = manager.checkPermission("read", { path: "src/App.jsx" });
3294
+ const result = checkTool(manager, "read", { path: "src/App.jsx" });
3242
3295
  // The later "src/*" deny wins over the earlier absolute allow.
3243
3296
  expect(result.state).toBe("deny");
3244
3297
  expect(result.matchedPattern).toBe("src/*");
@@ -3253,7 +3306,7 @@ describe("checkPermission — cwd-aware path policy values", () => {
3253
3306
  });
3254
3307
  try {
3255
3308
  manager.configureForCwd(cwd);
3256
- const result = manager.checkPermission("path", { path: "src/App.jsx" });
3309
+ const result = checkTool(manager, "path", { path: "src/App.jsx" });
3257
3310
  expect(result.state).toBe("allow");
3258
3311
  expect(result.matchedPattern).toBe(`${cwd}/*`);
3259
3312
  } finally {
@@ -3270,7 +3323,7 @@ describe("checkPathPolicy", () => {
3270
3323
  path: { "*": "ask", [`${cwd}/*`]: "allow" },
3271
3324
  });
3272
3325
  try {
3273
- const result = manager.checkPathPolicy([
3326
+ const result = checkPathValues(manager, [
3274
3327
  `${cwd}/src/App.jsx`,
3275
3328
  "src/App.jsx",
3276
3329
  ]);
@@ -3288,7 +3341,7 @@ describe("checkPathPolicy", () => {
3288
3341
  path: { "*": "ask", [`${cwd}/*`]: "allow", "src/*": "deny" },
3289
3342
  });
3290
3343
  try {
3291
- const result = manager.checkPathPolicy([
3344
+ const result = checkPathValues(manager, [
3292
3345
  `${cwd}/src/App.jsx`,
3293
3346
  "src/App.jsx",
3294
3347
  ]);
@@ -3305,7 +3358,8 @@ describe("checkPathPolicy", () => {
3305
3358
  });
3306
3359
  try {
3307
3360
  const sessionRules: Ruleset = [sessionAllow("path", "src/*")];
3308
- const result = manager.checkPathPolicy(
3361
+ const result = checkPathValues(
3362
+ manager,
3309
3363
  ["src/App.jsx"],
3310
3364
  undefined,
3311
3365
  sessionRules,
@@ -3322,7 +3376,7 @@ describe("checkPathPolicy", () => {
3322
3376
  path: { "*": "deny" },
3323
3377
  });
3324
3378
  try {
3325
- const result = manager.checkPathPolicy([]);
3379
+ const result = checkPathValues(manager, []);
3326
3380
  expect(result.state).toBe("deny");
3327
3381
  expect(result.matchedPattern).toBe("*");
3328
3382
  } finally {
@@ -3335,7 +3389,8 @@ describe("checkPathPolicy", () => {
3335
3389
  external_directory: { "*": "ask", "/tmp/*": "allow" },
3336
3390
  });
3337
3391
  try {
3338
- const result = manager.checkPathPolicy(
3392
+ const result = checkPathValues(
3393
+ manager,
3339
3394
  ["/tmp/x"],
3340
3395
  undefined,
3341
3396
  undefined,
@@ -3357,7 +3412,7 @@ describe("checkPathPolicy", () => {
3357
3412
  });
3358
3413
  try {
3359
3414
  // No path rule denies; the external_directory allow must NOT apply here.
3360
- const result = manager.checkPathPolicy(["/tmp/x"]);
3415
+ const result = checkPathValues(manager, ["/tmp/x"]);
3361
3416
  expect(result.toolName).toBe("path");
3362
3417
  expect(result.state).toBe("allow");
3363
3418
  expect(result.matchedPattern).toBe("*");
@@ -3366,3 +3421,184 @@ describe("checkPathPolicy", () => {
3366
3421
  }
3367
3422
  });
3368
3423
  });
3424
+
3425
+ // ---------------------------------------------------------------------------
3426
+ // check(intent) — unified entry point (Step 1 of #478)
3427
+ // ---------------------------------------------------------------------------
3428
+
3429
+ describe("check — tool intent", () => {
3430
+ it("resolves a tool call on the bash surface", () => {
3431
+ const { manager, cleanup } = makeManagerWithConfig({
3432
+ bash: { "*": "allow", "git push": "deny" },
3433
+ });
3434
+ try {
3435
+ const intent: ResolvedAccessIntent = {
3436
+ kind: "tool",
3437
+ surface: "bash",
3438
+ input: { command: "git push" },
3439
+ };
3440
+ const result = manager.check(intent);
3441
+ expect(result.state).toBe("deny");
3442
+ expect(result.toolName).toBe("bash");
3443
+ expect(result.source).toBe("bash");
3444
+ } finally {
3445
+ cleanup();
3446
+ }
3447
+ });
3448
+
3449
+ it("resolves a tool call on the read surface", () => {
3450
+ const { manager, cleanup } = makeManagerWithConfig({ read: "deny" });
3451
+ try {
3452
+ const intent: ResolvedAccessIntent = {
3453
+ kind: "tool",
3454
+ surface: "read",
3455
+ input: { path: "/some/file.txt" },
3456
+ };
3457
+ const result = manager.check(intent);
3458
+ expect(result.state).toBe("deny");
3459
+ expect(result.toolName).toBe("read");
3460
+ expect(result.source).toBe("tool");
3461
+ } finally {
3462
+ cleanup();
3463
+ }
3464
+ });
3465
+
3466
+ it("applies session rules via the tool intent", () => {
3467
+ const { manager, cleanup } = makeManagerWithConfig({
3468
+ bash: { "*": "deny" },
3469
+ });
3470
+ try {
3471
+ const sessionRules: Ruleset = [sessionAllow("bash", "echo *")];
3472
+ const intent: ResolvedAccessIntent = {
3473
+ kind: "tool",
3474
+ surface: "bash",
3475
+ input: { command: "echo hello" },
3476
+ };
3477
+ const result = manager.check(intent, sessionRules);
3478
+ expect(result.state).toBe("allow");
3479
+ expect(result.source).toBe("session");
3480
+ } finally {
3481
+ cleanup();
3482
+ }
3483
+ });
3484
+
3485
+ it("threads agentName through the tool intent", () => {
3486
+ const { manager, cleanup } = createManager(
3487
+ { permission: { bash: "deny" } },
3488
+ {
3489
+ "agent-a": `---\nname: agent-a\npermission:\n bash: allow\n---\n`,
3490
+ },
3491
+ );
3492
+ try {
3493
+ const intent: ResolvedAccessIntent = {
3494
+ kind: "tool",
3495
+ surface: "bash",
3496
+ input: { command: "echo hi" },
3497
+ agentName: "agent-a",
3498
+ };
3499
+ const result = manager.check(intent);
3500
+ expect(result.state).toBe("allow");
3501
+ } finally {
3502
+ cleanup();
3503
+ }
3504
+ });
3505
+ });
3506
+
3507
+ describe("check — path-values intent", () => {
3508
+ const cwd = "/workspace/project";
3509
+
3510
+ it("evaluates precomputed policy values against the path surface", () => {
3511
+ const { manager, cleanup } = makeManagerWithConfig({
3512
+ path: { "*": "ask", [`${cwd}/*`]: "allow" },
3513
+ });
3514
+ try {
3515
+ const intent: ResolvedAccessIntent = {
3516
+ kind: "path-values",
3517
+ surface: "path",
3518
+ values: [`${cwd}/src/App.jsx`, "src/App.jsx"],
3519
+ };
3520
+ const result = manager.check(intent);
3521
+ expect(result.state).toBe("allow");
3522
+ expect(result.matchedPattern).toBe(`${cwd}/*`);
3523
+ expect(result.source).toBe("special");
3524
+ expect(result.toolName).toBe("path");
3525
+ } finally {
3526
+ cleanup();
3527
+ }
3528
+ });
3529
+
3530
+ it("evaluates against the external_directory surface", () => {
3531
+ const { manager, cleanup } = makeManagerWithConfig({
3532
+ external_directory: { "*": "ask", "/tmp/*": "allow" },
3533
+ });
3534
+ try {
3535
+ const intent: ResolvedAccessIntent = {
3536
+ kind: "path-values",
3537
+ surface: "external_directory",
3538
+ values: ["/tmp/x"],
3539
+ };
3540
+ const result = manager.check(intent);
3541
+ expect(result.state).toBe("allow");
3542
+ expect(result.matchedPattern).toBe("/tmp/*");
3543
+ expect(result.source).toBe("special");
3544
+ expect(result.toolName).toBe("external_directory");
3545
+ } finally {
3546
+ cleanup();
3547
+ }
3548
+ });
3549
+
3550
+ it("falls back to the catch-all for an empty value list", () => {
3551
+ const { manager, cleanup } = makeManagerWithConfig({
3552
+ path: { "*": "deny" },
3553
+ });
3554
+ try {
3555
+ const intent: ResolvedAccessIntent = {
3556
+ kind: "path-values",
3557
+ surface: "path",
3558
+ values: [],
3559
+ };
3560
+ const result = manager.check(intent);
3561
+ expect(result.state).toBe("deny");
3562
+ expect(result.matchedPattern).toBe("*");
3563
+ } finally {
3564
+ cleanup();
3565
+ }
3566
+ });
3567
+
3568
+ it("applies session rules via the path-values intent", () => {
3569
+ const { manager, cleanup } = makeManagerWithConfig({
3570
+ path: { "*": "ask", "src/*": "deny" },
3571
+ });
3572
+ try {
3573
+ const sessionRules: Ruleset = [sessionAllow("path", "src/*")];
3574
+ const intent: ResolvedAccessIntent = {
3575
+ kind: "path-values",
3576
+ surface: "path",
3577
+ values: ["src/App.jsx"],
3578
+ };
3579
+ const result = manager.check(intent, sessionRules);
3580
+ expect(result.state).toBe("allow");
3581
+ expect(result.source).toBe("session");
3582
+ } finally {
3583
+ cleanup();
3584
+ }
3585
+ });
3586
+
3587
+ it("last-match-wins across the provided aliases", () => {
3588
+ const { manager, cleanup } = makeManagerWithConfig({
3589
+ path: { "*": "ask", [`${cwd}/*`]: "allow", "src/*": "deny" },
3590
+ });
3591
+ try {
3592
+ const intent: ResolvedAccessIntent = {
3593
+ kind: "path-values",
3594
+ surface: "path",
3595
+ values: [`${cwd}/src/App.jsx`, "src/App.jsx"],
3596
+ };
3597
+ const result = manager.check(intent);
3598
+ expect(result.state).toBe("deny");
3599
+ expect(result.matchedPattern).toBe("src/*");
3600
+ } finally {
3601
+ cleanup();
3602
+ }
3603
+ });
3604
+ });