@antmanler/claude-code-acp 0.12.6

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,462 @@
1
+ import { describe, it, expect, beforeEach, afterEach } from "vitest";
2
+ import { SettingsManager } from "../settings.js";
3
+ import * as fs from "node:fs";
4
+ import * as path from "node:path";
5
+ import * as os from "node:os";
6
+ describe("SettingsManager", () => {
7
+ let tempDir;
8
+ let settingsManager;
9
+ beforeEach(async () => {
10
+ tempDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "settings-test-"));
11
+ });
12
+ afterEach(async () => {
13
+ settingsManager?.dispose();
14
+ await fs.promises.rm(tempDir, { recursive: true, force: true });
15
+ });
16
+ describe("permission checking", () => {
17
+ it("should return 'ask' when no settings exist", async () => {
18
+ settingsManager = new SettingsManager(tempDir);
19
+ await settingsManager.initialize();
20
+ const result = settingsManager.checkPermission("mcp__acp__Read", {
21
+ file_path: "/some/file.txt",
22
+ });
23
+ expect(result.decision).toBe("ask");
24
+ });
25
+ it("should return 'ask' for non-ACP tools (permission checks only apply to mcp__acp__* tools)", async () => {
26
+ const claudeDir = path.join(tempDir, ".claude");
27
+ await fs.promises.mkdir(claudeDir, { recursive: true });
28
+ await fs.promises.writeFile(path.join(claudeDir, "settings.json"), JSON.stringify({
29
+ permissions: {
30
+ deny: ["Read"],
31
+ },
32
+ }));
33
+ settingsManager = new SettingsManager(tempDir);
34
+ await settingsManager.initialize();
35
+ // Non-ACP tools should always return 'ask' regardless of rules
36
+ const result = settingsManager.checkPermission("Read", { file_path: "/some/file.txt" });
37
+ expect(result.decision).toBe("ask");
38
+ });
39
+ it("should allow tool use when matching allow rule exists", async () => {
40
+ const claudeDir = path.join(tempDir, ".claude");
41
+ await fs.promises.mkdir(claudeDir, { recursive: true });
42
+ await fs.promises.writeFile(path.join(claudeDir, "settings.json"), JSON.stringify({
43
+ permissions: {
44
+ allow: ["Read"],
45
+ },
46
+ }));
47
+ settingsManager = new SettingsManager(tempDir);
48
+ await settingsManager.initialize();
49
+ const result = settingsManager.checkPermission("mcp__acp__Read", {
50
+ file_path: "/some/file.txt",
51
+ });
52
+ expect(result.decision).toBe("allow");
53
+ expect(result.rule).toBe("Read");
54
+ });
55
+ it("should deny tool use when matching deny rule exists", async () => {
56
+ const claudeDir = path.join(tempDir, ".claude");
57
+ await fs.promises.mkdir(claudeDir, { recursive: true });
58
+ await fs.promises.writeFile(path.join(claudeDir, "settings.json"), JSON.stringify({
59
+ permissions: {
60
+ deny: ["Read(./.env)"],
61
+ },
62
+ }));
63
+ settingsManager = new SettingsManager(tempDir);
64
+ await settingsManager.initialize();
65
+ const result = settingsManager.checkPermission("mcp__acp__Read", {
66
+ file_path: path.join(tempDir, ".env"),
67
+ });
68
+ expect(result.decision).toBe("deny");
69
+ expect(result.rule).toBe("Read(./.env)");
70
+ });
71
+ it("should prioritize deny rules over allow rules", async () => {
72
+ const claudeDir = path.join(tempDir, ".claude");
73
+ await fs.promises.mkdir(claudeDir, { recursive: true });
74
+ await fs.promises.writeFile(path.join(claudeDir, "settings.json"), JSON.stringify({
75
+ permissions: {
76
+ allow: ["Read"],
77
+ deny: ["Read(./.env)"],
78
+ },
79
+ }));
80
+ settingsManager = new SettingsManager(tempDir);
81
+ await settingsManager.initialize();
82
+ // .env should be denied
83
+ const envResult = settingsManager.checkPermission("mcp__acp__Read", {
84
+ file_path: path.join(tempDir, ".env"),
85
+ });
86
+ expect(envResult.decision).toBe("deny");
87
+ // other files should be allowed
88
+ const otherResult = settingsManager.checkPermission("mcp__acp__Read", {
89
+ file_path: path.join(tempDir, "other.txt"),
90
+ });
91
+ expect(otherResult.decision).toBe("allow");
92
+ });
93
+ it("should handle ACP-prefixed tool names", async () => {
94
+ const claudeDir = path.join(tempDir, ".claude");
95
+ await fs.promises.mkdir(claudeDir, { recursive: true });
96
+ await fs.promises.writeFile(path.join(claudeDir, "settings.json"), JSON.stringify({
97
+ permissions: {
98
+ allow: ["Read"],
99
+ },
100
+ }));
101
+ settingsManager = new SettingsManager(tempDir);
102
+ await settingsManager.initialize();
103
+ const result = settingsManager.checkPermission("mcp__acp__Read", {
104
+ file_path: "/some/file.txt",
105
+ });
106
+ expect(result.decision).toBe("allow");
107
+ });
108
+ });
109
+ describe("Bash permission rules", () => {
110
+ it("should match exact Bash commands without :* wildcard", async () => {
111
+ const claudeDir = path.join(tempDir, ".claude");
112
+ await fs.promises.mkdir(claudeDir, { recursive: true });
113
+ await fs.promises.writeFile(path.join(claudeDir, "settings.json"), JSON.stringify({
114
+ permissions: {
115
+ // Per docs: Bash(npm run build) matches the EXACT command "npm run build"
116
+ allow: ["Bash(npm run lint)"],
117
+ },
118
+ }));
119
+ settingsManager = new SettingsManager(tempDir);
120
+ await settingsManager.initialize();
121
+ // Exact match should be allowed
122
+ const exactResult = settingsManager.checkPermission("mcp__acp__Bash", {
123
+ command: "npm run lint",
124
+ });
125
+ expect(exactResult.decision).toBe("allow");
126
+ // Command with extra arguments should NOT match (exact match only)
127
+ const withArgsResult = settingsManager.checkPermission("mcp__acp__Bash", {
128
+ command: "npm run lint --fix",
129
+ });
130
+ expect(withArgsResult.decision).toBe("ask");
131
+ // Similar command should NOT match (exact match only)
132
+ const similarResult = settingsManager.checkPermission("mcp__acp__Bash", {
133
+ command: "npm run linting",
134
+ });
135
+ expect(similarResult.decision).toBe("ask");
136
+ // Different command should not match
137
+ const differentResult = settingsManager.checkPermission("mcp__acp__Bash", {
138
+ command: "npm run test",
139
+ });
140
+ expect(differentResult.decision).toBe("ask");
141
+ });
142
+ it("should match Bash commands with :* wildcard suffix (prefix matching)", async () => {
143
+ const claudeDir = path.join(tempDir, ".claude");
144
+ await fs.promises.mkdir(claudeDir, { recursive: true });
145
+ await fs.promises.writeFile(path.join(claudeDir, "settings.json"), JSON.stringify({
146
+ permissions: {
147
+ // The :* suffix is a convention to make prefix matching explicit
148
+ allow: ["Bash(npm run:*)"],
149
+ },
150
+ }));
151
+ settingsManager = new SettingsManager(tempDir);
152
+ await settingsManager.initialize();
153
+ // Any command starting with "npm run" should match (prefix matching with :*)
154
+ const lintResult = settingsManager.checkPermission("mcp__acp__Bash", {
155
+ command: "npm run lint",
156
+ });
157
+ expect(lintResult.decision).toBe("allow");
158
+ const testResult = settingsManager.checkPermission("mcp__acp__Bash", {
159
+ command: "npm run test",
160
+ });
161
+ expect(testResult.decision).toBe("allow");
162
+ // Commands with additional args also match
163
+ const withArgsResult = settingsManager.checkPermission("mcp__acp__Bash", {
164
+ command: "npm run test --watch",
165
+ });
166
+ expect(withArgsResult.decision).toBe("allow");
167
+ // Non-matching command
168
+ const installResult = settingsManager.checkPermission("mcp__acp__Bash", {
169
+ command: "npm install",
170
+ });
171
+ expect(installResult.decision).toBe("ask");
172
+ });
173
+ it("should not allow shell operators to bypass prefix matching", async () => {
174
+ const claudeDir = path.join(tempDir, ".claude");
175
+ await fs.promises.mkdir(claudeDir, { recursive: true });
176
+ await fs.promises.writeFile(path.join(claudeDir, "settings.json"), JSON.stringify({
177
+ permissions: {
178
+ allow: ["Bash(safe-cmd:*)"],
179
+ },
180
+ }));
181
+ settingsManager = new SettingsManager(tempDir);
182
+ await settingsManager.initialize();
183
+ // Normal prefix match should work
184
+ const normalResult = settingsManager.checkPermission("mcp__acp__Bash", {
185
+ command: "safe-cmd --flag",
186
+ });
187
+ expect(normalResult.decision).toBe("allow");
188
+ // Shell operators should NOT be allowed (per docs: Claude Code is aware of shell operators)
189
+ const andResult = settingsManager.checkPermission("mcp__acp__Bash", {
190
+ command: "safe-cmd && malicious-cmd",
191
+ });
192
+ expect(andResult.decision).toBe("ask");
193
+ const orResult = settingsManager.checkPermission("mcp__acp__Bash", {
194
+ command: "safe-cmd || malicious-cmd",
195
+ });
196
+ expect(orResult.decision).toBe("ask");
197
+ const semicolonResult = settingsManager.checkPermission("mcp__acp__Bash", {
198
+ command: "safe-cmd; malicious-cmd",
199
+ });
200
+ expect(semicolonResult.decision).toBe("ask");
201
+ const pipeResult = settingsManager.checkPermission("mcp__acp__Bash", {
202
+ command: "safe-cmd | malicious-cmd",
203
+ });
204
+ expect(pipeResult.decision).toBe("ask");
205
+ const subshellResult = settingsManager.checkPermission("mcp__acp__Bash", {
206
+ command: "safe-cmd $(malicious-cmd)",
207
+ });
208
+ expect(subshellResult.decision).toBe("ask");
209
+ const backtickResult = settingsManager.checkPermission("mcp__acp__Bash", {
210
+ command: "safe-cmd `malicious-cmd`",
211
+ });
212
+ expect(backtickResult.decision).toBe("ask");
213
+ const newlineResult = settingsManager.checkPermission("mcp__acp__Bash", {
214
+ command: "safe-cmd\nmalicious-cmd",
215
+ });
216
+ expect(newlineResult.decision).toBe("ask");
217
+ });
218
+ it("should deny dangerous Bash commands", async () => {
219
+ const claudeDir = path.join(tempDir, ".claude");
220
+ await fs.promises.mkdir(claudeDir, { recursive: true });
221
+ await fs.promises.writeFile(path.join(claudeDir, "settings.json"), JSON.stringify({
222
+ permissions: {
223
+ deny: ["Bash(curl:*)", "Bash(wget:*)"],
224
+ },
225
+ }));
226
+ settingsManager = new SettingsManager(tempDir);
227
+ await settingsManager.initialize();
228
+ const curlResult = settingsManager.checkPermission("mcp__acp__Bash", {
229
+ command: "curl https://example.com",
230
+ });
231
+ expect(curlResult.decision).toBe("deny");
232
+ const wgetResult = settingsManager.checkPermission("mcp__acp__Bash", {
233
+ command: "wget https://example.com",
234
+ });
235
+ expect(wgetResult.decision).toBe("deny");
236
+ const lsResult = settingsManager.checkPermission("mcp__acp__Bash", { command: "ls -la" });
237
+ expect(lsResult.decision).toBe("ask");
238
+ });
239
+ });
240
+ describe("file path glob matching", () => {
241
+ it("should match exact file paths", async () => {
242
+ const claudeDir = path.join(tempDir, ".claude");
243
+ await fs.promises.mkdir(claudeDir, { recursive: true });
244
+ await fs.promises.writeFile(path.join(claudeDir, "settings.json"), JSON.stringify({
245
+ permissions: {
246
+ deny: ["Read(./.env)"],
247
+ },
248
+ }));
249
+ settingsManager = new SettingsManager(tempDir);
250
+ await settingsManager.initialize();
251
+ const envResult = settingsManager.checkPermission("mcp__acp__Read", {
252
+ file_path: path.join(tempDir, ".env"),
253
+ });
254
+ expect(envResult.decision).toBe("deny");
255
+ const otherResult = settingsManager.checkPermission("mcp__acp__Read", {
256
+ file_path: path.join(tempDir, ".envrc"),
257
+ });
258
+ expect(otherResult.decision).toBe("ask");
259
+ });
260
+ it("should match glob patterns with single wildcard", async () => {
261
+ const claudeDir = path.join(tempDir, ".claude");
262
+ await fs.promises.mkdir(claudeDir, { recursive: true });
263
+ await fs.promises.writeFile(path.join(claudeDir, "settings.json"), JSON.stringify({
264
+ permissions: {
265
+ deny: ["Read(./.env.*)"],
266
+ },
267
+ }));
268
+ settingsManager = new SettingsManager(tempDir);
269
+ await settingsManager.initialize();
270
+ const envLocalResult = settingsManager.checkPermission("mcp__acp__Read", {
271
+ file_path: path.join(tempDir, ".env.local"),
272
+ });
273
+ expect(envLocalResult.decision).toBe("deny");
274
+ const envProdResult = settingsManager.checkPermission("mcp__acp__Read", {
275
+ file_path: path.join(tempDir, ".env.production"),
276
+ });
277
+ expect(envProdResult.decision).toBe("deny");
278
+ // Plain .env should not match .env.*
279
+ const plainEnvResult = settingsManager.checkPermission("mcp__acp__Read", {
280
+ file_path: path.join(tempDir, ".env"),
281
+ });
282
+ expect(plainEnvResult.decision).toBe("ask");
283
+ });
284
+ it("should match glob patterns with double wildcard (recursive)", async () => {
285
+ const claudeDir = path.join(tempDir, ".claude");
286
+ await fs.promises.mkdir(claudeDir, { recursive: true });
287
+ await fs.promises.writeFile(path.join(claudeDir, "settings.json"), JSON.stringify({
288
+ permissions: {
289
+ deny: ["Read(./secrets/**)"],
290
+ },
291
+ }));
292
+ settingsManager = new SettingsManager(tempDir);
293
+ await settingsManager.initialize();
294
+ const secretResult = settingsManager.checkPermission("mcp__acp__Read", {
295
+ file_path: path.join(tempDir, "secrets", "api-key.txt"),
296
+ });
297
+ expect(secretResult.decision).toBe("deny");
298
+ const nestedSecretResult = settingsManager.checkPermission("mcp__acp__Read", {
299
+ file_path: path.join(tempDir, "secrets", "deep", "nested", "key.txt"),
300
+ });
301
+ expect(nestedSecretResult.decision).toBe("deny");
302
+ const otherResult = settingsManager.checkPermission("mcp__acp__Read", {
303
+ file_path: path.join(tempDir, "public", "file.txt"),
304
+ });
305
+ expect(otherResult.decision).toBe("ask");
306
+ });
307
+ it("should handle home directory expansion", async () => {
308
+ const claudeDir = path.join(tempDir, ".claude");
309
+ await fs.promises.mkdir(claudeDir, { recursive: true });
310
+ await fs.promises.writeFile(path.join(claudeDir, "settings.json"), JSON.stringify({
311
+ permissions: {
312
+ allow: ["Read(~/.zshrc)"],
313
+ },
314
+ }));
315
+ settingsManager = new SettingsManager(tempDir);
316
+ await settingsManager.initialize();
317
+ const zshrcResult = settingsManager.checkPermission("mcp__acp__Read", {
318
+ file_path: path.join(os.homedir(), ".zshrc"),
319
+ });
320
+ expect(zshrcResult.decision).toBe("allow");
321
+ });
322
+ });
323
+ describe("settings merging", () => {
324
+ it("should merge project and local settings", async () => {
325
+ const claudeDir = path.join(tempDir, ".claude");
326
+ await fs.promises.mkdir(claudeDir, { recursive: true });
327
+ // Project settings
328
+ await fs.promises.writeFile(path.join(claudeDir, "settings.json"), JSON.stringify({
329
+ permissions: {
330
+ allow: ["Read"],
331
+ },
332
+ }));
333
+ // Local settings
334
+ await fs.promises.writeFile(path.join(claudeDir, "settings.local.json"), JSON.stringify({
335
+ permissions: {
336
+ deny: ["Read(./.env)"],
337
+ },
338
+ }));
339
+ settingsManager = new SettingsManager(tempDir);
340
+ await settingsManager.initialize();
341
+ // Read should be allowed in general
342
+ const readResult = settingsManager.checkPermission("mcp__acp__Read", {
343
+ file_path: path.join(tempDir, "file.txt"),
344
+ });
345
+ expect(readResult.decision).toBe("allow");
346
+ // But .env should be denied (local settings take precedence)
347
+ const envResult = settingsManager.checkPermission("mcp__acp__Read", {
348
+ file_path: path.join(tempDir, ".env"),
349
+ });
350
+ expect(envResult.decision).toBe("deny");
351
+ });
352
+ });
353
+ describe("ask rules", () => {
354
+ it("should return 'ask' for matching ask rules", async () => {
355
+ const claudeDir = path.join(tempDir, ".claude");
356
+ await fs.promises.mkdir(claudeDir, { recursive: true });
357
+ await fs.promises.writeFile(path.join(claudeDir, "settings.json"), JSON.stringify({
358
+ permissions: {
359
+ ask: ["Bash(git push:*)"],
360
+ },
361
+ }));
362
+ settingsManager = new SettingsManager(tempDir);
363
+ await settingsManager.initialize();
364
+ const result = settingsManager.checkPermission("mcp__acp__Bash", {
365
+ command: "git push origin main",
366
+ });
367
+ expect(result.decision).toBe("ask");
368
+ expect(result.rule).toBe("Bash(git push:*)");
369
+ });
370
+ });
371
+ describe("Edit and Write tools", () => {
372
+ it("should handle Edit tool permissions", async () => {
373
+ const claudeDir = path.join(tempDir, ".claude");
374
+ await fs.promises.mkdir(claudeDir, { recursive: true });
375
+ await fs.promises.writeFile(path.join(claudeDir, "settings.json"), JSON.stringify({
376
+ permissions: {
377
+ deny: ["Edit(./package-lock.json)"],
378
+ },
379
+ }));
380
+ settingsManager = new SettingsManager(tempDir);
381
+ await settingsManager.initialize();
382
+ const lockFileResult = settingsManager.checkPermission("mcp__acp__Edit", {
383
+ file_path: path.join(tempDir, "package-lock.json"),
384
+ });
385
+ expect(lockFileResult.decision).toBe("deny");
386
+ const otherResult = settingsManager.checkPermission("mcp__acp__Edit", {
387
+ file_path: path.join(tempDir, "package.json"),
388
+ });
389
+ expect(otherResult.decision).toBe("ask");
390
+ });
391
+ it("should handle mcp__acp__Edit and mcp__acp__Write tool names", async () => {
392
+ const claudeDir = path.join(tempDir, ".claude");
393
+ await fs.promises.mkdir(claudeDir, { recursive: true });
394
+ await fs.promises.writeFile(path.join(claudeDir, "settings.json"), JSON.stringify({
395
+ permissions: {
396
+ allow: ["Edit", "Write"],
397
+ },
398
+ }));
399
+ settingsManager = new SettingsManager(tempDir);
400
+ await settingsManager.initialize();
401
+ const editResult = settingsManager.checkPermission("mcp__acp__Edit", {
402
+ file_path: "/some/file.ts",
403
+ });
404
+ expect(editResult.decision).toBe("allow");
405
+ const writeResult = settingsManager.checkPermission("mcp__acp__Write", {
406
+ file_path: "/some/file.ts",
407
+ });
408
+ expect(writeResult.decision).toBe("allow");
409
+ });
410
+ });
411
+ describe("getSettings", () => {
412
+ it("should return merged settings", async () => {
413
+ const claudeDir = path.join(tempDir, ".claude");
414
+ await fs.promises.mkdir(claudeDir, { recursive: true });
415
+ await fs.promises.writeFile(path.join(claudeDir, "settings.json"), JSON.stringify({
416
+ permissions: {
417
+ allow: ["Read", "Bash(npm:*)"],
418
+ deny: ["Read(./.env)"],
419
+ },
420
+ env: {
421
+ FOO: "bar",
422
+ },
423
+ }));
424
+ settingsManager = new SettingsManager(tempDir);
425
+ await settingsManager.initialize();
426
+ const settings = settingsManager.getSettings();
427
+ expect(settings.permissions?.allow).toContain("Read");
428
+ expect(settings.permissions?.allow).toContain("Bash(npm:*)");
429
+ expect(settings.permissions?.deny).toContain("Read(./.env)");
430
+ expect(settings.env?.FOO).toBe("bar");
431
+ });
432
+ });
433
+ describe("setCwd", () => {
434
+ it("should reload settings when cwd changes", async () => {
435
+ const claudeDir1 = path.join(tempDir, ".claude");
436
+ await fs.promises.mkdir(claudeDir1, { recursive: true });
437
+ await fs.promises.writeFile(path.join(claudeDir1, "settings.json"), JSON.stringify({
438
+ permissions: {
439
+ allow: ["Read"],
440
+ },
441
+ }));
442
+ settingsManager = new SettingsManager(tempDir);
443
+ await settingsManager.initialize();
444
+ let result = settingsManager.checkPermission("mcp__acp__Read", { file_path: "/file.txt" });
445
+ expect(result.decision).toBe("allow");
446
+ // Create a new temp directory with different settings
447
+ const tempDir2 = await fs.promises.mkdtemp(path.join(os.tmpdir(), "settings-test-2-"));
448
+ const claudeDir2 = path.join(tempDir2, ".claude");
449
+ await fs.promises.mkdir(claudeDir2, { recursive: true });
450
+ await fs.promises.writeFile(path.join(claudeDir2, "settings.json"), JSON.stringify({
451
+ permissions: {
452
+ deny: ["Read"],
453
+ },
454
+ }));
455
+ await settingsManager.setCwd(tempDir2);
456
+ result = settingsManager.checkPermission("mcp__acp__Read", { file_path: "/file.txt" });
457
+ expect(result.decision).toBe("deny");
458
+ // Cleanup second temp dir
459
+ await fs.promises.rm(tempDir2, { recursive: true, force: true });
460
+ });
461
+ });
462
+ });