@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,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
+ }