@gotgenes/pi-permission-system 5.4.0 → 5.5.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +28 -0
- package/package.json +1 -1
- package/src/handlers/before-agent-start.ts +7 -7
- package/src/handlers/gates/bash-external-directory.ts +22 -24
- package/src/handlers/gates/external-directory.ts +32 -41
- package/src/handlers/gates/skill-read.ts +10 -12
- package/src/handlers/gates/tool.ts +20 -27
- package/src/handlers/gates/types.ts +75 -0
- package/src/handlers/input.ts +3 -3
- package/src/handlers/lifecycle.ts +21 -21
- package/src/handlers/tool-call.ts +77 -7
- package/src/handlers/types.ts +20 -7
- package/src/index.ts +6 -1
- package/src/permission-manager.ts +28 -279
- package/src/policy-loader.ts +350 -0
- package/src/runtime.ts +17 -9
- package/tests/handlers/before-agent-start.test.ts +17 -27
- package/tests/handlers/gates/bash-external-directory.test.ts +48 -105
- package/tests/handlers/gates/external-directory.test.ts +65 -140
- package/tests/handlers/gates/skill-read.test.ts +50 -65
- package/tests/handlers/gates/tool.test.ts +90 -334
- package/tests/handlers/input-events.test.ts +10 -21
- package/tests/handlers/input.test.ts +26 -43
- package/tests/handlers/lifecycle.test.ts +47 -66
- package/tests/handlers/tool-call-events.test.ts +29 -40
- package/tests/handlers/tool-call.test.ts +19 -30
- package/tests/permission-manager-unified.test.ts +319 -0
- package/tests/policy-loader.test.ts +561 -0
|
@@ -0,0 +1,561 @@
|
|
|
1
|
+
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { tmpdir } from "node:os";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { describe, expect, it } from "vitest";
|
|
5
|
+
import { FilePolicyLoader } from "../src/policy-loader";
|
|
6
|
+
|
|
7
|
+
// ---------------------------------------------------------------------------
|
|
8
|
+
// Helpers
|
|
9
|
+
// ---------------------------------------------------------------------------
|
|
10
|
+
|
|
11
|
+
function makeTempDir(): string {
|
|
12
|
+
return mkdtempSync(join(tmpdir(), "policy-loader-test-"));
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function makeLoader(
|
|
16
|
+
baseDir: string,
|
|
17
|
+
options: {
|
|
18
|
+
globalConfig?: Record<string, unknown>;
|
|
19
|
+
mcpServerNames?: readonly string[];
|
|
20
|
+
} = {},
|
|
21
|
+
) {
|
|
22
|
+
const agentsDir = join(baseDir, "agents");
|
|
23
|
+
mkdirSync(agentsDir, { recursive: true });
|
|
24
|
+
|
|
25
|
+
const globalConfigPath = join(baseDir, "config.json");
|
|
26
|
+
writeFileSync(
|
|
27
|
+
globalConfigPath,
|
|
28
|
+
JSON.stringify(options.globalConfig ?? {}, null, 2),
|
|
29
|
+
);
|
|
30
|
+
|
|
31
|
+
return new FilePolicyLoader({
|
|
32
|
+
globalConfigPath,
|
|
33
|
+
agentsDir,
|
|
34
|
+
mcpServerNames: options.mcpServerNames
|
|
35
|
+
? [...options.mcpServerNames]
|
|
36
|
+
: undefined,
|
|
37
|
+
});
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// ---------------------------------------------------------------------------
|
|
41
|
+
// loadGlobalConfig
|
|
42
|
+
// ---------------------------------------------------------------------------
|
|
43
|
+
|
|
44
|
+
describe("FilePolicyLoader.loadGlobalConfig", () => {
|
|
45
|
+
it("returns ScopeConfig with permission from a valid config file", () => {
|
|
46
|
+
const baseDir = makeTempDir();
|
|
47
|
+
try {
|
|
48
|
+
const loader = makeLoader(baseDir, {
|
|
49
|
+
globalConfig: { permission: { "*": "allow", read: "ask" } },
|
|
50
|
+
});
|
|
51
|
+
const config = loader.loadGlobalConfig();
|
|
52
|
+
expect(config.permission).toEqual({ "*": "allow", read: "ask" });
|
|
53
|
+
} finally {
|
|
54
|
+
rmSync(baseDir, { recursive: true, force: true });
|
|
55
|
+
}
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it("returns empty ScopeConfig when config file is missing", () => {
|
|
59
|
+
const loader = new FilePolicyLoader({
|
|
60
|
+
globalConfigPath: "/nonexistent/config.json",
|
|
61
|
+
agentsDir: "/nonexistent/agents",
|
|
62
|
+
});
|
|
63
|
+
const config = loader.loadGlobalConfig();
|
|
64
|
+
expect(config.permission).toBeUndefined();
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it("returns empty ScopeConfig when config file has no permission key", () => {
|
|
68
|
+
const baseDir = makeTempDir();
|
|
69
|
+
try {
|
|
70
|
+
const loader = makeLoader(baseDir, {
|
|
71
|
+
globalConfig: { debugLog: true },
|
|
72
|
+
});
|
|
73
|
+
const config = loader.loadGlobalConfig();
|
|
74
|
+
expect(config.permission).toBeUndefined();
|
|
75
|
+
} finally {
|
|
76
|
+
rmSync(baseDir, { recursive: true, force: true });
|
|
77
|
+
}
|
|
78
|
+
});
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
// ---------------------------------------------------------------------------
|
|
82
|
+
// loadProjectConfig
|
|
83
|
+
// ---------------------------------------------------------------------------
|
|
84
|
+
|
|
85
|
+
describe("FilePolicyLoader.loadProjectConfig", () => {
|
|
86
|
+
it("returns empty ScopeConfig when no project path is configured", () => {
|
|
87
|
+
const loader = new FilePolicyLoader({
|
|
88
|
+
globalConfigPath: "/nonexistent/config.json",
|
|
89
|
+
agentsDir: "/nonexistent/agents",
|
|
90
|
+
});
|
|
91
|
+
const config = loader.loadProjectConfig();
|
|
92
|
+
expect(config).toEqual({});
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it("returns ScopeConfig from a project config file", () => {
|
|
96
|
+
const baseDir = makeTempDir();
|
|
97
|
+
try {
|
|
98
|
+
const projectConfigPath = join(baseDir, "project-config.json");
|
|
99
|
+
writeFileSync(
|
|
100
|
+
projectConfigPath,
|
|
101
|
+
JSON.stringify({ permission: { bash: "allow" } }),
|
|
102
|
+
);
|
|
103
|
+
const loader = new FilePolicyLoader({
|
|
104
|
+
globalConfigPath: "/nonexistent/config.json",
|
|
105
|
+
agentsDir: "/nonexistent/agents",
|
|
106
|
+
projectGlobalConfigPath: projectConfigPath,
|
|
107
|
+
});
|
|
108
|
+
const config = loader.loadProjectConfig();
|
|
109
|
+
expect(config.permission).toEqual({ bash: "allow" });
|
|
110
|
+
} finally {
|
|
111
|
+
rmSync(baseDir, { recursive: true, force: true });
|
|
112
|
+
}
|
|
113
|
+
});
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
// ---------------------------------------------------------------------------
|
|
117
|
+
// loadAgentConfig / loadProjectAgentConfig
|
|
118
|
+
// ---------------------------------------------------------------------------
|
|
119
|
+
|
|
120
|
+
describe("FilePolicyLoader.loadAgentConfig", () => {
|
|
121
|
+
it("returns empty ScopeConfig when agentName is undefined", () => {
|
|
122
|
+
const baseDir = makeTempDir();
|
|
123
|
+
try {
|
|
124
|
+
const loader = makeLoader(baseDir);
|
|
125
|
+
expect(loader.loadAgentConfig()).toEqual({});
|
|
126
|
+
} finally {
|
|
127
|
+
rmSync(baseDir, { recursive: true, force: true });
|
|
128
|
+
}
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
it("returns ScopeConfig from agent frontmatter", () => {
|
|
132
|
+
const baseDir = makeTempDir();
|
|
133
|
+
try {
|
|
134
|
+
const agentsDir = join(baseDir, "agents");
|
|
135
|
+
mkdirSync(agentsDir, { recursive: true });
|
|
136
|
+
writeFileSync(
|
|
137
|
+
join(agentsDir, "coder.md"),
|
|
138
|
+
`---\npermission:\n bash: allow\n---\n# Coder agent\n`,
|
|
139
|
+
);
|
|
140
|
+
const loader = new FilePolicyLoader({
|
|
141
|
+
globalConfigPath: join(baseDir, "config.json"),
|
|
142
|
+
agentsDir,
|
|
143
|
+
});
|
|
144
|
+
writeFileSync(join(baseDir, "config.json"), "{}");
|
|
145
|
+
const config = loader.loadAgentConfig("coder");
|
|
146
|
+
expect(config.permission).toEqual({ bash: "allow" });
|
|
147
|
+
} finally {
|
|
148
|
+
rmSync(baseDir, { recursive: true, force: true });
|
|
149
|
+
}
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
it("returns empty ScopeConfig when agent file does not exist", () => {
|
|
153
|
+
const baseDir = makeTempDir();
|
|
154
|
+
try {
|
|
155
|
+
const loader = makeLoader(baseDir);
|
|
156
|
+
expect(loader.loadAgentConfig("nonexistent")).toEqual({});
|
|
157
|
+
} finally {
|
|
158
|
+
rmSync(baseDir, { recursive: true, force: true });
|
|
159
|
+
}
|
|
160
|
+
});
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
describe("FilePolicyLoader.loadProjectAgentConfig", () => {
|
|
164
|
+
it("returns empty ScopeConfig when no projectAgentsDir is configured", () => {
|
|
165
|
+
const baseDir = makeTempDir();
|
|
166
|
+
try {
|
|
167
|
+
const loader = makeLoader(baseDir);
|
|
168
|
+
expect(loader.loadProjectAgentConfig("coder")).toEqual({});
|
|
169
|
+
} finally {
|
|
170
|
+
rmSync(baseDir, { recursive: true, force: true });
|
|
171
|
+
}
|
|
172
|
+
});
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
// ---------------------------------------------------------------------------
|
|
176
|
+
// getConfigIssues
|
|
177
|
+
// ---------------------------------------------------------------------------
|
|
178
|
+
|
|
179
|
+
describe("FilePolicyLoader.getConfigIssues", () => {
|
|
180
|
+
it("returns empty array before any loads", () => {
|
|
181
|
+
const loader = new FilePolicyLoader({
|
|
182
|
+
globalConfigPath: "/nonexistent/config.json",
|
|
183
|
+
agentsDir: "/nonexistent/agents",
|
|
184
|
+
});
|
|
185
|
+
expect(loader.getConfigIssues()).toEqual([]);
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
it("returns empty array for valid config", () => {
|
|
189
|
+
const baseDir = makeTempDir();
|
|
190
|
+
try {
|
|
191
|
+
const loader = makeLoader(baseDir, {
|
|
192
|
+
globalConfig: { permission: { "*": "ask" } },
|
|
193
|
+
});
|
|
194
|
+
loader.loadGlobalConfig();
|
|
195
|
+
expect(loader.getConfigIssues()).toEqual([]);
|
|
196
|
+
} finally {
|
|
197
|
+
rmSync(baseDir, { recursive: true, force: true });
|
|
198
|
+
}
|
|
199
|
+
});
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
// ---------------------------------------------------------------------------
|
|
203
|
+
// getResolvedPolicyPaths
|
|
204
|
+
// ---------------------------------------------------------------------------
|
|
205
|
+
|
|
206
|
+
describe("FilePolicyLoader.getResolvedPolicyPaths", () => {
|
|
207
|
+
it("returns correct paths and existence when files exist", () => {
|
|
208
|
+
const baseDir = makeTempDir();
|
|
209
|
+
try {
|
|
210
|
+
const globalConfigPath = join(baseDir, "config.json");
|
|
211
|
+
const agentsDir = join(baseDir, "agents");
|
|
212
|
+
writeFileSync(globalConfigPath, "{}");
|
|
213
|
+
mkdirSync(agentsDir, { recursive: true });
|
|
214
|
+
|
|
215
|
+
const loader = new FilePolicyLoader({ globalConfigPath, agentsDir });
|
|
216
|
+
const paths = loader.getResolvedPolicyPaths();
|
|
217
|
+
|
|
218
|
+
expect(paths.globalConfigPath).toBe(globalConfigPath);
|
|
219
|
+
expect(paths.globalConfigExists).toBe(true);
|
|
220
|
+
expect(paths.agentsDir).toBe(agentsDir);
|
|
221
|
+
expect(paths.agentsDirExists).toBe(true);
|
|
222
|
+
expect(paths.projectConfigPath).toBeNull();
|
|
223
|
+
expect(paths.projectConfigExists).toBe(false);
|
|
224
|
+
expect(paths.projectAgentsDir).toBeNull();
|
|
225
|
+
expect(paths.projectAgentsDirExists).toBe(false);
|
|
226
|
+
} finally {
|
|
227
|
+
rmSync(baseDir, { recursive: true, force: true });
|
|
228
|
+
}
|
|
229
|
+
});
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
// ---------------------------------------------------------------------------
|
|
233
|
+
// getCacheStamp
|
|
234
|
+
// ---------------------------------------------------------------------------
|
|
235
|
+
|
|
236
|
+
describe("FilePolicyLoader.getCacheStamp", () => {
|
|
237
|
+
it("returns a string stamp", () => {
|
|
238
|
+
const loader = new FilePolicyLoader({
|
|
239
|
+
globalConfigPath: "/nonexistent/config.json",
|
|
240
|
+
agentsDir: "/nonexistent/agents",
|
|
241
|
+
});
|
|
242
|
+
const stamp = loader.getCacheStamp();
|
|
243
|
+
expect(typeof stamp).toBe("string");
|
|
244
|
+
expect(stamp.length).toBeGreaterThan(0);
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
it("changes when the global config file changes", () => {
|
|
248
|
+
const baseDir = makeTempDir();
|
|
249
|
+
try {
|
|
250
|
+
const globalConfigPath = join(baseDir, "config.json");
|
|
251
|
+
writeFileSync(globalConfigPath, "{}");
|
|
252
|
+
const agentsDir = join(baseDir, "agents");
|
|
253
|
+
mkdirSync(agentsDir, { recursive: true });
|
|
254
|
+
|
|
255
|
+
const loader = new FilePolicyLoader({ globalConfigPath, agentsDir });
|
|
256
|
+
const stamp1 = loader.getCacheStamp();
|
|
257
|
+
|
|
258
|
+
// Wait a tick so mtime changes
|
|
259
|
+
const now = Date.now();
|
|
260
|
+
while (Date.now() - now < 50) {
|
|
261
|
+
// busy-wait for mtime resolution
|
|
262
|
+
}
|
|
263
|
+
writeFileSync(globalConfigPath, '{"permission": {}}');
|
|
264
|
+
const stamp2 = loader.getCacheStamp();
|
|
265
|
+
|
|
266
|
+
expect(stamp1).not.toBe(stamp2);
|
|
267
|
+
} finally {
|
|
268
|
+
rmSync(baseDir, { recursive: true, force: true });
|
|
269
|
+
}
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
it("includes agent stamp when agentName is provided and agent file exists", () => {
|
|
273
|
+
const baseDir = makeTempDir();
|
|
274
|
+
try {
|
|
275
|
+
const agentsDir = join(baseDir, "agents");
|
|
276
|
+
mkdirSync(agentsDir, { recursive: true });
|
|
277
|
+
writeFileSync(join(baseDir, "config.json"), "{}");
|
|
278
|
+
writeFileSync(
|
|
279
|
+
join(agentsDir, "coder.md"),
|
|
280
|
+
"---\npermission:\n read: allow\n---\n",
|
|
281
|
+
);
|
|
282
|
+
|
|
283
|
+
const loader = new FilePolicyLoader({
|
|
284
|
+
globalConfigPath: join(baseDir, "config.json"),
|
|
285
|
+
agentsDir,
|
|
286
|
+
});
|
|
287
|
+
|
|
288
|
+
const stampWithout = loader.getCacheStamp();
|
|
289
|
+
const stampWith = loader.getCacheStamp("coder");
|
|
290
|
+
// Agent file exists, so the stamp differs from the no-agent case.
|
|
291
|
+
expect(stampWithout).not.toBe(stampWith);
|
|
292
|
+
} finally {
|
|
293
|
+
rmSync(baseDir, { recursive: true, force: true });
|
|
294
|
+
}
|
|
295
|
+
});
|
|
296
|
+
});
|
|
297
|
+
|
|
298
|
+
// ---------------------------------------------------------------------------
|
|
299
|
+
// Mtime cache invalidation
|
|
300
|
+
// ---------------------------------------------------------------------------
|
|
301
|
+
|
|
302
|
+
describe("FilePolicyLoader mtime caching", () => {
|
|
303
|
+
it("returns cached value on second call with unchanged file", () => {
|
|
304
|
+
const baseDir = makeTempDir();
|
|
305
|
+
try {
|
|
306
|
+
const loader = makeLoader(baseDir, {
|
|
307
|
+
globalConfig: { permission: { "*": "allow" } },
|
|
308
|
+
});
|
|
309
|
+
const first = loader.loadGlobalConfig();
|
|
310
|
+
const second = loader.loadGlobalConfig();
|
|
311
|
+
// Same reference — cache hit
|
|
312
|
+
expect(second).toBe(first);
|
|
313
|
+
} finally {
|
|
314
|
+
rmSync(baseDir, { recursive: true, force: true });
|
|
315
|
+
}
|
|
316
|
+
});
|
|
317
|
+
|
|
318
|
+
it("reloads when file mtime changes", () => {
|
|
319
|
+
const baseDir = makeTempDir();
|
|
320
|
+
try {
|
|
321
|
+
const globalConfigPath = join(baseDir, "config.json");
|
|
322
|
+
const agentsDir = join(baseDir, "agents");
|
|
323
|
+
mkdirSync(agentsDir, { recursive: true });
|
|
324
|
+
writeFileSync(
|
|
325
|
+
globalConfigPath,
|
|
326
|
+
JSON.stringify({ permission: { "*": "allow" } }),
|
|
327
|
+
);
|
|
328
|
+
|
|
329
|
+
const loader = new FilePolicyLoader({ globalConfigPath, agentsDir });
|
|
330
|
+
const first = loader.loadGlobalConfig();
|
|
331
|
+
expect(first.permission?.["*"]).toBe("allow");
|
|
332
|
+
|
|
333
|
+
// busy-wait for mtime resolution
|
|
334
|
+
const now = Date.now();
|
|
335
|
+
while (Date.now() - now < 50) {
|
|
336
|
+
/* spin */
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
writeFileSync(
|
|
340
|
+
globalConfigPath,
|
|
341
|
+
JSON.stringify({ permission: { "*": "deny" } }),
|
|
342
|
+
);
|
|
343
|
+
const second = loader.loadGlobalConfig();
|
|
344
|
+
expect(second.permission?.["*"]).toBe("deny");
|
|
345
|
+
expect(second).not.toBe(first);
|
|
346
|
+
} finally {
|
|
347
|
+
rmSync(baseDir, { recursive: true, force: true });
|
|
348
|
+
}
|
|
349
|
+
});
|
|
350
|
+
});
|
|
351
|
+
|
|
352
|
+
// ---------------------------------------------------------------------------
|
|
353
|
+
// Agent frontmatter loading
|
|
354
|
+
// ---------------------------------------------------------------------------
|
|
355
|
+
|
|
356
|
+
describe("FilePolicyLoader agent frontmatter", () => {
|
|
357
|
+
it("loads permission from agent frontmatter with pattern map", () => {
|
|
358
|
+
const baseDir = makeTempDir();
|
|
359
|
+
try {
|
|
360
|
+
const agentsDir = join(baseDir, "agents");
|
|
361
|
+
mkdirSync(agentsDir, { recursive: true });
|
|
362
|
+
writeFileSync(join(baseDir, "config.json"), "{}");
|
|
363
|
+
writeFileSync(
|
|
364
|
+
join(agentsDir, "coder.md"),
|
|
365
|
+
[
|
|
366
|
+
"---",
|
|
367
|
+
"permission:",
|
|
368
|
+
" bash:",
|
|
369
|
+
' "git *": allow',
|
|
370
|
+
' "rm *": deny',
|
|
371
|
+
"---",
|
|
372
|
+
"# Coder",
|
|
373
|
+
].join("\n"),
|
|
374
|
+
);
|
|
375
|
+
|
|
376
|
+
const loader = new FilePolicyLoader({
|
|
377
|
+
globalConfigPath: join(baseDir, "config.json"),
|
|
378
|
+
agentsDir,
|
|
379
|
+
});
|
|
380
|
+
const config = loader.loadAgentConfig("coder");
|
|
381
|
+
expect(config.permission).toBeDefined();
|
|
382
|
+
expect(config.permission?.bash).toEqual({
|
|
383
|
+
"git *": "allow",
|
|
384
|
+
"rm *": "deny",
|
|
385
|
+
});
|
|
386
|
+
} finally {
|
|
387
|
+
rmSync(baseDir, { recursive: true, force: true });
|
|
388
|
+
}
|
|
389
|
+
});
|
|
390
|
+
|
|
391
|
+
it("returns empty config for agent file without frontmatter", () => {
|
|
392
|
+
const baseDir = makeTempDir();
|
|
393
|
+
try {
|
|
394
|
+
const agentsDir = join(baseDir, "agents");
|
|
395
|
+
mkdirSync(agentsDir, { recursive: true });
|
|
396
|
+
writeFileSync(join(baseDir, "config.json"), "{}");
|
|
397
|
+
writeFileSync(join(agentsDir, "plain.md"), "# No frontmatter\n");
|
|
398
|
+
|
|
399
|
+
const loader = new FilePolicyLoader({
|
|
400
|
+
globalConfigPath: join(baseDir, "config.json"),
|
|
401
|
+
agentsDir,
|
|
402
|
+
});
|
|
403
|
+
expect(loader.loadAgentConfig("plain")).toEqual({});
|
|
404
|
+
} finally {
|
|
405
|
+
rmSync(baseDir, { recursive: true, force: true });
|
|
406
|
+
}
|
|
407
|
+
});
|
|
408
|
+
|
|
409
|
+
it("project agent config loads from projectAgentsDir", () => {
|
|
410
|
+
const baseDir = makeTempDir();
|
|
411
|
+
try {
|
|
412
|
+
const agentsDir = join(baseDir, "agents");
|
|
413
|
+
const projectAgentsDir = join(baseDir, "project-agents");
|
|
414
|
+
mkdirSync(agentsDir, { recursive: true });
|
|
415
|
+
mkdirSync(projectAgentsDir, { recursive: true });
|
|
416
|
+
writeFileSync(join(baseDir, "config.json"), "{}");
|
|
417
|
+
writeFileSync(
|
|
418
|
+
join(projectAgentsDir, "coder.md"),
|
|
419
|
+
"---\npermission:\n write: allow\n---\n# Coder\n",
|
|
420
|
+
);
|
|
421
|
+
|
|
422
|
+
const loader = new FilePolicyLoader({
|
|
423
|
+
globalConfigPath: join(baseDir, "config.json"),
|
|
424
|
+
agentsDir,
|
|
425
|
+
projectAgentsDir,
|
|
426
|
+
});
|
|
427
|
+
const config = loader.loadProjectAgentConfig("coder");
|
|
428
|
+
expect(config.permission).toEqual({ write: "allow" });
|
|
429
|
+
} finally {
|
|
430
|
+
rmSync(baseDir, { recursive: true, force: true });
|
|
431
|
+
}
|
|
432
|
+
});
|
|
433
|
+
});
|
|
434
|
+
|
|
435
|
+
// ---------------------------------------------------------------------------
|
|
436
|
+
// MCP server name reading
|
|
437
|
+
// ---------------------------------------------------------------------------
|
|
438
|
+
|
|
439
|
+
describe("FilePolicyLoader.getConfiguredMcpServerNames", () => {
|
|
440
|
+
it("returns override names when provided", () => {
|
|
441
|
+
const loader = new FilePolicyLoader({
|
|
442
|
+
globalConfigPath: "/nonexistent/config.json",
|
|
443
|
+
agentsDir: "/nonexistent/agents",
|
|
444
|
+
mcpServerNames: ["exa", "research"],
|
|
445
|
+
});
|
|
446
|
+
expect(loader.getConfiguredMcpServerNames()).toEqual(
|
|
447
|
+
expect.arrayContaining(["exa", "research"]),
|
|
448
|
+
);
|
|
449
|
+
});
|
|
450
|
+
|
|
451
|
+
it("deduplicates and trims override names", () => {
|
|
452
|
+
const loader = new FilePolicyLoader({
|
|
453
|
+
globalConfigPath: "/nonexistent/config.json",
|
|
454
|
+
agentsDir: "/nonexistent/agents",
|
|
455
|
+
mcpServerNames: [" exa ", "exa", "research"],
|
|
456
|
+
});
|
|
457
|
+
const names = loader.getConfiguredMcpServerNames();
|
|
458
|
+
expect(names.filter((n) => n === "exa")).toHaveLength(1);
|
|
459
|
+
});
|
|
460
|
+
|
|
461
|
+
it("reads server names from mcp.json on disk", () => {
|
|
462
|
+
const baseDir = makeTempDir();
|
|
463
|
+
try {
|
|
464
|
+
const mcpConfigPath = join(baseDir, "mcp.json");
|
|
465
|
+
writeFileSync(
|
|
466
|
+
mcpConfigPath,
|
|
467
|
+
JSON.stringify({ mcpServers: { exa: {}, research: {} } }),
|
|
468
|
+
);
|
|
469
|
+
|
|
470
|
+
const loader = new FilePolicyLoader({
|
|
471
|
+
globalConfigPath: "/nonexistent/config.json",
|
|
472
|
+
agentsDir: "/nonexistent/agents",
|
|
473
|
+
globalMcpConfigPath: mcpConfigPath,
|
|
474
|
+
});
|
|
475
|
+
const names = loader.getConfiguredMcpServerNames();
|
|
476
|
+
expect(names).toEqual(expect.arrayContaining(["exa", "research"]));
|
|
477
|
+
} finally {
|
|
478
|
+
rmSync(baseDir, { recursive: true, force: true });
|
|
479
|
+
}
|
|
480
|
+
});
|
|
481
|
+
|
|
482
|
+
it("returns empty array when mcp.json is missing", () => {
|
|
483
|
+
const loader = new FilePolicyLoader({
|
|
484
|
+
globalConfigPath: "/nonexistent/config.json",
|
|
485
|
+
agentsDir: "/nonexistent/agents",
|
|
486
|
+
globalMcpConfigPath: "/nonexistent/mcp.json",
|
|
487
|
+
});
|
|
488
|
+
expect(loader.getConfiguredMcpServerNames()).toEqual([]);
|
|
489
|
+
});
|
|
490
|
+
|
|
491
|
+
it("caches MCP server names across calls", () => {
|
|
492
|
+
const baseDir = makeTempDir();
|
|
493
|
+
try {
|
|
494
|
+
const mcpConfigPath = join(baseDir, "mcp.json");
|
|
495
|
+
writeFileSync(mcpConfigPath, JSON.stringify({ mcpServers: { exa: {} } }));
|
|
496
|
+
|
|
497
|
+
const loader = new FilePolicyLoader({
|
|
498
|
+
globalConfigPath: "/nonexistent/config.json",
|
|
499
|
+
agentsDir: "/nonexistent/agents",
|
|
500
|
+
globalMcpConfigPath: mcpConfigPath,
|
|
501
|
+
});
|
|
502
|
+
const first = loader.getConfiguredMcpServerNames();
|
|
503
|
+
const second = loader.getConfiguredMcpServerNames();
|
|
504
|
+
// Same reference — cache hit
|
|
505
|
+
expect(second).toBe(first);
|
|
506
|
+
} finally {
|
|
507
|
+
rmSync(baseDir, { recursive: true, force: true });
|
|
508
|
+
}
|
|
509
|
+
});
|
|
510
|
+
});
|
|
511
|
+
|
|
512
|
+
// ---------------------------------------------------------------------------
|
|
513
|
+
// Config issue accumulation
|
|
514
|
+
// ---------------------------------------------------------------------------
|
|
515
|
+
|
|
516
|
+
describe("FilePolicyLoader config issue accumulation", () => {
|
|
517
|
+
it("accumulates issues from malformed config files", () => {
|
|
518
|
+
const baseDir = makeTempDir();
|
|
519
|
+
try {
|
|
520
|
+
// Write invalid JSON to trigger a parse error issue
|
|
521
|
+
const globalConfigPath = join(baseDir, "config.json");
|
|
522
|
+
const agentsDir = join(baseDir, "agents");
|
|
523
|
+
mkdirSync(agentsDir, { recursive: true });
|
|
524
|
+
writeFileSync(globalConfigPath, "{ INVALID JSON");
|
|
525
|
+
|
|
526
|
+
const loader = new FilePolicyLoader({ globalConfigPath, agentsDir });
|
|
527
|
+
loader.loadGlobalConfig();
|
|
528
|
+
const issues = loader.getConfigIssues();
|
|
529
|
+
expect(issues.length).toBeGreaterThanOrEqual(1);
|
|
530
|
+
expect(issues[0]).toContain("Failed to read config");
|
|
531
|
+
} finally {
|
|
532
|
+
rmSync(baseDir, { recursive: true, force: true });
|
|
533
|
+
}
|
|
534
|
+
});
|
|
535
|
+
|
|
536
|
+
it("does not duplicate issues on repeated loads", () => {
|
|
537
|
+
const baseDir = makeTempDir();
|
|
538
|
+
try {
|
|
539
|
+
const globalConfigPath = join(baseDir, "config.json");
|
|
540
|
+
const agentsDir = join(baseDir, "agents");
|
|
541
|
+
mkdirSync(agentsDir, { recursive: true });
|
|
542
|
+
writeFileSync(globalConfigPath, "{ INVALID JSON");
|
|
543
|
+
|
|
544
|
+
const loader = new FilePolicyLoader({ globalConfigPath, agentsDir });
|
|
545
|
+
loader.loadGlobalConfig();
|
|
546
|
+
const issuesBefore = loader.getConfigIssues();
|
|
547
|
+
|
|
548
|
+
// Bust cache by waiting for mtime change
|
|
549
|
+
const now = Date.now();
|
|
550
|
+
while (Date.now() - now < 50) {
|
|
551
|
+
/* spin */
|
|
552
|
+
}
|
|
553
|
+
writeFileSync(globalConfigPath, "{ INVALID JSON");
|
|
554
|
+
loader.loadGlobalConfig();
|
|
555
|
+
const issuesAfter = loader.getConfigIssues();
|
|
556
|
+
expect(issuesAfter.length).toBe(issuesBefore.length);
|
|
557
|
+
} finally {
|
|
558
|
+
rmSync(baseDir, { recursive: true, force: true });
|
|
559
|
+
}
|
|
560
|
+
});
|
|
561
|
+
});
|