@gotgenes/pi-permission-system 5.4.0 → 5.5.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.
@@ -0,0 +1,350 @@
1
+ import { existsSync, readFileSync, statSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { getAgentDir } from "@mariozechner/pi-coding-agent";
4
+
5
+ import { extractFrontmatter, parseSimpleYamlMap, toRecord } from "./common";
6
+ import {
7
+ loadUnifiedConfig,
8
+ normalizeUnifiedConfig,
9
+ stripJsonComments,
10
+ } from "./config-loader";
11
+ import { getGlobalConfigPath } from "./config-paths";
12
+ import type { ScopeConfig } from "./types";
13
+
14
+ // ---------------------------------------------------------------------------
15
+ // File-stamp helper
16
+ // ---------------------------------------------------------------------------
17
+
18
+ function getFileStamp(path: string): string {
19
+ try {
20
+ return String(statSync(path).mtimeMs);
21
+ } catch {
22
+ return "missing";
23
+ }
24
+ }
25
+
26
+ // ---------------------------------------------------------------------------
27
+ // MCP server-name reading helpers
28
+ // ---------------------------------------------------------------------------
29
+
30
+ function readConfiguredMcpServerNamesFromConfigPath(
31
+ configPath: string,
32
+ ): string[] {
33
+ try {
34
+ const raw = readFileSync(configPath, "utf-8");
35
+ const parsed = JSON.parse(stripJsonComments(raw)) as unknown;
36
+ const root = toRecord(parsed);
37
+ const serverRecord = toRecord(root.mcpServers ?? root["mcp-servers"]);
38
+
39
+ return Object.keys(serverRecord)
40
+ .map((name) => name.trim())
41
+ .filter((name) => name.length > 0);
42
+ } catch {
43
+ return [];
44
+ }
45
+ }
46
+
47
+ function getConfiguredMcpServerNamesFromPaths(
48
+ paths: readonly string[],
49
+ ): string[] {
50
+ const seen = new Set<string>();
51
+
52
+ for (const path of paths) {
53
+ for (const name of readConfiguredMcpServerNamesFromConfigPath(path)) {
54
+ seen.add(name);
55
+ }
56
+ }
57
+
58
+ return [...seen].sort(
59
+ (left, right) => right.length - left.length || left.localeCompare(right),
60
+ );
61
+ }
62
+
63
+ // ---------------------------------------------------------------------------
64
+ // Resolved policy paths
65
+ // ---------------------------------------------------------------------------
66
+
67
+ export interface ResolvedPolicyPaths {
68
+ globalConfigPath: string;
69
+ globalConfigExists: boolean;
70
+ projectConfigPath: string | null;
71
+ projectConfigExists: boolean;
72
+ agentsDir: string;
73
+ agentsDirExists: boolean;
74
+ projectAgentsDir: string | null;
75
+ projectAgentsDirExists: boolean;
76
+ }
77
+
78
+ // ---------------------------------------------------------------------------
79
+ // PolicyLoader interface
80
+ // ---------------------------------------------------------------------------
81
+
82
+ /**
83
+ * Abstraction over file I/O for loading permission policy from disk.
84
+ * Implementations handle caching, path resolution, and config-issue
85
+ * accumulation. `PermissionManager` depends on this interface so that
86
+ * merge + evaluation logic can be tested with an in-memory stub.
87
+ */
88
+ export interface PolicyLoader {
89
+ loadGlobalConfig(): ScopeConfig;
90
+ loadProjectConfig(): ScopeConfig;
91
+ loadAgentConfig(agentName?: string): ScopeConfig;
92
+ loadProjectAgentConfig(agentName?: string): ScopeConfig;
93
+ getConfiguredMcpServerNames(): readonly string[];
94
+ /** Combined mtime stamp for cache invalidation. */
95
+ getCacheStamp(agentName?: string): string;
96
+ /** Accumulated config-parse issues across all loads. */
97
+ getConfigIssues(): string[];
98
+ /** Resolved paths for the /permission-system show command. */
99
+ getResolvedPolicyPaths(): ResolvedPolicyPaths;
100
+ }
101
+
102
+ // ---------------------------------------------------------------------------
103
+ // Default path factories (deferred until call-time, not module scope)
104
+ // ---------------------------------------------------------------------------
105
+
106
+ function defaultGlobalConfigPath(): string {
107
+ return getGlobalConfigPath(getAgentDir());
108
+ }
109
+ function defaultAgentsDir(): string {
110
+ return join(getAgentDir(), "agents");
111
+ }
112
+ function defaultGlobalMcpConfigPath(): string {
113
+ return join(getAgentDir(), "mcp.json");
114
+ }
115
+
116
+ // ---------------------------------------------------------------------------
117
+ // File cache helper type
118
+ // ---------------------------------------------------------------------------
119
+
120
+ type FileCacheEntry<TValue> = {
121
+ stamp: string;
122
+ value: TValue;
123
+ };
124
+
125
+ // ---------------------------------------------------------------------------
126
+ // Options shared between FilePolicyLoader and the backward-compat
127
+ // PermissionManager constructor.
128
+ // ---------------------------------------------------------------------------
129
+
130
+ export interface PolicyLoaderOptions {
131
+ globalConfigPath?: string;
132
+ agentsDir?: string;
133
+ projectGlobalConfigPath?: string;
134
+ projectAgentsDir?: string;
135
+ globalMcpConfigPath?: string;
136
+ mcpServerNames?: readonly string[];
137
+ }
138
+
139
+ // ---------------------------------------------------------------------------
140
+ // FilePolicyLoader — the production implementation
141
+ // ---------------------------------------------------------------------------
142
+
143
+ /**
144
+ * Production `PolicyLoader` that reads config files from disk with
145
+ * mtime-based caching.
146
+ */
147
+ export class FilePolicyLoader implements PolicyLoader {
148
+ private readonly globalConfigPath: string;
149
+ private readonly agentsDir: string;
150
+ private readonly projectGlobalConfigPath: string | null;
151
+ private readonly projectAgentsDir: string | null;
152
+ private readonly globalMcpConfigPath: string;
153
+ private readonly configuredMcpServerNamesOverride: readonly string[] | null;
154
+
155
+ private globalConfigCache: FileCacheEntry<ScopeConfig> | null = null;
156
+ private projectGlobalConfigCache: FileCacheEntry<ScopeConfig> | null = null;
157
+ private readonly agentConfigCache = new Map<
158
+ string,
159
+ FileCacheEntry<ScopeConfig>
160
+ >();
161
+ private readonly projectAgentConfigCache = new Map<
162
+ string,
163
+ FileCacheEntry<ScopeConfig>
164
+ >();
165
+ private configuredMcpServerNamesCache: FileCacheEntry<
166
+ readonly string[]
167
+ > | null = null;
168
+ private accumulatedConfigIssues: string[] = [];
169
+
170
+ constructor(options: PolicyLoaderOptions = {}) {
171
+ this.globalConfigPath =
172
+ options.globalConfigPath || defaultGlobalConfigPath();
173
+ this.agentsDir = options.agentsDir || defaultAgentsDir();
174
+ this.projectGlobalConfigPath = options.projectGlobalConfigPath || null;
175
+ this.projectAgentsDir = options.projectAgentsDir || null;
176
+ this.globalMcpConfigPath =
177
+ options.globalMcpConfigPath || defaultGlobalMcpConfigPath();
178
+ this.configuredMcpServerNamesOverride = options.mcpServerNames
179
+ ? [
180
+ ...new Set(
181
+ options.mcpServerNames
182
+ .map((name) => name.trim())
183
+ .filter((name) => name.length > 0),
184
+ ),
185
+ ]
186
+ : null;
187
+ }
188
+
189
+ // ── Config issue accumulation ────────────────────────────────────────
190
+
191
+ private accumulateConfigIssues(issues: string[]): void {
192
+ for (const issue of issues) {
193
+ if (!this.accumulatedConfigIssues.includes(issue)) {
194
+ this.accumulatedConfigIssues.push(issue);
195
+ }
196
+ }
197
+ }
198
+
199
+ getConfigIssues(): string[] {
200
+ return [...this.accumulatedConfigIssues];
201
+ }
202
+
203
+ // ── Scope loaders ────────────────────────────────────────────────────
204
+
205
+ loadGlobalConfig(): ScopeConfig {
206
+ const stamp = getFileStamp(this.globalConfigPath);
207
+ if (this.globalConfigCache?.stamp === stamp) {
208
+ return this.globalConfigCache.value;
209
+ }
210
+
211
+ const { config, issues } = loadUnifiedConfig(this.globalConfigPath);
212
+ this.accumulateConfigIssues(issues);
213
+
214
+ const value: ScopeConfig = {
215
+ permission: config.permission,
216
+ };
217
+
218
+ this.globalConfigCache = { stamp, value };
219
+ return value;
220
+ }
221
+
222
+ loadProjectConfig(): ScopeConfig {
223
+ if (!this.projectGlobalConfigPath) {
224
+ return {};
225
+ }
226
+
227
+ const stamp = getFileStamp(this.projectGlobalConfigPath);
228
+ if (this.projectGlobalConfigCache?.stamp === stamp) {
229
+ return this.projectGlobalConfigCache.value;
230
+ }
231
+
232
+ const { config, issues } = loadUnifiedConfig(this.projectGlobalConfigPath);
233
+ this.accumulateConfigIssues(issues);
234
+
235
+ const value: ScopeConfig = {
236
+ permission: config.permission,
237
+ };
238
+
239
+ this.projectGlobalConfigCache = { stamp, value };
240
+ return value;
241
+ }
242
+
243
+ private loadScopeConfigFrom(
244
+ dir: string | null,
245
+ cache: Map<string, FileCacheEntry<ScopeConfig>>,
246
+ agentName?: string,
247
+ ): ScopeConfig {
248
+ if (!dir || !agentName) {
249
+ return {};
250
+ }
251
+
252
+ const filePath = join(dir, `${agentName}.md`);
253
+ const stamp = getFileStamp(filePath);
254
+ const cached = cache.get(agentName);
255
+ if (cached?.stamp === stamp) {
256
+ return cached.value;
257
+ }
258
+
259
+ let value: ScopeConfig;
260
+ try {
261
+ const markdown = readFileSync(filePath, "utf-8");
262
+ const frontmatter = extractFrontmatter(markdown);
263
+ if (!frontmatter) {
264
+ value = {};
265
+ } else {
266
+ const parsed = parseSimpleYamlMap(frontmatter);
267
+ const { config, issues } = normalizeUnifiedConfig(parsed);
268
+ this.accumulateConfigIssues(issues);
269
+ value = { permission: config.permission };
270
+ }
271
+ } catch {
272
+ value = {};
273
+ }
274
+
275
+ cache.set(agentName, { stamp, value });
276
+ return value;
277
+ }
278
+
279
+ loadAgentConfig(agentName?: string): ScopeConfig {
280
+ return this.loadScopeConfigFrom(
281
+ this.agentsDir,
282
+ this.agentConfigCache,
283
+ agentName,
284
+ );
285
+ }
286
+
287
+ loadProjectAgentConfig(agentName?: string): ScopeConfig {
288
+ return this.loadScopeConfigFrom(
289
+ this.projectAgentsDir,
290
+ this.projectAgentConfigCache,
291
+ agentName,
292
+ );
293
+ }
294
+
295
+ // ── MCP server names ─────────────────────────────────────────────────
296
+
297
+ getConfiguredMcpServerNames(): readonly string[] {
298
+ if (this.configuredMcpServerNamesOverride) {
299
+ return this.configuredMcpServerNamesOverride;
300
+ }
301
+
302
+ const paths = [this.globalMcpConfigPath];
303
+ const stamp = paths
304
+ .map((path) => `${path}:${getFileStamp(path)}`)
305
+ .join("|");
306
+ if (this.configuredMcpServerNamesCache?.stamp === stamp) {
307
+ return this.configuredMcpServerNamesCache.value;
308
+ }
309
+
310
+ const value = getConfiguredMcpServerNamesFromPaths(paths);
311
+ this.configuredMcpServerNamesCache = { stamp, value };
312
+ return value;
313
+ }
314
+
315
+ // ── Cache stamp ───────────────────────────────────────────────────────
316
+
317
+ getCacheStamp(agentName?: string): string {
318
+ const agentStamp = agentName
319
+ ? getFileStamp(join(this.agentsDir, `${agentName}.md`))
320
+ : "missing";
321
+ const projectStamp = this.projectGlobalConfigPath
322
+ ? getFileStamp(this.projectGlobalConfigPath)
323
+ : "none";
324
+ const projectAgentStamp =
325
+ this.projectAgentsDir && agentName
326
+ ? getFileStamp(join(this.projectAgentsDir, `${agentName}.md`))
327
+ : "none";
328
+
329
+ return `${getFileStamp(this.globalConfigPath)}|${projectStamp}|${agentStamp}|${projectAgentStamp}`;
330
+ }
331
+
332
+ // ── Resolved paths ────────────────────────────────────────────────────
333
+
334
+ getResolvedPolicyPaths(): ResolvedPolicyPaths {
335
+ return {
336
+ globalConfigPath: this.globalConfigPath,
337
+ globalConfigExists: existsSync(this.globalConfigPath),
338
+ projectConfigPath: this.projectGlobalConfigPath,
339
+ projectConfigExists: this.projectGlobalConfigPath
340
+ ? existsSync(this.projectGlobalConfigPath)
341
+ : false,
342
+ agentsDir: this.agentsDir,
343
+ agentsDirExists: existsSync(this.agentsDir),
344
+ projectAgentsDir: this.projectAgentsDir,
345
+ projectAgentsDirExists: this.projectAgentsDir
346
+ ? existsSync(this.projectAgentsDir)
347
+ : false,
348
+ };
349
+ }
350
+ }
@@ -661,3 +661,322 @@ describe("checkPermission — rule origin provenance", () => {
661
661
  expect(result.origin).toBe("builtin");
662
662
  });
663
663
  });
664
+
665
+ // ---------------------------------------------------------------------------
666
+ // In-memory PolicyLoader stub tests — no filesystem required
667
+ // ---------------------------------------------------------------------------
668
+
669
+ import type { PolicyLoader } from "../src/permission-manager";
670
+ import type { ResolvedPolicyPaths } from "../src/policy-loader";
671
+ import type { ScopeConfig } from "../src/types";
672
+
673
+ /**
674
+ * Minimal in-memory PolicyLoader for testing merge + evaluation logic
675
+ * without touching the filesystem.
676
+ */
677
+ function createInMemoryPolicyLoader(
678
+ scopes: {
679
+ global?: ScopeConfig;
680
+ project?: ScopeConfig;
681
+ agent?: Record<string, ScopeConfig>;
682
+ projectAgent?: Record<string, ScopeConfig>;
683
+ } = {},
684
+ mcpServerNames: readonly string[] = [],
685
+ ): PolicyLoader {
686
+ const issues: string[] = [];
687
+ return {
688
+ loadGlobalConfig: () => scopes.global ?? {},
689
+ loadProjectConfig: () => scopes.project ?? {},
690
+ loadAgentConfig: (name?: string) => (name && scopes.agent?.[name]) || {},
691
+ loadProjectAgentConfig: (name?: string) =>
692
+ (name && scopes.projectAgent?.[name]) || {},
693
+ getConfiguredMcpServerNames: () => mcpServerNames,
694
+ getCacheStamp: () => "in-memory",
695
+ getConfigIssues: () => issues,
696
+ getResolvedPolicyPaths: (): ResolvedPolicyPaths => ({
697
+ globalConfigPath: "/in-memory/config.json",
698
+ globalConfigExists: true,
699
+ projectConfigPath: null,
700
+ projectConfigExists: false,
701
+ agentsDir: "/in-memory/agents",
702
+ agentsDirExists: false,
703
+ projectAgentsDir: null,
704
+ projectAgentsDirExists: false,
705
+ }),
706
+ };
707
+ }
708
+
709
+ /** Create a PermissionManager backed by an in-memory PolicyLoader. */
710
+ function makeInMemoryManager(
711
+ scopes: Parameters<typeof createInMemoryPolicyLoader>[0] = {},
712
+ mcpServerNames: readonly string[] = [],
713
+ ): PermissionManager {
714
+ return new PermissionManager({
715
+ policyLoader: createInMemoryPolicyLoader(scopes, mcpServerNames),
716
+ });
717
+ }
718
+
719
+ describe("PermissionManager with in-memory PolicyLoader", () => {
720
+ describe("universal fallback", () => {
721
+ it("defaults to ask when no config is provided", () => {
722
+ const manager = makeInMemoryManager();
723
+ const result = manager.checkPermission("read", {});
724
+ expect(result.state).toBe("ask");
725
+ expect(result.origin).toBe("builtin");
726
+ });
727
+
728
+ it("respects permission['*'] = 'allow' from global config", () => {
729
+ const manager = makeInMemoryManager({
730
+ global: { permission: { "*": "allow" } },
731
+ });
732
+ const result = manager.checkPermission("read", {});
733
+ expect(result.state).toBe("allow");
734
+ expect(result.origin).toBe("global");
735
+ });
736
+
737
+ it("respects permission['*'] = 'deny' from global config", () => {
738
+ const manager = makeInMemoryManager({
739
+ global: { permission: { "*": "deny" } },
740
+ });
741
+ const result = manager.checkPermission("write", {});
742
+ expect(result.state).toBe("deny");
743
+ });
744
+ });
745
+
746
+ describe("surface routing", () => {
747
+ it("bash surface routes correctly", () => {
748
+ const manager = makeInMemoryManager({
749
+ global: {
750
+ permission: { "*": "ask", bash: { "git *": "allow" } },
751
+ },
752
+ });
753
+ const result = manager.checkPermission("bash", {
754
+ command: "git status",
755
+ });
756
+ expect(result.state).toBe("allow");
757
+ expect(result.source).toBe("bash");
758
+ expect(result.matchedPattern).toBe("git *");
759
+ });
760
+
761
+ it("tool surface routes correctly for built-in tools", () => {
762
+ const manager = makeInMemoryManager({
763
+ global: { permission: { "*": "deny", read: "allow" } },
764
+ });
765
+ const result = manager.checkPermission("read", {});
766
+ expect(result.state).toBe("allow");
767
+ expect(result.source).toBe("tool");
768
+ });
769
+
770
+ it("skill surface routes correctly", () => {
771
+ const manager = makeInMemoryManager({
772
+ global: {
773
+ permission: { "*": "ask", skill: { librarian: "allow" } },
774
+ },
775
+ });
776
+ const result = manager.checkPermission("skill", { name: "librarian" });
777
+ expect(result.state).toBe("allow");
778
+ expect(result.source).toBe("skill");
779
+ });
780
+
781
+ it("mcp surface routes correctly", () => {
782
+ const manager = makeInMemoryManager(
783
+ {
784
+ global: {
785
+ permission: { "*": "ask", mcp: { exa_search: "allow" } },
786
+ },
787
+ },
788
+ ["exa"],
789
+ );
790
+ const result = manager.checkPermission("mcp", {
791
+ tool: "exa:search",
792
+ server: "exa",
793
+ });
794
+ expect(result.state).toBe("allow");
795
+ expect(result.source).toBe("mcp");
796
+ });
797
+
798
+ it("external_directory surface routes correctly", () => {
799
+ const manager = makeInMemoryManager({
800
+ global: {
801
+ permission: {
802
+ "*": "ask",
803
+ external_directory: { "/trusted/*": "allow" },
804
+ },
805
+ },
806
+ });
807
+ const result = manager.checkPermission("external_directory", {
808
+ path: "/trusted/repo",
809
+ });
810
+ expect(result.state).toBe("allow");
811
+ expect(result.source).toBe("special");
812
+ });
813
+
814
+ it("extension tools use 'default' source when no config rule matches", () => {
815
+ const manager = makeInMemoryManager({
816
+ global: { permission: { "*": "ask" } },
817
+ });
818
+ const result = manager.checkPermission("my_custom_tool", {});
819
+ expect(result.state).toBe("ask");
820
+ expect(result.source).toBe("default");
821
+ });
822
+ });
823
+
824
+ describe("multi-scope merge", () => {
825
+ it("project overrides global", () => {
826
+ const manager = makeInMemoryManager({
827
+ global: { permission: { read: "ask" } },
828
+ project: { permission: { read: "allow" } },
829
+ });
830
+ const result = manager.checkPermission("read", {});
831
+ expect(result.state).toBe("allow");
832
+ expect(result.origin).toBe("project");
833
+ });
834
+
835
+ it("agent overrides project", () => {
836
+ const manager = makeInMemoryManager({
837
+ global: { permission: { read: "ask" } },
838
+ project: { permission: { read: "allow" } },
839
+ agent: { coder: { permission: { read: "deny" } } },
840
+ });
841
+ const result = manager.checkPermission("read", {}, "coder");
842
+ expect(result.state).toBe("deny");
843
+ expect(result.origin).toBe("agent");
844
+ });
845
+
846
+ it("project-agent overrides agent", () => {
847
+ const manager = makeInMemoryManager({
848
+ global: { permission: { read: "deny" } },
849
+ agent: { coder: { permission: { read: "deny" } } },
850
+ projectAgent: { coder: { permission: { read: "allow" } } },
851
+ });
852
+ const result = manager.checkPermission("read", {}, "coder");
853
+ expect(result.state).toBe("allow");
854
+ expect(result.origin).toBe("project-agent");
855
+ });
856
+
857
+ it("deep-shallow merge preserves patterns from different scopes", () => {
858
+ const manager = makeInMemoryManager({
859
+ global: { permission: { bash: { "git *": "allow" } } },
860
+ project: { permission: { bash: { "rm *": "deny" } } },
861
+ });
862
+ const gitResult = manager.checkPermission("bash", {
863
+ command: "git status",
864
+ });
865
+ expect(gitResult.state).toBe("allow");
866
+ expect(gitResult.origin).toBe("global");
867
+
868
+ const rmResult = manager.checkPermission("bash", {
869
+ command: "rm -rf /",
870
+ });
871
+ expect(rmResult.state).toBe("deny");
872
+ expect(rmResult.origin).toBe("project");
873
+ });
874
+
875
+ it("string replaces object in override scope", () => {
876
+ const manager = makeInMemoryManager({
877
+ global: {
878
+ permission: { bash: { "git *": "ask", "npm *": "ask" } },
879
+ },
880
+ project: { permission: { bash: "allow" } },
881
+ });
882
+ const result = manager.checkPermission("bash", { command: "anything" });
883
+ expect(result.state).toBe("allow");
884
+ expect(result.origin).toBe("project");
885
+ });
886
+ });
887
+
888
+ describe("session rule composition", () => {
889
+ it("session rule wins over config", () => {
890
+ const manager = makeInMemoryManager({
891
+ global: { permission: { "*": "deny" } },
892
+ });
893
+ const sessionRules: Ruleset = [sessionAllow("read", "*")];
894
+ const result = manager.checkPermission(
895
+ "read",
896
+ {},
897
+ undefined,
898
+ sessionRules,
899
+ );
900
+ expect(result.state).toBe("allow");
901
+ expect(result.source).toBe("session");
902
+ });
903
+
904
+ it("session rule does not bleed across surfaces", () => {
905
+ const manager = makeInMemoryManager({
906
+ global: { permission: { "*": "ask" } },
907
+ });
908
+ const sessionRules: Ruleset = [sessionAllow("bash", "git *")];
909
+ const bashResult = manager.checkPermission(
910
+ "bash",
911
+ { command: "git status" },
912
+ undefined,
913
+ sessionRules,
914
+ );
915
+ expect(bashResult.state).toBe("allow");
916
+
917
+ const readResult = manager.checkPermission(
918
+ "read",
919
+ {},
920
+ undefined,
921
+ sessionRules,
922
+ );
923
+ expect(readResult.state).toBe("ask");
924
+ });
925
+ });
926
+
927
+ describe("origin tracking", () => {
928
+ it("universal fallback from project carries origin 'project'", () => {
929
+ const manager = makeInMemoryManager({
930
+ global: { permission: { "*": "ask" } },
931
+ project: { permission: { "*": "allow" } },
932
+ });
933
+ const result = manager.checkPermission("read", {});
934
+ expect(result.state).toBe("allow");
935
+ expect(result.origin).toBe("project");
936
+ });
937
+
938
+ it("session origin is 'session'", () => {
939
+ const manager = makeInMemoryManager();
940
+ const sessionRules: Ruleset = [sessionAllow("read", "*")];
941
+ const result = manager.checkPermission(
942
+ "read",
943
+ {},
944
+ undefined,
945
+ sessionRules,
946
+ );
947
+ expect(result.origin).toBe("session");
948
+ });
949
+ });
950
+
951
+ describe("getToolPermission", () => {
952
+ it("returns tool-level state for built-in tools", () => {
953
+ const manager = makeInMemoryManager({
954
+ global: { permission: { "*": "deny", read: "allow" } },
955
+ });
956
+ expect(manager.getToolPermission("read")).toBe("allow");
957
+ expect(manager.getToolPermission("write")).toBe("deny");
958
+ });
959
+
960
+ it("returns tool-level state for bash surface", () => {
961
+ const manager = makeInMemoryManager({
962
+ global: { permission: { "*": "deny", bash: "allow" } },
963
+ });
964
+ expect(manager.getToolPermission("bash")).toBe("allow");
965
+ });
966
+ });
967
+
968
+ describe("getComposedConfigRules", () => {
969
+ it("returns only config-layer rules", () => {
970
+ const manager = makeInMemoryManager({
971
+ global: {
972
+ permission: { "*": "ask", bash: { "git *": "allow" } },
973
+ },
974
+ });
975
+ const rules = manager.getComposedConfigRules();
976
+ expect(rules.every((r) => r.layer === "config")).toBe(true);
977
+ expect(
978
+ rules.some((r) => r.surface === "bash" && r.pattern === "git *"),
979
+ ).toBe(true);
980
+ });
981
+ });
982
+ });