@gotgenes/pi-permission-system 3.10.0 → 4.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.
@@ -31,10 +31,7 @@ import {
31
31
  SUBAGENT_ENV_HINT_KEYS,
32
32
  SUBAGENT_PARENT_SESSION_ENV_KEY,
33
33
  } from "../src/permission-forwarding";
34
- import {
35
- normalizeRawPermission,
36
- PermissionManager,
37
- } from "../src/permission-manager";
34
+ import { PermissionManager } from "../src/permission-manager";
38
35
  import {
39
36
  findSkillPathMatch,
40
37
  parseAllSkillPromptSections,
@@ -46,7 +43,11 @@ import {
46
43
  checkRequestedToolRegistration,
47
44
  getToolNameFromValue,
48
45
  } from "../src/tool-registry";
49
- import type { PermissionState, ScopeConfig } from "../src/types";
46
+ import type {
47
+ PermissionCheckResult,
48
+ PermissionState,
49
+ ScopeConfig,
50
+ } from "../src/types";
50
51
  import {
51
52
  canResolveAskPermissionRequest,
52
53
  shouldAutoApprovePermissionState,
@@ -514,20 +515,7 @@ test("Before-agent-start cache dedupes unchanged active-tool exposure and prompt
514
515
 
515
516
  test("Before-agent-start prompt cache invalidates on permission changes while runtime enforcement stays authoritative", () => {
516
517
  const { manager, globalConfigPath, cleanup } = createManager({
517
- defaultPolicy: {
518
- tools: "allow",
519
- bash: "allow",
520
- mcp: "allow",
521
- skills: "allow",
522
- special: "allow",
523
- },
524
- tools: {
525
- write: "deny",
526
- },
527
- bash: {},
528
- mcp: {},
529
- skills: {},
530
- special: {},
518
+ permission: { "*": "allow", write: "deny" },
531
519
  });
532
520
 
533
521
  try {
@@ -547,22 +535,7 @@ test("Before-agent-start prompt cache invalidates on permission changes while ru
547
535
  assert.equal(manager.checkPermission("write", {}, undefined).state, "deny");
548
536
 
549
537
  const updatedConfig = `${JSON.stringify(
550
- {
551
- defaultPolicy: {
552
- tools: "allow",
553
- bash: "allow",
554
- mcp: "allow",
555
- skills: "allow",
556
- special: "allow",
557
- },
558
- tools: {
559
- write: "allow",
560
- },
561
- bash: {},
562
- mcp: {},
563
- skills: {},
564
- special: {},
565
- },
538
+ { permission: { "*": "allow", write: "allow" } },
566
539
  null,
567
540
  2,
568
541
  )}\n`;
@@ -633,10 +606,6 @@ test("Permission-system logger respects debug toggle and keeps review log enable
633
606
  assert.equal(reviewWarning, undefined);
634
607
  assert.equal(existsSync(debugLogPath), false);
635
608
  assert.equal(existsSync(reviewLogPath), true);
636
- assert.match(
637
- readFileSync(reviewLogPath, "utf8"),
638
- /permission_request\.waiting/,
639
- );
640
609
 
641
610
  config.debugLog = true;
642
611
  const enabledDebugWarning = logger.debug("debug.enabled", { sample: true });
@@ -650,16 +619,7 @@ test("Permission-system logger respects debug toggle and keeps review log enable
650
619
 
651
620
  test("PermissionManager canonical built-in permission checking", () => {
652
621
  const { manager, cleanup } = createManager({
653
- defaultPolicy: {
654
- tools: "deny",
655
- bash: "ask",
656
- mcp: "ask",
657
- skills: "ask",
658
- special: "ask",
659
- },
660
- tools: {
661
- read: "allow",
662
- },
622
+ permission: { "*": "deny", read: "allow" },
663
623
  });
664
624
 
665
625
  try {
@@ -675,49 +635,26 @@ test("PermissionManager canonical built-in permission checking", () => {
675
635
  }
676
636
  });
677
637
 
678
- test("Bash patterns stay higher priority than tool-level bash fallback", () => {
679
- const { manager, cleanup } = createManager(
680
- {
681
- defaultPolicy: {
682
- tools: "ask",
683
- bash: "ask",
684
- mcp: "ask",
685
- skills: "ask",
686
- special: "ask",
687
- },
688
- bash: {
689
- "rm -rf *": "deny",
690
- },
691
- },
692
- {
693
- reviewer: `---
694
- name: reviewer
695
- permission:
696
- tools:
697
- bash: allow
698
- ---
699
- `,
638
+ test("Bash specific deny patterns override catch-all within the same config", () => {
639
+ // In the flat format, patterns within a surface map are ordered by insertion.
640
+ // Last-match-wins means specific patterns placed AFTER the catch-all override it.
641
+ const { manager, cleanup } = createManager({
642
+ permission: {
643
+ "*": "ask",
644
+ bash: { "*": "allow", "rm -rf *": "deny" },
700
645
  },
701
- );
646
+ });
702
647
 
703
648
  try {
704
- const denied = manager.checkPermission(
705
- "bash",
706
- { command: "rm -rf build" },
707
- "reviewer",
708
- );
649
+ const denied = manager.checkPermission("bash", { command: "rm -rf build" });
709
650
  assert.equal(denied.state, "deny");
710
651
  assert.equal(denied.source, "bash");
711
652
  assert.equal(denied.matchedPattern, "rm -rf *");
712
653
 
713
- const fallback = manager.checkPermission(
714
- "bash",
715
- { command: "echo hello" },
716
- "reviewer",
717
- );
718
- assert.equal(fallback.state, "allow");
719
- assert.equal(fallback.source, "bash");
720
- assert.equal(fallback.matchedPattern, undefined);
654
+ const allowed = manager.checkPermission("bash", { command: "echo hello" });
655
+ assert.equal(allowed.state, "allow");
656
+ assert.equal(allowed.source, "bash");
657
+ assert.equal(allowed.matchedPattern, "*");
721
658
  } finally {
722
659
  cleanup();
723
660
  }
@@ -725,17 +662,9 @@ permission:
725
662
 
726
663
  test("MCP wildcard matching uses the registered mcp tool", () => {
727
664
  const { manager, cleanup } = createManager({
728
- defaultPolicy: {
729
- tools: "ask",
730
- bash: "ask",
731
- mcp: "ask",
732
- skills: "ask",
733
- special: "ask",
734
- },
735
- mcp: {
736
- "*": "deny",
737
- "research_*": "ask",
738
- "research_query-*": "allow",
665
+ permission: {
666
+ "*": "ask",
667
+ mcp: { "*": "deny", "research_*": "ask", "research_query-*": "allow" },
739
668
  },
740
669
  });
741
670
 
@@ -748,12 +677,12 @@ test("MCP wildcard matching uses the registered mcp tool", () => {
748
677
  assert.equal(queryDocs.matchedPattern, "research_query-*");
749
678
  assert.equal(queryDocs.target, "research_query-docs");
750
679
 
751
- const resolve = manager.checkPermission("mcp", {
680
+ const resolve2 = manager.checkPermission("mcp", {
752
681
  tool: "research:resolve-context",
753
682
  });
754
- assert.equal(resolve.state, "ask");
755
- assert.equal(resolve.matchedPattern, "research_*");
756
- assert.equal(resolve.target, "research_resolve-context");
683
+ assert.equal(resolve2.state, "ask");
684
+ assert.equal(resolve2.matchedPattern, "research_*");
685
+ assert.equal(resolve2.target, "research_resolve-context");
757
686
 
758
687
  const unknown = manager.checkPermission("mcp", { tool: "search:provider" });
759
688
  assert.equal(unknown.state, "deny");
@@ -766,18 +695,10 @@ test("MCP wildcard matching uses the registered mcp tool", () => {
766
695
 
767
696
  test("Arbitrary extension tools use exact-name tool permissions instead of MCP fallback", () => {
768
697
  const { manager, cleanup } = createManager({
769
- defaultPolicy: {
770
- tools: "deny",
771
- bash: "ask",
772
- mcp: "allow",
773
- skills: "ask",
774
- special: "ask",
775
- },
776
- tools: {
777
- third_party_tool: "allow",
778
- },
779
- mcp: {
698
+ permission: {
780
699
  "*": "deny",
700
+ third_party_tool: "allow",
701
+ mcp: { "*": "deny" },
781
702
  },
782
703
  });
783
704
 
@@ -786,6 +707,8 @@ test("Arbitrary extension tools use exact-name tool permissions instead of MCP f
786
707
  assert.equal(allowed.state, "allow");
787
708
  assert.equal(allowed.source, "tool");
788
709
 
710
+ // another_extension_tool has no explicit rule — falls through to the
711
+ // universal default (permission["*"] = "deny") with source "default".
789
712
  const fallback = manager.checkPermission("another_extension_tool", {});
790
713
  assert.equal(fallback.state, "deny");
791
714
  assert.equal(fallback.source, "default");
@@ -796,17 +719,13 @@ test("Arbitrary extension tools use exact-name tool permissions instead of MCP f
796
719
 
797
720
  test("Skill permission matching", () => {
798
721
  const { manager, cleanup } = createManager({
799
- defaultPolicy: {
800
- tools: "ask",
801
- bash: "ask",
802
- mcp: "ask",
803
- skills: "ask",
804
- special: "ask",
805
- },
806
- skills: {
722
+ permission: {
807
723
  "*": "ask",
808
- "web-*": "deny",
809
- "requesting-code-review": "allow",
724
+ skill: {
725
+ "*": "ask",
726
+ "web-*": "deny",
727
+ "requesting-code-review": "allow",
728
+ },
810
729
  },
811
730
  });
812
731
 
@@ -837,16 +756,9 @@ test("Skill permission matching", () => {
837
756
  test("MCP proxy tool infers server-prefixed aliases from configured server names", () => {
838
757
  const { manager, cleanup } = createManager(
839
758
  {
840
- defaultPolicy: {
841
- tools: "ask",
842
- bash: "ask",
843
- mcp: "ask",
844
- skills: "ask",
845
- special: "ask",
846
- },
847
- mcp: {
848
- "exa_*": "deny",
849
- exa_get_code_context_exa: "allow",
759
+ permission: {
760
+ "*": "ask",
761
+ mcp: { "exa_*": "deny", exa_get_code_context_exa: "allow" },
850
762
  },
851
763
  },
852
764
  {},
@@ -876,25 +788,8 @@ test("MCP server names in settings.json are not used — only mcp.json is consul
876
788
  const agentsDir = join(baseDir, "agents");
877
789
  mkdirSync(agentsDir, { recursive: true });
878
790
 
879
- // Policy: allow any target prefixed with legacy-server, default mcp is ask.
880
- // If legacy-server were known as a configured server name, a tool named
881
- // "some_tool_legacy-server" would derive "legacy-server_some_tool_legacy-server"
882
- // which matches this rule and returns "allow".
883
- // After the fix, settings.json is ignored, so no server name is derived and the
884
- // result falls through to the default mcp policy ("ask").
885
791
  const config: ScopeConfig = {
886
- defaultPolicy: {
887
- tools: "ask",
888
- bash: "ask",
889
- mcp: "ask",
890
- skills: "ask",
891
- special: "ask",
892
- },
893
- tools: {},
894
- bash: {},
895
- mcp: { "legacy-server_*": "allow" },
896
- skills: {},
897
- special: {},
792
+ permission: { "*": "ask", mcp: { "legacy-server_*": "allow" } },
898
793
  };
899
794
 
900
795
  writeFileSync(
@@ -902,9 +797,7 @@ test("MCP server names in settings.json are not used — only mcp.json is consul
902
797
  `${JSON.stringify(config, null, 2)}\n`,
903
798
  "utf8",
904
799
  );
905
- // mcp.json does not know about legacy-server.
906
800
  writeFileSync(mcpConfigPath, JSON.stringify({ mcpServers: {} }), "utf8");
907
- // settings.json has legacy-server — the legacy source that must now be ignored.
908
801
  writeFileSync(
909
802
  settingsJsonPath,
910
803
  JSON.stringify({ mcpServers: { "legacy-server": {} } }),
@@ -918,8 +811,6 @@ test("MCP server names in settings.json are not used — only mcp.json is consul
918
811
  });
919
812
 
920
813
  try {
921
- // "legacy-server" must not be derived from settings.json.
922
- // The bare tool name falls through to the default mcp policy → "ask".
923
814
  const result = manager.checkPermission("mcp", {
924
815
  tool: "some_tool_legacy-server",
925
816
  });
@@ -932,16 +823,9 @@ test("MCP server names in settings.json are not used — only mcp.json is consul
932
823
  test("MCP describe mode normalizes qualified tool names without duplicating server prefixes", () => {
933
824
  const { manager, cleanup } = createManager(
934
825
  {
935
- defaultPolicy: {
936
- tools: "ask",
937
- bash: "ask",
938
- mcp: "ask",
939
- skills: "ask",
940
- special: "ask",
941
- },
942
- mcp: {
943
- "exa_*": "deny",
944
- exa_web_search_exa: "allow",
826
+ permission: {
827
+ "*": "ask",
828
+ mcp: { "exa_*": "deny", exa_web_search_exa: "allow" },
945
829
  },
946
830
  },
947
831
  {},
@@ -966,17 +850,7 @@ test("MCP describe mode normalizes qualified tool names without duplicating serv
966
850
 
967
851
  test("Canonical tools map directly without legacy aliases", () => {
968
852
  const { manager, cleanup } = createManager({
969
- defaultPolicy: {
970
- tools: "ask",
971
- bash: "ask",
972
- mcp: "ask",
973
- skills: "ask",
974
- special: "ask",
975
- },
976
- tools: {
977
- find: "allow",
978
- ls: "deny",
979
- },
853
+ permission: { "*": "ask", find: "allow", ls: "deny" },
980
854
  });
981
855
 
982
856
  try {
@@ -992,23 +866,16 @@ test("Canonical tools map directly without legacy aliases", () => {
992
866
  }
993
867
  });
994
868
 
995
- test("tools.mcp acts as fallback allow for unmatched MCP targets", () => {
869
+ test("mcp catch-all acts as fallback for unmatched MCP targets", () => {
996
870
  const { manager, cleanup } = createManager(
997
871
  {
998
- defaultPolicy: {
999
- tools: "ask",
1000
- bash: "ask",
1001
- mcp: "ask",
1002
- skills: "ask",
1003
- special: "ask",
1004
- },
872
+ permission: { "*": "ask" },
1005
873
  },
1006
874
  {
1007
875
  reviewer: `---
1008
876
  name: reviewer
1009
877
  permission:
1010
- tools:
1011
- mcp: allow
878
+ mcp: allow
1012
879
  ---
1013
880
  `,
1014
881
  },
@@ -1021,31 +888,24 @@ permission:
1021
888
  "reviewer",
1022
889
  );
1023
890
  assert.equal(result.state, "allow");
1024
- assert.equal(result.source, "tool");
891
+ assert.equal(result.source, "mcp");
1025
892
  assert.equal(result.target, "exa_web_search_exa");
1026
893
  } finally {
1027
894
  cleanup();
1028
895
  }
1029
896
  });
1030
897
 
1031
- test("specific MCP rules override tools.mcp fallback", () => {
898
+ test("specific MCP rules override mcp catch-all", () => {
1032
899
  const { manager, cleanup } = createManager(
1033
900
  {
1034
- defaultPolicy: {
1035
- tools: "ask",
1036
- bash: "ask",
1037
- mcp: "ask",
1038
- skills: "ask",
1039
- special: "ask",
1040
- },
901
+ permission: { "*": "ask" },
1041
902
  },
1042
903
  {
1043
904
  reviewer: `---
1044
905
  name: reviewer
1045
906
  permission:
1046
- tools:
1047
- mcp: allow
1048
907
  mcp:
908
+ "*": allow
1049
909
  exa_web_search_exa: deny
1050
910
  ---
1051
911
  `,
@@ -1070,24 +930,17 @@ permission:
1070
930
  }
1071
931
  });
1072
932
 
1073
- test("specific MCP rules still win when tools.mcp is deny", () => {
933
+ test("specific MCP rules still win when mcp catch-all is deny", () => {
1074
934
  const { manager, cleanup } = createManager(
1075
935
  {
1076
- defaultPolicy: {
1077
- tools: "ask",
1078
- bash: "ask",
1079
- mcp: "ask",
1080
- skills: "ask",
1081
- special: "ask",
1082
- },
936
+ permission: { "*": "ask" },
1083
937
  },
1084
938
  {
1085
939
  reviewer: `---
1086
940
  name: reviewer
1087
941
  permission:
1088
- tools:
1089
- mcp: deny
1090
942
  mcp:
943
+ "*": deny
1091
944
  exa_web_search_exa: allow
1092
945
  ---
1093
946
  `,
@@ -1114,30 +967,23 @@ permission:
1114
967
  "reviewer",
1115
968
  );
1116
969
  assert.equal(fallback.state, "deny");
1117
- assert.equal(fallback.source, "tool");
970
+ assert.equal(fallback.source, "mcp");
1118
971
  assert.equal(fallback.target, "exa_other_exa");
1119
972
  } finally {
1120
973
  cleanup();
1121
974
  }
1122
975
  });
1123
976
 
1124
- test("partial agent defaultPolicy overrides preserve global defaults", () => {
977
+ test("mcp catch-all in agent frontmatter overrides global default", () => {
1125
978
  const { manager, cleanup } = createManager(
1126
979
  {
1127
- defaultPolicy: {
1128
- tools: "deny",
1129
- bash: "deny",
1130
- mcp: "deny",
1131
- skills: "deny",
1132
- special: "deny",
1133
- },
980
+ permission: { "*": "deny" },
1134
981
  },
1135
982
  {
1136
983
  reviewer: `---
1137
984
  name: reviewer
1138
985
  permission:
1139
- defaultPolicy:
1140
- mcp: allow
986
+ mcp: allow
1141
987
  ---
1142
988
  `,
1143
989
  },
@@ -1154,7 +1000,7 @@ permission:
1154
1000
  "reviewer",
1155
1001
  );
1156
1002
  assert.equal(mcpResult.state, "allow");
1157
- assert.equal(mcpResult.source, "default");
1003
+ assert.equal(mcpResult.source, "mcp");
1158
1004
  } finally {
1159
1005
  cleanup();
1160
1006
  }
@@ -1163,13 +1009,7 @@ permission:
1163
1009
  test("Agent frontmatter canonical tools resolve correctly", () => {
1164
1010
  const { manager, cleanup } = createManager(
1165
1011
  {
1166
- defaultPolicy: {
1167
- tools: "deny",
1168
- bash: "ask",
1169
- mcp: "ask",
1170
- skills: "ask",
1171
- special: "ask",
1172
- },
1012
+ permission: { "*": "deny" },
1173
1013
  },
1174
1014
  {
1175
1015
  reviewer: `---
@@ -1195,16 +1035,10 @@ permission:
1195
1035
  }
1196
1036
  });
1197
1037
 
1198
- test("Only canonical built-ins support top-level shorthand in agent frontmatter", () => {
1038
+ test("All surface names work in agent frontmatter flat permission format", () => {
1199
1039
  const { manager, cleanup } = createManager(
1200
1040
  {
1201
- defaultPolicy: {
1202
- tools: "deny",
1203
- bash: "ask",
1204
- mcp: "deny",
1205
- skills: "ask",
1206
- special: "ask",
1207
- },
1041
+ permission: { "*": "deny" },
1208
1042
  },
1209
1043
  {
1210
1044
  reviewer: `---
@@ -1223,17 +1057,18 @@ permission:
1223
1057
  assert.equal(findResult.state, "allow");
1224
1058
  assert.equal(findResult.source, "tool");
1225
1059
 
1060
+ // In flat format any surface key works, including extension tools
1226
1061
  const taskResult = manager.checkPermission("task", {}, "reviewer");
1227
- assert.equal(taskResult.state, "deny");
1228
- assert.equal(taskResult.source, "default");
1062
+ assert.equal(taskResult.state, "allow");
1063
+ assert.equal(taskResult.source, "tool");
1229
1064
 
1065
+ // mcp: allow catches all MCP targets
1230
1066
  const mcpResult = manager.checkPermission(
1231
1067
  "mcp",
1232
1068
  { tool: "exa:web_search_exa" },
1233
1069
  "reviewer",
1234
1070
  );
1235
- assert.equal(mcpResult.state, "deny");
1236
- assert.equal(mcpResult.source, "default");
1071
+ assert.equal(mcpResult.state, "allow");
1237
1072
  } finally {
1238
1073
  cleanup();
1239
1074
  }
@@ -1241,16 +1076,7 @@ permission:
1241
1076
 
1242
1077
  test("task uses exact-name tool permissions like any registered extension tool", () => {
1243
1078
  const { manager, cleanup } = createManager({
1244
- defaultPolicy: {
1245
- tools: "deny",
1246
- bash: "ask",
1247
- mcp: "allow",
1248
- skills: "ask",
1249
- special: "ask",
1250
- },
1251
- tools: {
1252
- task: "allow",
1253
- },
1079
+ permission: { "*": "deny", task: "allow" },
1254
1080
  });
1255
1081
 
1256
1082
  try {
@@ -1303,22 +1129,15 @@ test("Tool registry blocks unregistered tools and handles aliases", () => {
1303
1129
  test("getToolPermission returns tool-level policy for canonical and extension tools", () => {
1304
1130
  const { manager, cleanup } = createManager(
1305
1131
  {
1306
- defaultPolicy: {
1307
- tools: "ask",
1308
- bash: "ask",
1309
- mcp: "ask",
1310
- skills: "ask",
1311
- special: "ask",
1312
- },
1132
+ permission: { "*": "ask" },
1313
1133
  },
1314
1134
  {
1315
1135
  reviewer: `---
1316
1136
  name: reviewer
1317
1137
  permission:
1318
- tools:
1319
- bash: deny
1320
- read: deny
1321
- task: allow
1138
+ bash: deny
1139
+ read: deny
1140
+ task: allow
1322
1141
  ---
1323
1142
  `,
1324
1143
  },
@@ -1338,16 +1157,7 @@ permission:
1338
1157
  assert.equal(defaultBashPermission, "ask");
1339
1158
 
1340
1159
  const { manager: manager2, cleanup: cleanup2 } = createManager({
1341
- defaultPolicy: {
1342
- tools: "deny",
1343
- bash: "ask",
1344
- mcp: "ask",
1345
- skills: "ask",
1346
- special: "ask",
1347
- },
1348
- tools: {
1349
- bash: "allow",
1350
- },
1160
+ permission: { "*": "deny", bash: "allow" },
1351
1161
  });
1352
1162
 
1353
1163
  try {
@@ -1363,16 +1173,7 @@ permission:
1363
1173
 
1364
1174
  test("getToolPermission supports arbitrary extension tool names", () => {
1365
1175
  const { manager, cleanup } = createManager({
1366
- defaultPolicy: {
1367
- tools: "deny",
1368
- bash: "ask",
1369
- mcp: "allow",
1370
- skills: "ask",
1371
- special: "ask",
1372
- },
1373
- tools: {
1374
- third_party_tool: "allow",
1375
- },
1176
+ permission: { "*": "deny", third_party_tool: "allow" },
1376
1177
  });
1377
1178
 
1378
1179
  try {
@@ -1481,6 +1282,10 @@ test("Permission forwarding rejects unresolved sentinel session ids", () => {
1481
1282
  assert.equal(targetSessionId, null);
1482
1283
  });
1483
1284
 
1285
+ // ---------------------------------------------------------------------------
1286
+ // Project-level and per-agent config scope tests
1287
+ // ---------------------------------------------------------------------------
1288
+
1484
1289
  type CreateManagerWithProjectOptions = CreateManagerOptions & {
1485
1290
  projectConfig?: ScopeConfig;
1486
1291
  projectAgentFiles?: Record<string, string>;
@@ -1545,23 +1350,15 @@ function createManagerWithProject(
1545
1350
  test("Project-level config overrides base bash patterns", () => {
1546
1351
  const { manager, cleanup } = createManagerWithProject(
1547
1352
  {
1548
- defaultPolicy: {
1549
- tools: "allow",
1550
- bash: "ask",
1551
- mcp: "ask",
1552
- skills: "ask",
1553
- special: "ask",
1554
- },
1555
- bash: {
1556
- "rm -rf *": "deny",
1353
+ permission: {
1354
+ "*": "allow",
1355
+ bash: { "*": "ask", "rm -rf *": "deny" },
1557
1356
  },
1558
1357
  },
1559
1358
  {},
1560
1359
  {
1561
1360
  projectConfig: {
1562
- bash: {
1563
- "rm -rf build": "allow",
1564
- },
1361
+ permission: { bash: { "rm -rf build": "allow" } },
1565
1362
  },
1566
1363
  },
1567
1364
  );
@@ -1586,13 +1383,7 @@ test("Project-level config overrides base bash patterns", () => {
1586
1383
  test("System-agent config overrides project-level bash patterns", () => {
1587
1384
  const { manager, cleanup } = createManagerWithProject(
1588
1385
  {
1589
- defaultPolicy: {
1590
- tools: "allow",
1591
- bash: "ask",
1592
- mcp: "ask",
1593
- skills: "ask",
1594
- special: "ask",
1595
- },
1386
+ permission: { "*": "allow", bash: "ask" },
1596
1387
  },
1597
1388
  {
1598
1389
  reviewer: `---
@@ -1605,9 +1396,7 @@ permission:
1605
1396
  },
1606
1397
  {
1607
1398
  projectConfig: {
1608
- bash: {
1609
- "git *": "deny",
1610
- },
1399
+ permission: { bash: { "git *": "deny" } },
1611
1400
  },
1612
1401
  },
1613
1402
  );
@@ -1636,20 +1425,13 @@ permission:
1636
1425
  test("Project-agent config overrides system-agent tool rules", () => {
1637
1426
  const { manager, cleanup } = createManagerWithProject(
1638
1427
  {
1639
- defaultPolicy: {
1640
- tools: "ask",
1641
- bash: "ask",
1642
- mcp: "ask",
1643
- skills: "ask",
1644
- special: "ask",
1645
- },
1428
+ permission: { "*": "ask" },
1646
1429
  },
1647
1430
  {
1648
1431
  reviewer: `---
1649
1432
  name: reviewer
1650
1433
  permission:
1651
- tools:
1652
- read: deny
1434
+ read: deny
1653
1435
  ---
1654
1436
  `,
1655
1437
  },
@@ -1658,8 +1440,7 @@ permission:
1658
1440
  reviewer: `---
1659
1441
  name: reviewer
1660
1442
  permission:
1661
- tools:
1662
- read: allow
1443
+ read: allow
1663
1444
  ---
1664
1445
  `,
1665
1446
  },
@@ -1675,38 +1456,28 @@ permission:
1675
1456
  }
1676
1457
  });
1677
1458
 
1678
- test("Full precedence chain base < project < system-agent < project-agent for defaultPolicy", () => {
1459
+ test("Full precedence chain base < project < system-agent < project-agent for universal default", () => {
1679
1460
  const { manager, cleanup } = createManagerWithProject(
1680
1461
  {
1681
- defaultPolicy: {
1682
- tools: "deny",
1683
- bash: "ask",
1684
- mcp: "ask",
1685
- skills: "ask",
1686
- special: "ask",
1687
- },
1462
+ permission: { "*": "deny" },
1688
1463
  },
1689
1464
  {
1690
1465
  reviewer: `---
1691
1466
  name: reviewer
1692
1467
  permission:
1693
- defaultPolicy:
1694
- tools: ask
1468
+ "*": ask
1695
1469
  ---
1696
1470
  `,
1697
1471
  },
1698
1472
  {
1699
1473
  projectConfig: {
1700
- defaultPolicy: {
1701
- tools: "allow",
1702
- },
1474
+ permission: { "*": "allow" },
1703
1475
  },
1704
1476
  projectAgentFiles: {
1705
1477
  reviewer: `---
1706
1478
  name: reviewer
1707
1479
  permission:
1708
- defaultPolicy:
1709
- tools: deny
1480
+ "*": deny
1710
1481
  ---
1711
1482
  `,
1712
1483
  },
@@ -1733,13 +1504,7 @@ permission:
1733
1504
  test("Project-agent applies even without a matching system-agent file", () => {
1734
1505
  const { manager, cleanup } = createManagerWithProject(
1735
1506
  {
1736
- defaultPolicy: {
1737
- tools: "allow",
1738
- bash: "ask",
1739
- mcp: "ask",
1740
- skills: "ask",
1741
- special: "ask",
1742
- },
1507
+ permission: { "*": "allow" },
1743
1508
  },
1744
1509
  {},
1745
1510
  {
@@ -1747,8 +1512,7 @@ test("Project-agent applies even without a matching system-agent file", () => {
1747
1512
  reviewer: `---
1748
1513
  name: reviewer
1749
1514
  permission:
1750
- tools:
1751
- read: deny
1515
+ read: deny
1752
1516
  ---
1753
1517
  `,
1754
1518
  },
@@ -1780,18 +1544,7 @@ test("PermissionManager reads config from PI_CODING_AGENT_DIR when set", () => {
1780
1544
  mkdirSync(dirname(newConfigPath), { recursive: true });
1781
1545
 
1782
1546
  const config: ScopeConfig = {
1783
- defaultPolicy: {
1784
- tools: "deny",
1785
- bash: "deny",
1786
- mcp: "deny",
1787
- skills: "deny",
1788
- special: "deny",
1789
- },
1790
- tools: { read: "allow" },
1791
- bash: {},
1792
- mcp: {},
1793
- skills: {},
1794
- special: {},
1547
+ permission: { "*": "deny", read: "allow" },
1795
1548
  };
1796
1549
  writeFileSync(newConfigPath, JSON.stringify(config), "utf8");
1797
1550
 
@@ -1848,15 +1601,9 @@ test("parseAllSkillPromptSections finds every available_skills block", () => {
1848
1601
 
1849
1602
  test("REGRESSION: resolveSkillPromptEntries sanitizes every available_skills block", () => {
1850
1603
  const { manager, cleanup } = createManager({
1851
- defaultPolicy: {
1852
- tools: "ask",
1853
- bash: "ask",
1854
- mcp: "ask",
1855
- skills: "ask",
1856
- special: "ask",
1857
- },
1858
- skills: {
1859
- "denied-skill": "deny",
1604
+ permission: {
1605
+ "*": "ask",
1606
+ skill: { "denied-skill": "deny" },
1860
1607
  },
1861
1608
  });
1862
1609
 
@@ -1915,15 +1662,9 @@ test("REGRESSION: resolveSkillPromptEntries sanitizes every available_skills blo
1915
1662
 
1916
1663
  test("REGRESSION: resolveSkillPromptEntries keeps only visible skills available for path matching", () => {
1917
1664
  const { manager, cleanup } = createManager({
1918
- defaultPolicy: {
1919
- tools: "ask",
1920
- bash: "ask",
1921
- mcp: "ask",
1922
- skills: "ask",
1923
- special: "ask",
1924
- },
1925
- skills: {
1926
- "blocked-skill": "deny",
1665
+ permission: {
1666
+ "*": "ask",
1667
+ skill: { "blocked-skill": "deny" },
1927
1668
  },
1928
1669
  });
1929
1670
 
@@ -1975,16 +1716,9 @@ test("REGRESSION: resolveSkillPromptEntries keeps only visible skills available
1975
1716
  // external_directory special permission
1976
1717
  // ---------------------------------------------------------------------------
1977
1718
 
1978
- test("external_directory permission falls back to special default policy when not explicitly configured", () => {
1979
- const { manager, cleanup } = createManager({
1980
- defaultPolicy: {
1981
- tools: "allow",
1982
- bash: "allow",
1983
- mcp: "allow",
1984
- skills: "allow",
1985
- special: "ask",
1986
- },
1987
- });
1719
+ test("external_directory permission falls back to universal default when not explicitly configured", () => {
1720
+ // Empty permission: everything defaults to "ask" (least privilege).
1721
+ const { manager, cleanup } = createManager({ permission: {} });
1988
1722
 
1989
1723
  try {
1990
1724
  const result = manager.checkPermission("external_directory", {});
@@ -1996,25 +1730,16 @@ test("external_directory permission falls back to special default policy when no
1996
1730
  }
1997
1731
  });
1998
1732
 
1999
- test("external_directory permission respects explicit deny in special config", () => {
1733
+ test("external_directory permission respects explicit deny", () => {
2000
1734
  const { manager, cleanup } = createManager({
2001
- defaultPolicy: {
2002
- tools: "allow",
2003
- bash: "allow",
2004
- mcp: "allow",
2005
- skills: "allow",
2006
- special: "ask",
2007
- },
2008
- special: {
2009
- external_directory: "deny",
2010
- },
1735
+ permission: { "*": "allow", external_directory: "deny" },
2011
1736
  });
2012
1737
 
2013
1738
  try {
2014
1739
  const result = manager.checkPermission("external_directory", {});
2015
1740
  assert.equal(result.state, "deny");
2016
1741
  assert.equal(result.source, "special");
2017
- assert.equal(result.matchedPattern, "external_directory");
1742
+ assert.equal(result.matchedPattern, "*");
2018
1743
  } finally {
2019
1744
  cleanup();
2020
1745
  }
@@ -2022,23 +1747,14 @@ test("external_directory permission respects explicit deny in special config", (
2022
1747
 
2023
1748
  test("external_directory permission can be explicitly allowed", () => {
2024
1749
  const { manager, cleanup } = createManager({
2025
- defaultPolicy: {
2026
- tools: "allow",
2027
- bash: "allow",
2028
- mcp: "allow",
2029
- skills: "allow",
2030
- special: "deny",
2031
- },
2032
- special: {
2033
- external_directory: "allow",
2034
- },
1750
+ permission: { "*": "allow", external_directory: "allow" },
2035
1751
  });
2036
1752
 
2037
1753
  try {
2038
1754
  const result = manager.checkPermission("external_directory", {});
2039
1755
  assert.equal(result.state, "allow");
2040
1756
  assert.equal(result.source, "special");
2041
- assert.equal(result.matchedPattern, "external_directory");
1757
+ assert.equal(result.matchedPattern, "*");
2042
1758
  } finally {
2043
1759
  cleanup();
2044
1760
  }
@@ -2047,23 +1763,13 @@ test("external_directory permission can be explicitly allowed", () => {
2047
1763
  test("external_directory permission respects per-agent override", () => {
2048
1764
  const { manager, cleanup } = createManager(
2049
1765
  {
2050
- defaultPolicy: {
2051
- tools: "allow",
2052
- bash: "allow",
2053
- mcp: "allow",
2054
- skills: "allow",
2055
- special: "ask",
2056
- },
2057
- special: {
2058
- external_directory: "deny",
2059
- },
1766
+ permission: { "*": "allow", external_directory: "deny" },
2060
1767
  },
2061
1768
  {
2062
1769
  trusted: `---
2063
1770
  name: trusted
2064
1771
  permission:
2065
- special:
2066
- external_directory: allow
1772
+ external_directory: allow
2067
1773
  ---
2068
1774
  `,
2069
1775
  },
@@ -2087,31 +1793,18 @@ permission:
2087
1793
  }
2088
1794
  });
2089
1795
 
2090
- test("external_directory permission is unaffected when doom_loop key is present in config (deprecated and ignored)", () => {
1796
+ test("external_directory permission is not affected by unrelated surface keys", () => {
1797
+ // Flat format: unknown surface keys are just rules for that surface.
1798
+ // external_directory resolves from its own rule, not from unrelated keys.
2091
1799
  const { manager, cleanup } = createManager({
2092
- defaultPolicy: {
2093
- tools: "allow",
2094
- bash: "allow",
2095
- mcp: "allow",
2096
- skills: "allow",
2097
- special: "ask",
2098
- },
2099
- special: {
2100
- doom_loop: "deny",
2101
- external_directory: "allow",
2102
- },
1800
+ permission: { "*": "allow", external_directory: "allow" },
2103
1801
  });
2104
1802
 
2105
1803
  try {
2106
- // doom_loop is deprecated and stripped — falls through to defaultPolicy.tools
2107
- const doomResult = manager.checkPermission("doom_loop", {});
2108
- assert.equal(doomResult.state, "allow"); // defaultPolicy.tools, not the stripped doom_loop: "deny"
2109
- assert.equal(doomResult.matchedPattern, undefined);
2110
-
2111
1804
  // external_directory still resolves from its own entry
2112
1805
  const extResult = manager.checkPermission("external_directory", {});
2113
1806
  assert.equal(extResult.state, "allow");
2114
- assert.equal(extResult.matchedPattern, "external_directory");
1807
+ assert.equal(extResult.matchedPattern, "*");
2115
1808
  } finally {
2116
1809
  cleanup();
2117
1810
  }
@@ -2125,14 +1818,7 @@ test("tool_call blocks path-bearing tools outside cwd when external_directory is
2125
1818
 
2126
1819
  const harness = createToolCallHarness(
2127
1820
  {
2128
- defaultPolicy: {
2129
- tools: "allow",
2130
- bash: "allow",
2131
- mcp: "allow",
2132
- skills: "allow",
2133
- special: "ask",
2134
- },
2135
- special: { external_directory: "deny" },
1821
+ permission: { "*": "allow", external_directory: "deny" },
2136
1822
  },
2137
1823
  ["read"],
2138
1824
  { cwd },
@@ -2160,14 +1846,7 @@ test("tool_call blocks path-bearing tools outside cwd when external_directory is
2160
1846
  test("tool_call allows path-bearing tools inside cwd without external_directory prompt", async () => {
2161
1847
  const harness = createToolCallHarness(
2162
1848
  {
2163
- defaultPolicy: {
2164
- tools: "allow",
2165
- bash: "allow",
2166
- mcp: "allow",
2167
- skills: "allow",
2168
- special: "ask",
2169
- },
2170
- special: { external_directory: "deny" },
1849
+ permission: { "*": "allow", external_directory: "deny" },
2171
1850
  },
2172
1851
  ["read"],
2173
1852
  );
@@ -2189,14 +1868,7 @@ test("tool_call allows path-bearing tools inside cwd without external_directory
2189
1868
  test("tool_call blocks external_directory ask when no confirmation channel is available", async () => {
2190
1869
  const harness = createToolCallHarness(
2191
1870
  {
2192
- defaultPolicy: {
2193
- tools: "allow",
2194
- bash: "allow",
2195
- mcp: "allow",
2196
- skills: "allow",
2197
- special: "ask",
2198
- },
2199
- special: { external_directory: "ask" },
1871
+ permission: { "*": "allow", external_directory: "ask" },
2200
1872
  },
2201
1873
  ["write"],
2202
1874
  );
@@ -2224,14 +1896,7 @@ test("tool_call blocks external_directory ask when no confirmation channel is av
2224
1896
  test("tool_call prompts for external_directory and then falls through to normal tool policy", async () => {
2225
1897
  const harness = createToolCallHarness(
2226
1898
  {
2227
- defaultPolicy: {
2228
- tools: "allow",
2229
- bash: "allow",
2230
- mcp: "allow",
2231
- skills: "allow",
2232
- special: "ask",
2233
- },
2234
- special: { external_directory: "ask" },
1899
+ permission: { "*": "allow", external_directory: "ask" },
2235
1900
  },
2236
1901
  ["grep"],
2237
1902
  );
@@ -2261,14 +1926,7 @@ test("tool_call prompts for external_directory and then falls through to normal
2261
1926
  test("tool_call skips external_directory checks for optional path tools without a path", async () => {
2262
1927
  const harness = createToolCallHarness(
2263
1928
  {
2264
- defaultPolicy: {
2265
- tools: "allow",
2266
- bash: "allow",
2267
- mcp: "allow",
2268
- skills: "allow",
2269
- special: "ask",
2270
- },
2271
- special: { external_directory: "deny" },
1929
+ permission: { "*": "allow", external_directory: "deny" },
2272
1930
  },
2273
1931
  ["find"],
2274
1932
  );
@@ -2292,14 +1950,7 @@ test("tool_call skips external_directory checks for optional path tools without
2292
1950
  test("tool_call blocks bash command with external path when external_directory is denied", async () => {
2293
1951
  const harness = createToolCallHarness(
2294
1952
  {
2295
- defaultPolicy: {
2296
- tools: "allow",
2297
- bash: "allow",
2298
- mcp: "allow",
2299
- skills: "allow",
2300
- special: "ask",
2301
- },
2302
- special: { external_directory: "deny" },
1953
+ permission: { "*": "allow", external_directory: "deny" },
2303
1954
  },
2304
1955
  ["bash"],
2305
1956
  );
@@ -2325,14 +1976,7 @@ test("tool_call blocks bash command with external path when external_directory i
2325
1976
  test("tool_call allows bash command with only internal paths when external_directory is denied", async () => {
2326
1977
  const harness = createToolCallHarness(
2327
1978
  {
2328
- defaultPolicy: {
2329
- tools: "allow",
2330
- bash: "allow",
2331
- mcp: "allow",
2332
- skills: "allow",
2333
- special: "ask",
2334
- },
2335
- special: { external_directory: "deny" },
1979
+ permission: { "*": "allow", external_directory: "deny" },
2336
1980
  },
2337
1981
  ["bash"],
2338
1982
  );
@@ -2353,14 +1997,7 @@ test("tool_call allows bash command with only internal paths when external_direc
2353
1997
  test("tool_call prompts for bash command with external path when external_directory is ask", async () => {
2354
1998
  const harness = createToolCallHarness(
2355
1999
  {
2356
- defaultPolicy: {
2357
- tools: "allow",
2358
- bash: "allow",
2359
- mcp: "allow",
2360
- skills: "allow",
2361
- special: "ask",
2362
- },
2363
- special: { external_directory: "ask" },
2000
+ permission: { "*": "allow", external_directory: "ask" },
2364
2001
  },
2365
2002
  ["bash"],
2366
2003
  );
@@ -2386,14 +2023,7 @@ test("tool_call prompts for bash command with external path when external_direct
2386
2023
  test("tool_call allows bash command with external path when external_directory is allow", async () => {
2387
2024
  const harness = createToolCallHarness(
2388
2025
  {
2389
- defaultPolicy: {
2390
- tools: "allow",
2391
- bash: "allow",
2392
- mcp: "allow",
2393
- skills: "allow",
2394
- special: "ask",
2395
- },
2396
- special: { external_directory: "allow" },
2026
+ permission: { "*": "allow", external_directory: "allow" },
2397
2027
  },
2398
2028
  ["bash"],
2399
2029
  );
@@ -2415,15 +2045,11 @@ test("tool_call allows bash command with external path when external_directory i
2415
2045
  test("tool_call applies bash pattern permissions after external_directory allow", async () => {
2416
2046
  const harness = createToolCallHarness(
2417
2047
  {
2418
- defaultPolicy: {
2419
- tools: "allow",
2420
- bash: "allow",
2421
- mcp: "allow",
2422
- skills: "allow",
2423
- special: "ask",
2048
+ permission: {
2049
+ "*": "allow",
2050
+ external_directory: "allow",
2051
+ bash: { "*": "allow", "cat *": "deny" },
2424
2052
  },
2425
- special: { external_directory: "allow" },
2426
- bash: { "cat *": "deny" },
2427
2053
  },
2428
2054
  ["bash"],
2429
2055
  );
@@ -2446,13 +2072,7 @@ test("tool_call applies bash pattern permissions after external_directory allow"
2446
2072
  test("generic ask prompts include serialized tool input for informed approval", async () => {
2447
2073
  const harness = createToolCallHarness(
2448
2074
  {
2449
- defaultPolicy: {
2450
- tools: "ask",
2451
- bash: "ask",
2452
- mcp: "ask",
2453
- skills: "ask",
2454
- special: "ask",
2455
- },
2075
+ permission: { "*": "ask" },
2456
2076
  },
2457
2077
  ["weather_lookup"],
2458
2078
  );
@@ -2539,86 +2159,11 @@ test("getResolvedPolicyPaths returns false for missing files and null for absent
2539
2159
  }
2540
2160
  });
2541
2161
 
2542
- // --- tool_call_limit deprecation tests (#18) ---
2543
-
2544
- test("normalizeRawPermission emits deprecation issue for special.tool_call_limit (integer)", () => {
2545
- const result = normalizeRawPermission({ special: { tool_call_limit: 5 } });
2546
- assert.equal(result.configIssues.length, 1);
2547
- assert.ok(result.configIssues[0].includes("tool_call_limit"));
2548
- assert.equal(result.permissions.special?.tool_call_limit, undefined);
2549
- });
2550
-
2551
- test("normalizeRawPermission emits deprecation issue for special.tool_call_limit (string)", () => {
2552
- const result = normalizeRawPermission({
2553
- special: { tool_call_limit: "allow" },
2554
- });
2555
- assert.equal(result.configIssues.length, 1);
2556
- assert.ok(result.configIssues[0].includes("tool_call_limit"));
2557
- assert.equal(result.permissions.special?.tool_call_limit, undefined);
2558
- });
2559
-
2560
- test("normalizeRawPermission emits deprecation issue for special.doom_loop (string)", () => {
2561
- const result = normalizeRawPermission({
2562
- special: { doom_loop: "ask" },
2563
- });
2564
- assert.equal(result.configIssues.length, 1);
2565
- assert.ok(result.configIssues[0].includes("doom_loop"));
2566
- assert.equal(result.permissions.special?.doom_loop, undefined);
2567
- });
2568
-
2569
- test("normalizeRawPermission emits deprecation issue for special.doom_loop (deny)", () => {
2570
- const result = normalizeRawPermission({
2571
- special: { doom_loop: "deny" },
2572
- });
2573
- assert.equal(result.configIssues.length, 1);
2574
- assert.ok(result.configIssues[0].includes("doom_loop"));
2575
- assert.equal(result.permissions.special?.doom_loop, undefined);
2576
- });
2577
-
2578
- test("normalizeRawPermission emits no issues when special is absent", () => {
2579
- const result = normalizeRawPermission({ tools: { read: "allow" } });
2580
- assert.equal(result.configIssues.length, 0);
2581
- });
2582
-
2583
- test("PermissionManager.getConfigIssues returns deprecation for tool_call_limit in global config", () => {
2584
- const config: ScopeConfig = {
2585
- defaultPolicy: {
2586
- tools: "ask",
2587
- bash: "ask",
2588
- mcp: "ask",
2589
- skills: "ask",
2590
- special: "ask",
2591
- },
2592
- tools: {},
2593
- bash: {},
2594
- mcp: {},
2595
- skills: {},
2596
- special: { tool_call_limit: "allow" as PermissionState },
2597
- };
2598
- const { manager, cleanup } = createManager(config);
2599
- try {
2600
- const issues = manager.getConfigIssues();
2601
- assert.equal(issues.length, 1);
2602
- assert.ok(issues[0].includes("tool_call_limit"));
2603
- } finally {
2604
- cleanup();
2605
- }
2606
- });
2162
+ // --- config issues tests ---
2607
2163
 
2608
2164
  test("PermissionManager.getConfigIssues returns empty array for clean config", () => {
2609
2165
  const config: ScopeConfig = {
2610
- defaultPolicy: {
2611
- tools: "ask",
2612
- bash: "ask",
2613
- mcp: "ask",
2614
- skills: "ask",
2615
- special: "ask",
2616
- },
2617
- tools: {},
2618
- bash: {},
2619
- mcp: {},
2620
- skills: {},
2621
- special: { external_directory: "ask" },
2166
+ permission: { "*": "ask", external_directory: "ask" },
2622
2167
  };
2623
2168
  const { manager, cleanup } = createManager(config);
2624
2169
  try {
@@ -2629,59 +2174,19 @@ test("PermissionManager.getConfigIssues returns empty array for clean config", (
2629
2174
  }
2630
2175
  });
2631
2176
 
2632
- // --- doom_loop config-loader deprecation tests (#54) ---
2633
-
2634
- test("PermissionManager.getConfigIssues returns deprecation for doom_loop in global config", () => {
2635
- const config: ScopeConfig = {
2636
- defaultPolicy: {
2637
- tools: "ask",
2638
- bash: "ask",
2639
- mcp: "ask",
2640
- skills: "ask",
2641
- special: "ask",
2642
- },
2643
- tools: {},
2644
- bash: {},
2645
- mcp: {},
2646
- skills: {},
2647
- special: { doom_loop: "deny" },
2648
- };
2649
- const { manager, cleanup } = createManager(config);
2177
+ test("PermissionManager.getConfigIssues returns empty array for empty config", () => {
2178
+ const { manager, cleanup } = createManager({});
2650
2179
  try {
2651
2180
  const issues = manager.getConfigIssues();
2652
- assert.equal(issues.length, 1);
2653
- assert.ok(issues[0].includes("doom_loop"));
2654
- } finally {
2655
- cleanup();
2656
- }
2657
- });
2658
-
2659
- test("checkPermission doom_loop falls through to defaultPolicy.tools when stripped by config-loader", () => {
2660
- const { manager, cleanup } = createManager({
2661
- defaultPolicy: {
2662
- tools: "allow",
2663
- bash: "ask",
2664
- mcp: "ask",
2665
- skills: "ask",
2666
- special: "deny",
2667
- },
2668
- tools: {},
2669
- bash: {},
2670
- mcp: {},
2671
- skills: {},
2672
- special: { doom_loop: "ask" },
2673
- });
2674
- try {
2675
- const result = manager.checkPermission("doom_loop", {});
2676
- // doom_loop stripped by config-loader — falls through to defaultPolicy.tools
2677
- assert.equal(result.state, "allow");
2678
- assert.equal(result.matchedPattern, undefined);
2181
+ assert.equal(issues.length, 0);
2679
2182
  } finally {
2680
2183
  cleanup();
2681
2184
  }
2682
2185
  });
2683
2186
 
2684
- // --- session-scoped approval tests (#45) ---
2187
+ // ---------------------------------------------------------------------------
2188
+ // Session-scoped approval tests (#45)
2189
+ // ---------------------------------------------------------------------------
2685
2190
 
2686
2191
  test("session approval: first prompt with 'Yes, for this session' skips subsequent prompts under same prefix", async () => {
2687
2192
  const rootDir = mkdtempSync(join(tmpdir(), "pi-session-approval-"));
@@ -2692,14 +2197,7 @@ test("session approval: first prompt with 'Yes, for this session' skips subseque
2692
2197
 
2693
2198
  const harness = createToolCallHarness(
2694
2199
  {
2695
- defaultPolicy: {
2696
- tools: "allow",
2697
- bash: "allow",
2698
- mcp: "allow",
2699
- skills: "allow",
2700
- special: "ask",
2701
- },
2702
- special: { external_directory: "ask" },
2200
+ permission: { "*": "allow", external_directory: "ask" },
2703
2201
  },
2704
2202
  ["read", "grep"],
2705
2203
  { cwd },
@@ -2762,14 +2260,7 @@ test("session approval: different directory prefix still prompts", async () => {
2762
2260
 
2763
2261
  const harness = createToolCallHarness(
2764
2262
  {
2765
- defaultPolicy: {
2766
- tools: "allow",
2767
- bash: "allow",
2768
- mcp: "allow",
2769
- skills: "allow",
2770
- special: "ask",
2771
- },
2772
- special: { external_directory: "ask" },
2263
+ permission: { "*": "allow", external_directory: "ask" },
2773
2264
  },
2774
2265
  ["read"],
2775
2266
  { cwd },
@@ -2814,14 +2305,7 @@ test("session approval: session_shutdown clears session approvals", async () =>
2814
2305
 
2815
2306
  const harness = createToolCallHarness(
2816
2307
  {
2817
- defaultPolicy: {
2818
- tools: "allow",
2819
- bash: "allow",
2820
- mcp: "allow",
2821
- skills: "allow",
2822
- special: "ask",
2823
- },
2824
- special: { external_directory: "ask" },
2308
+ permission: { "*": "allow", external_directory: "ask" },
2825
2309
  },
2826
2310
  ["read"],
2827
2311
  { cwd },
@@ -2872,14 +2356,7 @@ test("session approval: bash external directory with 'Yes, for this session' ski
2872
2356
 
2873
2357
  const harness = createToolCallHarness(
2874
2358
  {
2875
- defaultPolicy: {
2876
- tools: "allow",
2877
- bash: "allow",
2878
- mcp: "allow",
2879
- skills: "allow",
2880
- special: "ask",
2881
- },
2882
- special: { external_directory: "ask" },
2359
+ permission: { "*": "allow", external_directory: "ask" },
2883
2360
  },
2884
2361
  ["bash"],
2885
2362
  { cwd },
@@ -2927,14 +2404,7 @@ test("session approval: regular 'Yes' does not create session approval", async (
2927
2404
 
2928
2405
  const harness = createToolCallHarness(
2929
2406
  {
2930
- defaultPolicy: {
2931
- tools: "allow",
2932
- bash: "allow",
2933
- mcp: "allow",
2934
- skills: "allow",
2935
- special: "ask",
2936
- },
2937
- special: { external_directory: "ask" },
2407
+ permission: { "*": "allow", external_directory: "ask" },
2938
2408
  },
2939
2409
  ["read"],
2940
2410
  { cwd },
@@ -2969,3 +2439,166 @@ test("session approval: regular 'Yes' does not create session approval", async (
2969
2439
  rmSync(rootDir, { recursive: true, force: true });
2970
2440
  }
2971
2441
  });
2442
+
2443
+ // ---------------------------------------------------------------------------
2444
+ // Session-aware checkPermission() integration
2445
+ // ---------------------------------------------------------------------------
2446
+
2447
+ test("checkPermission returns source 'session' when session rules cover the external_directory path", () => {
2448
+ const { manager, cleanup } = createManager({
2449
+ permission: { "*": "allow" },
2450
+ });
2451
+
2452
+ try {
2453
+ const sessionRules = [
2454
+ {
2455
+ surface: "external_directory",
2456
+ pattern: "/other/project/*",
2457
+ action: "allow" as const,
2458
+ layer: "session" as const,
2459
+ },
2460
+ ];
2461
+
2462
+ const result = manager.checkPermission(
2463
+ "external_directory",
2464
+ { path: "/other/project/src/foo.ts" },
2465
+ undefined,
2466
+ sessionRules,
2467
+ );
2468
+ assert.equal(result.state, "allow");
2469
+ assert.equal(result.source, "session");
2470
+ assert.equal(result.matchedPattern, "/other/project/*");
2471
+ } finally {
2472
+ cleanup();
2473
+ }
2474
+ });
2475
+
2476
+ test("checkPermission falls back to config policy when session rules do not cover the path", () => {
2477
+ const { manager, cleanup } = createManager({
2478
+ permission: { "*": "allow", external_directory: "deny" },
2479
+ });
2480
+
2481
+ try {
2482
+ const sessionRules = [
2483
+ {
2484
+ surface: "external_directory",
2485
+ pattern: "/other/project/*",
2486
+ action: "allow" as const,
2487
+ layer: "session" as const,
2488
+ },
2489
+ ];
2490
+
2491
+ // Path NOT under /other/project/ — session rules don't match.
2492
+ const result = manager.checkPermission(
2493
+ "external_directory",
2494
+ { path: "/completely/different/path.ts" },
2495
+ undefined,
2496
+ sessionRules,
2497
+ );
2498
+ assert.equal(result.state, "deny");
2499
+ assert.equal(result.source, "special");
2500
+ } finally {
2501
+ cleanup();
2502
+ }
2503
+ });
2504
+
2505
+ test("checkPermission with empty session rules is identical to call without sessionRules arg", () => {
2506
+ const { manager, cleanup } = createManager({
2507
+ permission: { "*": "allow", external_directory: "deny" },
2508
+ });
2509
+
2510
+ try {
2511
+ const withEmpty = manager.checkPermission(
2512
+ "external_directory",
2513
+ { path: "/other/project/foo.ts" },
2514
+ undefined,
2515
+ [],
2516
+ );
2517
+ const withoutArg = manager.checkPermission("external_directory", {
2518
+ path: "/other/project/foo.ts",
2519
+ });
2520
+ const expected: PermissionCheckResult = {
2521
+ toolName: "external_directory",
2522
+ state: "deny",
2523
+ matchedPattern: "*",
2524
+ source: "special",
2525
+ };
2526
+ assert.deepEqual(withEmpty, expected);
2527
+ assert.deepEqual(withoutArg, expected);
2528
+ } finally {
2529
+ cleanup();
2530
+ }
2531
+ });
2532
+
2533
+ test("session rules for one surface do not affect checks on other surfaces", () => {
2534
+ const { manager, cleanup } = createManager({
2535
+ // Empty permission: universal default is "ask" from DEFAULT_UNIVERSAL_FALLBACK.
2536
+ permission: {},
2537
+ });
2538
+
2539
+ try {
2540
+ const sessionRules = [
2541
+ {
2542
+ surface: "external_directory",
2543
+ pattern: "/other/project/*",
2544
+ action: "allow" as const,
2545
+ layer: "session" as const,
2546
+ },
2547
+ ];
2548
+
2549
+ // Bash check — session rules should not affect bash decisions.
2550
+ const bashResult = manager.checkPermission(
2551
+ "bash",
2552
+ { command: "git status" },
2553
+ undefined,
2554
+ sessionRules,
2555
+ );
2556
+ assert.equal(bashResult.state, "ask");
2557
+ assert.equal(bashResult.source, "bash");
2558
+
2559
+ // MCP check — session rules should not affect MCP decisions.
2560
+ const mcpResult = manager.checkPermission(
2561
+ "mcp",
2562
+ { tool: "exa:search" },
2563
+ undefined,
2564
+ sessionRules,
2565
+ );
2566
+ assert.equal(mcpResult.state, "ask");
2567
+ assert.equal(mcpResult.source, "default");
2568
+ } finally {
2569
+ cleanup();
2570
+ }
2571
+ });
2572
+
2573
+ test("session rules override config deny for external_directory", () => {
2574
+ const { manager, cleanup } = createManager({
2575
+ permission: { "*": "allow", external_directory: "deny" },
2576
+ });
2577
+
2578
+ try {
2579
+ const sessionRules = [
2580
+ {
2581
+ surface: "external_directory",
2582
+ pattern: "/other/project/*",
2583
+ action: "allow" as const,
2584
+ layer: "session" as const,
2585
+ },
2586
+ ];
2587
+
2588
+ // Session approval overrides config deny for the covered path.
2589
+ const result = manager.checkPermission(
2590
+ "external_directory",
2591
+ { path: "/other/project/src/foo.ts" },
2592
+ undefined,
2593
+ sessionRules,
2594
+ );
2595
+ assert.equal(result.state, "allow");
2596
+ assert.equal(result.source, "session");
2597
+ } finally {
2598
+ cleanup();
2599
+ }
2600
+ });
2601
+
2602
+ // Suppress unused import warning — PermissionState used in type annotations
2603
+ const _unused: PermissionState = "ask";
2604
+ void _unused;