@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.
- package/LICENSE +222 -0
- package/README.md +53 -0
- package/dist/acp-agent.js +908 -0
- package/dist/index.js +20 -0
- package/dist/lib.js +6 -0
- package/dist/mcp-server.js +731 -0
- package/dist/settings.js +422 -0
- package/dist/tests/acp-agent.test.js +753 -0
- package/dist/tests/extract-lines.test.js +79 -0
- package/dist/tests/replace-and-calculate-location.test.js +266 -0
- package/dist/tests/settings.test.js +462 -0
- package/dist/tools.js +555 -0
- package/dist/utils.js +150 -0
- package/package.json +73 -0
package/dist/settings.js
ADDED
|
@@ -0,0 +1,422 @@
|
|
|
1
|
+
import * as fs from "node:fs";
|
|
2
|
+
import * as path from "node:path";
|
|
3
|
+
import * as os from "node:os";
|
|
4
|
+
import { minimatch } from "minimatch";
|
|
5
|
+
import { ACP_TOOL_NAME_PREFIX, acpToolNames } from "./tools.js";
|
|
6
|
+
import { CLAUDE_CONFIG_DIR } from "./acp-agent.js";
|
|
7
|
+
/**
|
|
8
|
+
* Shell operators that can be used for command chaining/injection
|
|
9
|
+
* These should cause a prefix match to fail to prevent bypasses like:
|
|
10
|
+
* - "safe-cmd && malicious-cmd"
|
|
11
|
+
* - "safe-cmd; malicious-cmd"
|
|
12
|
+
* - "safe-cmd | malicious-cmd"
|
|
13
|
+
* - "safe-cmd || malicious-cmd"
|
|
14
|
+
* - "$(malicious-cmd)"
|
|
15
|
+
* - "`malicious-cmd`"
|
|
16
|
+
*/
|
|
17
|
+
const SHELL_OPERATORS = ["&&", "||", ";", "|", "$(", "`", "\n"];
|
|
18
|
+
/**
|
|
19
|
+
* Checks if a string contains shell operators that could allow command chaining
|
|
20
|
+
*/
|
|
21
|
+
function containsShellOperator(str) {
|
|
22
|
+
return SHELL_OPERATORS.some((op) => str.includes(op));
|
|
23
|
+
}
|
|
24
|
+
/*
|
|
25
|
+
* Tools that modify files. Per Claude Code docs:
|
|
26
|
+
* "Edit rules apply to all built-in tools that edit files."
|
|
27
|
+
* This means an Edit(...) rule should match Write, MultiEdit, etc.
|
|
28
|
+
*/
|
|
29
|
+
const FILE_EDITING_TOOLS = [acpToolNames.edit, acpToolNames.write];
|
|
30
|
+
/**
|
|
31
|
+
* Tools that read files. Per Claude Code docs:
|
|
32
|
+
* "Claude will make a best-effort attempt to apply Read rules to all built-in tools
|
|
33
|
+
* that read files like Grep and Glob."
|
|
34
|
+
* This means a Read(...) rule should match Grep, Glob, etc.
|
|
35
|
+
*/
|
|
36
|
+
const FILE_READING_TOOLS = [acpToolNames.read];
|
|
37
|
+
/**
|
|
38
|
+
* Functions to extract the relevant argument from tool input for permission matching
|
|
39
|
+
*/
|
|
40
|
+
const TOOL_ARG_ACCESSORS = {
|
|
41
|
+
mcp__acp__Read: (input) => input?.file_path,
|
|
42
|
+
mcp__acp__Edit: (input) => input?.file_path,
|
|
43
|
+
mcp__acp__Write: (input) => input?.file_path,
|
|
44
|
+
mcp__acp__Bash: (input) => input?.command,
|
|
45
|
+
};
|
|
46
|
+
/**
|
|
47
|
+
* Parses a permission rule string into its components
|
|
48
|
+
* Examples:
|
|
49
|
+
* "Read" -> { toolName: "Read" }
|
|
50
|
+
* "Read(./.env)" -> { toolName: "Read", argument: "./.env" }
|
|
51
|
+
* "Bash(npm run:*)" -> { toolName: "Bash", argument: "npm run", isWildcard: true }
|
|
52
|
+
*/
|
|
53
|
+
function parseRule(rule) {
|
|
54
|
+
const match = rule.match(/^(\w+)(?:\((.+)\))?$/);
|
|
55
|
+
if (!match) {
|
|
56
|
+
return { toolName: rule };
|
|
57
|
+
}
|
|
58
|
+
const [, toolName, argument] = match;
|
|
59
|
+
if (argument && argument.endsWith(":*")) {
|
|
60
|
+
return {
|
|
61
|
+
toolName,
|
|
62
|
+
argument: argument.slice(0, -2),
|
|
63
|
+
isWildcard: true,
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
return { toolName, argument };
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* Normalizes a path for comparison:
|
|
70
|
+
* - Expands ~ to home directory
|
|
71
|
+
* - Resolves relative paths against cwd
|
|
72
|
+
* - Normalizes path separators
|
|
73
|
+
*/
|
|
74
|
+
function normalizePath(filePath, cwd) {
|
|
75
|
+
if (filePath.startsWith("~/")) {
|
|
76
|
+
filePath = path.join(os.homedir(), filePath.slice(2));
|
|
77
|
+
}
|
|
78
|
+
else if (filePath.startsWith("./")) {
|
|
79
|
+
filePath = path.join(cwd, filePath.slice(2));
|
|
80
|
+
}
|
|
81
|
+
else if (!path.isAbsolute(filePath)) {
|
|
82
|
+
filePath = path.join(cwd, filePath);
|
|
83
|
+
}
|
|
84
|
+
return path.normalize(filePath);
|
|
85
|
+
}
|
|
86
|
+
/**
|
|
87
|
+
* Checks if a file path matches a glob pattern
|
|
88
|
+
*/
|
|
89
|
+
function matchesGlob(pattern, filePath, cwd) {
|
|
90
|
+
const normalizedPattern = normalizePath(pattern, cwd);
|
|
91
|
+
const normalizedPath = normalizePath(filePath, cwd);
|
|
92
|
+
return minimatch(normalizedPath, normalizedPattern, {
|
|
93
|
+
dot: true,
|
|
94
|
+
matchBase: false,
|
|
95
|
+
nocase: process.platform === "win32",
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
/**
|
|
99
|
+
* Checks if a tool invocation matches a parsed permission rule
|
|
100
|
+
*/
|
|
101
|
+
function matchesRule(rule, toolName, toolInput, cwd) {
|
|
102
|
+
// Per Claude Code docs:
|
|
103
|
+
// - "Edit rules apply to all built-in tools that edit files."
|
|
104
|
+
// - "Claude will make a best-effort attempt to apply Read rules to all built-in tools
|
|
105
|
+
// that read files like Grep, Glob, and LS."
|
|
106
|
+
const ruleAppliesToTool = rule.toolName === "Bash" ||
|
|
107
|
+
(rule.toolName === "Edit" && FILE_EDITING_TOOLS.includes(toolName)) ||
|
|
108
|
+
(rule.toolName === "Read" && FILE_READING_TOOLS.includes(toolName));
|
|
109
|
+
if (!ruleAppliesToTool) {
|
|
110
|
+
return false;
|
|
111
|
+
}
|
|
112
|
+
if (!rule.argument) {
|
|
113
|
+
return true;
|
|
114
|
+
}
|
|
115
|
+
const argAccessor = TOOL_ARG_ACCESSORS[toolName];
|
|
116
|
+
if (!argAccessor) {
|
|
117
|
+
return true;
|
|
118
|
+
}
|
|
119
|
+
const actualArg = argAccessor(toolInput);
|
|
120
|
+
if (!actualArg) {
|
|
121
|
+
return false;
|
|
122
|
+
}
|
|
123
|
+
if (toolName === acpToolNames.bash) {
|
|
124
|
+
// Per Claude Code docs: https://code.claude.com/docs/en/iam#tool-specific-permission-rules
|
|
125
|
+
// - Bash(npm run build) matches the EXACT command "npm run build"
|
|
126
|
+
// - Bash(npm run test:*) matches commands STARTING WITH "npm run test"
|
|
127
|
+
// The :* suffix enables prefix matching, without it the match is exact
|
|
128
|
+
//
|
|
129
|
+
// Also from docs: "Claude Code is aware of shell operators (like &&) so a prefix match
|
|
130
|
+
// rule like Bash(safe-cmd:*) won't give it permission to run the command safe-cmd && other-cmd"
|
|
131
|
+
if (rule.isWildcard) {
|
|
132
|
+
if (!actualArg.startsWith(rule.argument)) {
|
|
133
|
+
return false;
|
|
134
|
+
}
|
|
135
|
+
// Check that the matched prefix isn't followed by shell operators that could
|
|
136
|
+
// allow command chaining/injection
|
|
137
|
+
const remainder = actualArg.slice(rule.argument.length);
|
|
138
|
+
if (containsShellOperator(remainder)) {
|
|
139
|
+
return false;
|
|
140
|
+
}
|
|
141
|
+
return true;
|
|
142
|
+
}
|
|
143
|
+
return actualArg === rule.argument;
|
|
144
|
+
}
|
|
145
|
+
// For file-based tools (Read, Edit, Write), use glob matching
|
|
146
|
+
return matchesGlob(rule.argument, actualArg, cwd);
|
|
147
|
+
}
|
|
148
|
+
/**
|
|
149
|
+
* Reads and parses a JSON settings file, returning an empty object if not found or invalid
|
|
150
|
+
*/
|
|
151
|
+
async function loadSettingsFile(filePath) {
|
|
152
|
+
if (!filePath) {
|
|
153
|
+
return {};
|
|
154
|
+
}
|
|
155
|
+
try {
|
|
156
|
+
const content = await fs.promises.readFile(filePath, "utf-8");
|
|
157
|
+
return JSON.parse(content);
|
|
158
|
+
}
|
|
159
|
+
catch {
|
|
160
|
+
return {};
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
/**
|
|
164
|
+
* Gets the enterprise settings path based on the current platform
|
|
165
|
+
*/
|
|
166
|
+
export function getManagedSettingsPath() {
|
|
167
|
+
switch (process.platform) {
|
|
168
|
+
case "darwin":
|
|
169
|
+
return "/Library/Application Support/ClaudeCode/managed-settings.json";
|
|
170
|
+
case "linux":
|
|
171
|
+
return "/etc/claude-code/managed-settings.json";
|
|
172
|
+
case "win32":
|
|
173
|
+
return "C:\\Program Files\\ClaudeCode\\managed-settings.json";
|
|
174
|
+
default:
|
|
175
|
+
return "/etc/claude-code/managed-settings.json";
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
/**
|
|
179
|
+
* Manages Claude Code settings from multiple sources with proper precedence.
|
|
180
|
+
*
|
|
181
|
+
* Settings are loaded from (in order of increasing precedence):
|
|
182
|
+
* 1. User settings (~/.claude/settings.json)
|
|
183
|
+
* 2. Project settings (<cwd>/.claude/settings.json)
|
|
184
|
+
* 3. Local project settings (<cwd>/.claude/settings.local.json)
|
|
185
|
+
* 4. Enterprise managed settings (platform-specific path)
|
|
186
|
+
*
|
|
187
|
+
* The manager watches all settings files for changes and automatically reloads.
|
|
188
|
+
*/
|
|
189
|
+
export class SettingsManager {
|
|
190
|
+
constructor(cwd, options) {
|
|
191
|
+
this.userSettings = {};
|
|
192
|
+
this.projectSettings = {};
|
|
193
|
+
this.localSettings = {};
|
|
194
|
+
this.enterpriseSettings = {};
|
|
195
|
+
this.mergedSettings = {};
|
|
196
|
+
this.watchers = [];
|
|
197
|
+
this.initialized = false;
|
|
198
|
+
this.debounceTimer = null;
|
|
199
|
+
this.cwd = cwd;
|
|
200
|
+
this.onChange = options?.onChange;
|
|
201
|
+
this.logger = options?.logger ?? console;
|
|
202
|
+
}
|
|
203
|
+
/**
|
|
204
|
+
* Initialize the settings manager by loading all settings and setting up file watchers
|
|
205
|
+
*/
|
|
206
|
+
async initialize() {
|
|
207
|
+
if (this.initialized) {
|
|
208
|
+
return;
|
|
209
|
+
}
|
|
210
|
+
await this.loadAllSettings();
|
|
211
|
+
this.setupWatchers();
|
|
212
|
+
this.initialized = true;
|
|
213
|
+
}
|
|
214
|
+
/**
|
|
215
|
+
* Returns the path to the user settings file
|
|
216
|
+
*/
|
|
217
|
+
getUserSettingsPath() {
|
|
218
|
+
return path.join(CLAUDE_CONFIG_DIR, "settings.json");
|
|
219
|
+
}
|
|
220
|
+
/**
|
|
221
|
+
* Returns the path to the project settings file
|
|
222
|
+
*/
|
|
223
|
+
getProjectSettingsPath() {
|
|
224
|
+
return path.join(this.cwd, ".claude", "settings.json");
|
|
225
|
+
}
|
|
226
|
+
/**
|
|
227
|
+
* Returns the path to the local project settings file
|
|
228
|
+
*/
|
|
229
|
+
getLocalSettingsPath() {
|
|
230
|
+
return path.join(this.cwd, ".claude", "settings.local.json");
|
|
231
|
+
}
|
|
232
|
+
/**
|
|
233
|
+
* Loads settings from all sources
|
|
234
|
+
*/
|
|
235
|
+
async loadAllSettings() {
|
|
236
|
+
const [userSettings, projectSettings, localSettings, enterpriseSettings] = await Promise.all([
|
|
237
|
+
loadSettingsFile(this.getUserSettingsPath()),
|
|
238
|
+
loadSettingsFile(this.getProjectSettingsPath()),
|
|
239
|
+
loadSettingsFile(this.getLocalSettingsPath()),
|
|
240
|
+
loadSettingsFile(getManagedSettingsPath()),
|
|
241
|
+
]);
|
|
242
|
+
this.userSettings = userSettings;
|
|
243
|
+
this.projectSettings = projectSettings;
|
|
244
|
+
this.localSettings = localSettings;
|
|
245
|
+
this.enterpriseSettings = enterpriseSettings;
|
|
246
|
+
this.mergeSettings();
|
|
247
|
+
}
|
|
248
|
+
/**
|
|
249
|
+
* Merges all settings sources with proper precedence.
|
|
250
|
+
* For permissions, rules from all sources are combined.
|
|
251
|
+
* Deny rules always take precedence during permission checks.
|
|
252
|
+
*/
|
|
253
|
+
mergeSettings() {
|
|
254
|
+
const allSettings = [
|
|
255
|
+
this.userSettings,
|
|
256
|
+
this.projectSettings,
|
|
257
|
+
this.localSettings,
|
|
258
|
+
this.enterpriseSettings,
|
|
259
|
+
];
|
|
260
|
+
const merged = {
|
|
261
|
+
permissions: {
|
|
262
|
+
allow: [],
|
|
263
|
+
deny: [],
|
|
264
|
+
ask: [],
|
|
265
|
+
},
|
|
266
|
+
};
|
|
267
|
+
for (const settings of allSettings) {
|
|
268
|
+
if (settings.permissions) {
|
|
269
|
+
if (settings.permissions.allow) {
|
|
270
|
+
merged.permissions.allow.push(...settings.permissions.allow);
|
|
271
|
+
}
|
|
272
|
+
if (settings.permissions.deny) {
|
|
273
|
+
merged.permissions.deny.push(...settings.permissions.deny);
|
|
274
|
+
}
|
|
275
|
+
if (settings.permissions.ask) {
|
|
276
|
+
merged.permissions.ask.push(...settings.permissions.ask);
|
|
277
|
+
}
|
|
278
|
+
if (settings.permissions.additionalDirectories) {
|
|
279
|
+
merged.permissions.additionalDirectories = [
|
|
280
|
+
...(merged.permissions.additionalDirectories || []),
|
|
281
|
+
...settings.permissions.additionalDirectories,
|
|
282
|
+
];
|
|
283
|
+
}
|
|
284
|
+
if (settings.permissions.defaultMode) {
|
|
285
|
+
merged.permissions.defaultMode = settings.permissions.defaultMode;
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
if (settings.env) {
|
|
289
|
+
merged.env = { ...merged.env, ...settings.env };
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
this.mergedSettings = merged;
|
|
293
|
+
}
|
|
294
|
+
/**
|
|
295
|
+
* Sets up file watchers for all settings files
|
|
296
|
+
*/
|
|
297
|
+
setupWatchers() {
|
|
298
|
+
const paths = [
|
|
299
|
+
this.getUserSettingsPath(),
|
|
300
|
+
this.getProjectSettingsPath(),
|
|
301
|
+
this.getLocalSettingsPath(),
|
|
302
|
+
getManagedSettingsPath(),
|
|
303
|
+
];
|
|
304
|
+
for (const filePath of paths) {
|
|
305
|
+
if (!filePath)
|
|
306
|
+
continue;
|
|
307
|
+
try {
|
|
308
|
+
const dir = path.dirname(filePath);
|
|
309
|
+
const filename = path.basename(filePath);
|
|
310
|
+
if (fs.existsSync(dir)) {
|
|
311
|
+
const watcher = fs.watch(dir, (eventType, changedFilename) => {
|
|
312
|
+
if (changedFilename === filename) {
|
|
313
|
+
this.handleSettingsChange();
|
|
314
|
+
}
|
|
315
|
+
});
|
|
316
|
+
watcher.on("error", (error) => {
|
|
317
|
+
this.logger.error(`Settings watcher error for ${filePath}:`, error);
|
|
318
|
+
});
|
|
319
|
+
this.watchers.push(watcher);
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
catch (error) {
|
|
323
|
+
this.logger.error(`Failed to set up watcher for ${filePath}:`, error);
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
/**
|
|
328
|
+
* Handles settings file changes with debouncing to avoid rapid reloads
|
|
329
|
+
*/
|
|
330
|
+
handleSettingsChange() {
|
|
331
|
+
if (this.debounceTimer) {
|
|
332
|
+
clearTimeout(this.debounceTimer);
|
|
333
|
+
}
|
|
334
|
+
this.debounceTimer = setTimeout(async () => {
|
|
335
|
+
this.debounceTimer = null;
|
|
336
|
+
try {
|
|
337
|
+
await this.loadAllSettings();
|
|
338
|
+
this.onChange?.();
|
|
339
|
+
}
|
|
340
|
+
catch (error) {
|
|
341
|
+
this.logger.error("Failed to reload settings:", error);
|
|
342
|
+
}
|
|
343
|
+
}, 100);
|
|
344
|
+
}
|
|
345
|
+
/**
|
|
346
|
+
* Checks if a tool invocation is allowed based on the loaded settings.
|
|
347
|
+
*
|
|
348
|
+
* @param toolName - The tool name (can be ACP-prefixed like mcp__acp__Read or plain like Read)
|
|
349
|
+
* @param toolInput - The tool input object
|
|
350
|
+
* @returns The permission decision and matching rule info
|
|
351
|
+
*/
|
|
352
|
+
checkPermission(toolName, toolInput) {
|
|
353
|
+
if (!toolName.startsWith(ACP_TOOL_NAME_PREFIX)) {
|
|
354
|
+
return { decision: "ask" };
|
|
355
|
+
}
|
|
356
|
+
const permissions = this.mergedSettings.permissions;
|
|
357
|
+
if (!permissions) {
|
|
358
|
+
return { decision: "ask" };
|
|
359
|
+
}
|
|
360
|
+
// Check deny rules first (highest priority)
|
|
361
|
+
for (const rule of permissions.deny || []) {
|
|
362
|
+
const parsed = parseRule(rule);
|
|
363
|
+
if (matchesRule(parsed, toolName, toolInput, this.cwd)) {
|
|
364
|
+
return { decision: "deny", rule, source: "deny" };
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
// Check allow rules
|
|
368
|
+
for (const rule of permissions.allow || []) {
|
|
369
|
+
const parsed = parseRule(rule);
|
|
370
|
+
if (matchesRule(parsed, toolName, toolInput, this.cwd)) {
|
|
371
|
+
return { decision: "allow", rule, source: "allow" };
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
// Check ask rules
|
|
375
|
+
for (const rule of permissions.ask || []) {
|
|
376
|
+
const parsed = parseRule(rule);
|
|
377
|
+
if (matchesRule(parsed, toolName, toolInput, this.cwd)) {
|
|
378
|
+
return { decision: "ask", rule, source: "ask" };
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
// No matching rule - default to ask
|
|
382
|
+
return { decision: "ask" };
|
|
383
|
+
}
|
|
384
|
+
/**
|
|
385
|
+
* Returns the current merged settings
|
|
386
|
+
*/
|
|
387
|
+
getSettings() {
|
|
388
|
+
return this.mergedSettings;
|
|
389
|
+
}
|
|
390
|
+
/**
|
|
391
|
+
* Returns the current working directory
|
|
392
|
+
*/
|
|
393
|
+
getCwd() {
|
|
394
|
+
return this.cwd;
|
|
395
|
+
}
|
|
396
|
+
/**
|
|
397
|
+
* Updates the working directory and reloads project-specific settings
|
|
398
|
+
*/
|
|
399
|
+
async setCwd(cwd) {
|
|
400
|
+
if (this.cwd === cwd) {
|
|
401
|
+
return;
|
|
402
|
+
}
|
|
403
|
+
this.dispose();
|
|
404
|
+
this.cwd = cwd;
|
|
405
|
+
this.initialized = false;
|
|
406
|
+
await this.initialize();
|
|
407
|
+
}
|
|
408
|
+
/**
|
|
409
|
+
* Disposes of file watchers and cleans up resources
|
|
410
|
+
*/
|
|
411
|
+
dispose() {
|
|
412
|
+
if (this.debounceTimer) {
|
|
413
|
+
clearTimeout(this.debounceTimer);
|
|
414
|
+
this.debounceTimer = null;
|
|
415
|
+
}
|
|
416
|
+
for (const watcher of this.watchers) {
|
|
417
|
+
watcher.close();
|
|
418
|
+
}
|
|
419
|
+
this.watchers = [];
|
|
420
|
+
this.initialized = false;
|
|
421
|
+
}
|
|
422
|
+
}
|