diff --git a/package-lock.json b/package-lock.json index fb2d2658..55164745 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,7 +12,8 @@ "@agentclientprotocol/sdk": "0.9.0", "@anthropic-ai/claude-agent-sdk": "0.1.65", "@modelcontextprotocol/sdk": "1.24.3", - "diff": "8.0.2" + "diff": "8.0.2", + "minimatch": "^10.1.1" }, "bin": { "claude-code-acp": "dist/index.js" @@ -1111,6 +1112,27 @@ "url": "https://opencollective.com/libvips" } }, + "node_modules/@isaacs/balanced-match": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/@isaacs/balanced-match/-/balanced-match-4.0.1.tgz", + "integrity": "sha512-yzMTt9lEb8Gv7zRioUilSglI0c0smZ9k5D65677DLWLtWJaXIS3CqcGyUFByYKlnUj6TkjLVs54fBl6+TiGQDQ==", + "license": "MIT", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/@isaacs/brace-expansion": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/@isaacs/brace-expansion/-/brace-expansion-5.0.0.tgz", + "integrity": "sha512-ZT55BDLV0yv0RBm2czMiZ+SqCGO7AvmOM3G/w2xhVPH+te0aKgFjmBvGlL1dH+ql2tgGO3MVrbb3jCKyvpgnxA==", + "license": "MIT", + "dependencies": { + "@isaacs/balanced-match": "^4.0.1" + }, + "engines": { + "node": "20 || >=22" + } + }, "node_modules/@jridgewell/resolve-uri": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", @@ -1557,7 +1579,6 @@ "integrity": "sha512-rl78HwuZlaDIUSeUKkmogkhebA+8K1Hy7tddZuJ3D0xV8pZSfsYGTsliGUol1JPzu9EKnTxPC4L1fiWouStRew==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "undici-types": "~7.16.0" } @@ -1597,7 +1618,6 @@ "integrity": "sha512-N9lBGA9o9aqb1hVMc9hzySbhKibHmB+N3IpoShyV6HyQYRGIhlrO5rQgttypi+yEeKsKI4idxC8Jw6gXKD4THA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.49.0", "@typescript-eslint/types": "8.49.0", @@ -1741,6 +1761,22 @@ "typescript": ">=4.8.4 <6.0.0" } }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": { + "version": "9.0.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", + "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/@typescript-eslint/utils": { "version": "8.49.0", "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.49.0.tgz", @@ -1926,7 +1962,6 @@ "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "dev": true, "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -2422,7 +2457,6 @@ "integrity": "sha512-BhHmn2yNOFA9H9JmmIVKJmd288g9hrVRDkdoIgRCRuSySRUHH7r/DI6aAXW9T1WwUuY3DFgrcaqB+deURBLR5g==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -2726,7 +2760,6 @@ "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz", "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==", "license": "MIT", - "peer": true, "dependencies": { "accepts": "^2.0.0", "body-parser": "^2.2.1", @@ -3356,16 +3389,15 @@ } }, "node_modules/minimatch": { - "version": "9.0.5", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", - "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", - "dev": true, - "license": "ISC", + "version": "10.1.1", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.1.1.tgz", + "integrity": "sha512-enIvLvRAFZYXJzkCYG5RKmPfrFArdLv+R+lbQ53BmIMLIry74bjKzX6iHAm8WYamJkhSSEabrWN5D97XnKObjQ==", + "license": "BlueOak-1.0.0", "dependencies": { - "brace-expansion": "^2.0.1" + "@isaacs/brace-expansion": "^5.0.0" }, "engines": { - "node": ">=16 || 14 >=14.17" + "node": "20 || >=22" }, "funding": { "url": "https://github.com/sponsors/isaacs" @@ -3586,7 +3618,6 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -4178,7 +4209,6 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -4235,7 +4265,6 @@ "integrity": "sha512-ITcnkFeR3+fI8P1wMgItjGrR10170d8auB4EpMLPqmx6uxElH3a/hHGQabSHKdqd4FXWO1nFIp9rRn7JQ34ACQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.5.0", @@ -4459,7 +4488,6 @@ "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", "license": "MIT", - "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } diff --git a/package.json b/package.json index ec30ac0c..66bce3f9 100644 --- a/package.json +++ b/package.json @@ -54,7 +54,8 @@ "@agentclientprotocol/sdk": "0.9.0", "@anthropic-ai/claude-agent-sdk": "0.1.65", "@modelcontextprotocol/sdk": "1.24.3", - "diff": "8.0.2" + "diff": "8.0.2", + "minimatch": "^10.1.1" }, "devDependencies": { "@anthropic-ai/sdk": "0.71.2", diff --git a/src/acp-agent.ts b/src/acp-agent.ts index 88ffa248..04686b6c 100644 --- a/src/acp-agent.ts +++ b/src/acp-agent.ts @@ -26,6 +26,7 @@ import { WriteTextFileRequest, WriteTextFileResponse, } from "@agentclientprotocol/sdk"; +import { SettingsManager } from "./settings.js"; import { CanUseTool, McpServerConfig, @@ -40,7 +41,8 @@ import * as fs from "node:fs"; import * as path from "node:path"; import * as os from "node:os"; import { nodeToWebReadable, nodeToWebWritable, Pushable, unreachable } from "./utils.js"; -import { createMcpServer, EDIT_TOOL_NAMES, toolNames } from "./mcp-server.js"; +import { createMcpServer } from "./mcp-server.js"; +import { EDIT_TOOL_NAMES, acpToolNames } from "./tools.js"; import { toolInfoFromToolUse, planEntries, @@ -48,6 +50,7 @@ import { ClaudePlanEntry, registerHookCallback, createPostToolUseHook, + createPreToolUseHook, } from "./tools.js"; import { ContentBlockParam } from "@anthropic-ai/sdk/resources"; import { BetaContentBlock, BetaRawContentBlockDelta } from "@anthropic-ai/sdk/resources/beta.mjs"; @@ -69,6 +72,7 @@ type Session = { input: Pushable; cancelled: boolean; permissionMode: PermissionMode; + settingsManager: SettingsManager; }; type BackgroundTerminal = @@ -205,6 +209,11 @@ export class ClaudeAcpAgent implements Agent { const sessionId = userProvidedOptions?.resume || randomUUID(); const input = new Pushable(); + const settingsManager = new SettingsManager(params.cwd, { + logger: this.logger, + }); + await settingsManager.initialize(); + const mcpServers: Record = {}; if (Array.isArray(params.mcpServers)) { for (const server of params.mcpServers) { @@ -284,6 +293,12 @@ export class ClaudeAcpAgent implements Agent { }), hooks: { ...userProvidedOptions?.hooks, + PreToolUse: [ + ...(userProvidedOptions?.hooks?.PreToolUse || []), + { + hooks: [createPreToolUseHook(settingsManager, this.logger)], + }, + ], PostToolUse: [ ...(userProvidedOptions?.hooks?.PostToolUse || []), { @@ -301,25 +316,25 @@ export class ClaudeAcpAgent implements Agent { if (!disableBuiltInTools) { if (this.clientCapabilities?.fs?.readTextFile) { - allowedTools.push(toolNames.read); + allowedTools.push(acpToolNames.read); disallowedTools.push("Read"); } if (this.clientCapabilities?.fs?.writeTextFile) { disallowedTools.push("Write", "Edit"); } if (this.clientCapabilities?.terminal) { - allowedTools.push(toolNames.bashOutput, toolNames.killShell); + allowedTools.push(acpToolNames.bashOutput, acpToolNames.killShell); disallowedTools.push("Bash", "BashOutput", "KillShell"); } } else { // When built-in tools are disabled, explicitly disallow all of them disallowedTools.push( - toolNames.read, - toolNames.write, - toolNames.edit, - toolNames.bash, - toolNames.bashOutput, - toolNames.killShell, + acpToolNames.read, + acpToolNames.write, + acpToolNames.edit, + acpToolNames.bash, + acpToolNames.bashOutput, + acpToolNames.killShell, "Read", "Write", "Edit", @@ -363,6 +378,7 @@ export class ClaudeAcpAgent implements Agent { input: input, cancelled: false, permissionMode, + settingsManager, }; const availableCommands = await getAvailableSlashCommands(q); diff --git a/src/lib.ts b/src/lib.ts index 1ebc1173..060fd674 100644 --- a/src/lib.ts +++ b/src/lib.ts @@ -15,8 +15,22 @@ export { Pushable, unreachable, } from "./utils.js"; -export { createMcpServer, toolNames } from "./mcp-server.js"; -export { toolInfoFromToolUse, planEntries, toolUpdateFromToolResult } from "./tools.js"; +export { createMcpServer } from "./mcp-server.js"; +export { + toolInfoFromToolUse, + planEntries, + toolUpdateFromToolResult, + createPreToolUseHook, + acpToolNames as toolNames, +} from "./tools.js"; +export { + SettingsManager, + type ClaudeCodeSettings, + type PermissionSettings, + type PermissionDecision, + type PermissionCheckResult, + type SettingsManagerOptions, +} from "./settings.js"; // Export types export type { ClaudePlanEntry } from "./tools.js"; diff --git a/src/mcp-server.ts b/src/mcp-server.ts index b779da77..320b3b75 100644 --- a/src/mcp-server.ts +++ b/src/mcp-server.ts @@ -18,6 +18,7 @@ import * as path from "node:path"; import * as fs from "node:fs/promises"; import { sleep, unreachable, extractLinesWithByteLimit } from "./utils.js"; +import { acpToolNames } from "./tools.js"; export const SYSTEM_REMINDER = ` @@ -36,18 +37,6 @@ const unqualifiedToolNames = { bashOutput: "BashOutput", }; -const SERVER_PREFIX = "mcp__acp__"; -export const toolNames = { - read: SERVER_PREFIX + unqualifiedToolNames.read, - edit: SERVER_PREFIX + unqualifiedToolNames.edit, - write: SERVER_PREFIX + unqualifiedToolNames.write, - bash: SERVER_PREFIX + unqualifiedToolNames.bash, - killShell: SERVER_PREFIX + unqualifiedToolNames.killShell, - bashOutput: SERVER_PREFIX + unqualifiedToolNames.bashOutput, -}; - -export const EDIT_TOOL_NAMES = [toolNames.edit, toolNames.write]; - export function createMcpServer( agent: ClaudeAcpAgent, sessionId: string, @@ -119,7 +108,7 @@ export function createMcpServer( title: unqualifiedToolNames.read, description: `Reads the content of the given file in the project. -In sessions with ${toolNames.read} always use it instead of Read as it contains the most up-to-date contents. +In sessions with ${acpToolNames.read} always use it instead of Read as it contains the most up-to-date contents. Reads a file from the local filesystem. If the User provides a path to a file assume that path is valid. It is okay to read a file that does not exist; an error will be returned. @@ -129,7 +118,7 @@ Usage: - You can optionally specify a line offset and limit (especially handy for long files), but it's recommended to read the whole file by not providing these parameters - Any files larger than ${defaults.maxFileSize} bytes will be truncated - This tool allows Claude Code to read images (eg PNG, JPG, etc). When reading an image file the contents are presented visually as Claude Code is a multimodal LLM. -- This tool can only read files, not directories. To read a directory, use an ls command via the ${toolNames.bash} tool. +- This tool can only read files, not directories. To read a directory, use an ls command via the ${acpToolNames.bash} tool. - You have the capability to call multiple tools in a single response. It is always better to speculatively read multiple files as a batch that are potentially useful.`, inputSchema: { file_path: z.string().describe("The absolute path to the file to read"), @@ -226,12 +215,12 @@ Usage: title: unqualifiedToolNames.write, description: `Writes a file to the local filesystem.. -In sessions with ${toolNames.write} always use it instead of Write as it will +In sessions with ${acpToolNames.write} always use it instead of Write as it will allow the user to conveniently review changes. Usage: - This tool will overwrite the existing file if there is one at the provided path. -- If this is an existing file, you MUST use the ${toolNames.read} tool first to read the file's contents. This tool will fail if you did not read the file first. +- If this is an existing file, you MUST use the ${acpToolNames.read} tool first to read the file's contents. This tool will fail if you did not read the file first. - ALWAYS prefer editing existing files in the codebase. NEVER write new files unless explicitly required. - NEVER proactively create documentation files (*.md) or README files. Only create documentation files if explicitly requested by the User. - Only use emojis if the user explicitly requests it. Avoid writing emojis to files unless asked.`, @@ -286,11 +275,11 @@ Usage: title: unqualifiedToolNames.edit, description: `Performs exact string replacements in files. -In sessions with ${toolNames.edit} always use it instead of Edit as it will +In sessions with ${acpToolNames.edit} always use it instead of Edit as it will allow the user to conveniently review changes. Usage: -- You must use your \`${toolNames.read}\` tool at least once in the conversation before editing. This tool will error if you attempt an edit without reading the file. +- You must use your \`${acpToolNames.read}\` tool at least once in the conversation before editing. This tool will error if you attempt an edit without reading the file. - When editing text from Read tool output, ensure you preserve the exact indentation (tabs/spaces) as it appears. - ALWAYS prefer editing existing files in the codebase. NEVER write new files unless explicitly required. - Only use emojis if the user explicitly requests it. Avoid adding emojis to files unless asked. @@ -379,7 +368,7 @@ Usage: title: unqualifiedToolNames.bash, description: `Executes a bash command -In sessions with ${toolNames.bash} always use it instead of Bash`, +In sessions with ${acpToolNames.bash} always use it instead of Bash`, inputSchema: { command: z.string().describe("The command to execute"), timeout: z.number().describe(`Optional timeout in milliseconds (max ${2 * 60 * 1000})`), @@ -400,7 +389,7 @@ Output: Create directory 'foo'`), .boolean() .default(false) .describe( - `Set to true to run this command in the background. The tool returns an \`id\` that can be used with the \`${toolNames.bashOutput}\` tool to retrieve the current output, or the \`${toolNames.killShell}\` tool to stop it early.`, + `Set to true to run this command in the background. The tool returns an \`id\` that can be used with the \`${acpToolNames.bashOutput}\` tool to retrieve the current output, or the \`${acpToolNames.killShell}\` tool to stop it early.`, ), }, }, @@ -532,11 +521,13 @@ Output: Create directory 'foo'`), - Returns stdout and stderr output along with shell status - Use this tool when you need to monitor or check the output of a long-running shell -In sessions with ${toolNames.bashOutput} always use it for output from Bash commands instead of TaskOutput.`, +In sessions with ${acpToolNames.bashOutput} always use it for output from Bash commands instead of TaskOutput.`, inputSchema: { bash_id: z .string() - .describe(`The id of the background bash command as returned by \`${toolNames.bash}\``), + .describe( + `The id of the background bash command as returned by \`${acpToolNames.bash}\``, + ), }, }, async (input) => { @@ -587,11 +578,13 @@ In sessions with ${toolNames.bashOutput} always use it for output from Bash comm - Returns a success or failure status - Use this tool when you need to terminate a long-running shell -In sessions with ${toolNames.killShell} always use it instead of KillShell.`, +In sessions with ${acpToolNames.killShell} always use it instead of KillShell.`, inputSchema: { shell_id: z .string() - .describe(`The id of the background bash command as returned by \`${toolNames.bash}\``), + .describe( + `The id of the background bash command as returned by \`${acpToolNames.bash}\``, + ), }, }, async (input: KillShellInput) => { diff --git a/src/settings.ts b/src/settings.ts new file mode 100644 index 00000000..5de1fc61 --- /dev/null +++ b/src/settings.ts @@ -0,0 +1,522 @@ +import * as fs from "node:fs"; +import * as path from "node:path"; +import * as os from "node:os"; +import { minimatch } from "minimatch"; +import { ACP_TOOL_NAME_PREFIX, acpToolNames } from "./tools.js"; +import { CLAUDE_CONFIG_DIR } from "./acp-agent.js"; + +/** + * Permission rule format examples: + * - "Read" - matches all Read tool calls + * - "Read(./.env)" - matches specific path + * - "Read(./.env.*)" - glob pattern + * - "Read(./secrets/**)" - recursive glob + * - "Bash(npm run lint)" - exact command prefix + * - "Bash(npm run:*)" - command prefix with wildcard + * + * Docs: https://code.claude.com/docs/en/iam#tool-specific-permission-rules + */ + +export interface PermissionSettings { + allow?: string[]; + deny?: string[]; + ask?: string[]; + additionalDirectories?: string[]; + defaultMode?: string; +} + +export interface ClaudeCodeSettings { + permissions?: PermissionSettings; + env?: Record; +} + +export type PermissionDecision = "allow" | "deny" | "ask"; + +export interface PermissionCheckResult { + decision: PermissionDecision; + rule?: string; + source?: "allow" | "deny" | "ask"; +} + +interface ParsedRule { + toolName: string; + argument?: string; + isWildcard?: boolean; +} + +/** + * Shell operators that can be used for command chaining/injection + * These should cause a prefix match to fail to prevent bypasses like: + * - "safe-cmd && malicious-cmd" + * - "safe-cmd; malicious-cmd" + * - "safe-cmd | malicious-cmd" + * - "safe-cmd || malicious-cmd" + * - "$(malicious-cmd)" + * - "`malicious-cmd`" + */ +const SHELL_OPERATORS = ["&&", "||", ";", "|", "$(", "`", "\n"]; + +/** + * Checks if a string contains shell operators that could allow command chaining + */ +function containsShellOperator(str: string): boolean { + return SHELL_OPERATORS.some((op) => str.includes(op)); +} + +/* + * Tools that modify files. Per Claude Code docs: + * "Edit rules apply to all built-in tools that edit files." + * This means an Edit(...) rule should match Write, MultiEdit, etc. + */ +const FILE_EDITING_TOOLS = [acpToolNames.edit, acpToolNames.write]; + +/** + * Tools that read files. Per Claude Code docs: + * "Claude will make a best-effort attempt to apply Read rules to all built-in tools + * that read files like Grep and Glob." + * This means a Read(...) rule should match Grep, Glob, etc. + */ +const FILE_READING_TOOLS = [acpToolNames.read]; + +/** + * Functions to extract the relevant argument from tool input for permission matching + */ +const TOOL_ARG_ACCESSORS: Record string | undefined> = { + mcp__acp__Read: (input) => (input as { file_path?: string })?.file_path, + mcp__acp__Edit: (input) => (input as { file_path?: string })?.file_path, + mcp__acp__Write: (input) => (input as { file_path?: string })?.file_path, + mcp__acp__Bash: (input) => (input as { command?: string })?.command, +}; + +/** + * Parses a permission rule string into its components + * Examples: + * "Read" -> { toolName: "Read" } + * "Read(./.env)" -> { toolName: "Read", argument: "./.env" } + * "Bash(npm run:*)" -> { toolName: "Bash", argument: "npm run", isWildcard: true } + */ +function parseRule(rule: string): ParsedRule { + const match = rule.match(/^(\w+)(?:\((.+)\))?$/); + if (!match) { + return { toolName: rule }; + } + + const [, toolName, argument] = match; + + if (argument && argument.endsWith(":*")) { + return { + toolName, + argument: argument.slice(0, -2), + isWildcard: true, + }; + } + + return { toolName, argument }; +} + +/** + * Normalizes a path for comparison: + * - Expands ~ to home directory + * - Resolves relative paths against cwd + * - Normalizes path separators + */ +function normalizePath(filePath: string, cwd: string): string { + if (filePath.startsWith("~/")) { + filePath = path.join(os.homedir(), filePath.slice(2)); + } else if (filePath.startsWith("./")) { + filePath = path.join(cwd, filePath.slice(2)); + } else if (!path.isAbsolute(filePath)) { + filePath = path.join(cwd, filePath); + } + return path.normalize(filePath); +} + +/** + * Checks if a file path matches a glob pattern + */ +function matchesGlob(pattern: string, filePath: string, cwd: string): boolean { + const normalizedPattern = normalizePath(pattern, cwd); + const normalizedPath = normalizePath(filePath, cwd); + + return minimatch(normalizedPath, normalizedPattern, { + dot: true, + matchBase: false, + nocase: process.platform === "win32", + }); +} + +/** + * Checks if a tool invocation matches a parsed permission rule + */ +function matchesRule(rule: ParsedRule, toolName: string, toolInput: unknown, cwd: string): boolean { + // Per Claude Code docs: + // - "Edit rules apply to all built-in tools that edit files." + // - "Claude will make a best-effort attempt to apply Read rules to all built-in tools + // that read files like Grep, Glob, and LS." + const ruleAppliesToTool = + rule.toolName === "Bash" || + (rule.toolName === "Edit" && FILE_EDITING_TOOLS.includes(toolName)) || + (rule.toolName === "Read" && FILE_READING_TOOLS.includes(toolName)); + + if (!ruleAppliesToTool) { + return false; + } + + if (!rule.argument) { + return true; + } + + const argAccessor = TOOL_ARG_ACCESSORS[toolName]; + if (!argAccessor) { + return true; + } + + const actualArg = argAccessor(toolInput); + if (!actualArg) { + return false; + } + + if (toolName === acpToolNames.bash) { + // Per Claude Code docs: https://code.claude.com/docs/en/iam#tool-specific-permission-rules + // - Bash(npm run build) matches the EXACT command "npm run build" + // - Bash(npm run test:*) matches commands STARTING WITH "npm run test" + // The :* suffix enables prefix matching, without it the match is exact + // + // Also from docs: "Claude Code is aware of shell operators (like &&) so a prefix match + // rule like Bash(safe-cmd:*) won't give it permission to run the command safe-cmd && other-cmd" + if (rule.isWildcard) { + if (!actualArg.startsWith(rule.argument)) { + return false; + } + // Check that the matched prefix isn't followed by shell operators that could + // allow command chaining/injection + const remainder = actualArg.slice(rule.argument.length); + if (containsShellOperator(remainder)) { + return false; + } + return true; + } + return actualArg === rule.argument; + } + + // For file-based tools (Read, Edit, Write), use glob matching + return matchesGlob(rule.argument, actualArg, cwd); +} + +/** + * Reads and parses a JSON settings file, returning an empty object if not found or invalid + */ +async function loadSettingsFile(filePath: string | null): Promise { + if (!filePath) { + return {}; + } + + try { + const content = await fs.promises.readFile(filePath, "utf-8"); + return JSON.parse(content) as ClaudeCodeSettings; + } catch { + return {}; + } +} + +/** + * Gets the enterprise settings path based on the current platform + */ +export function getManagedSettingsPath(): string { + switch (process.platform) { + case "darwin": + return "/Library/Application Support/ClaudeCode/managed-settings.json"; + case "linux": + return "/etc/claude-code/managed-settings.json"; + case "win32": + return "C:\\Program Files\\ClaudeCode\\managed-settings.json"; + default: + return "/etc/claude-code/managed-settings.json"; + } +} + +export interface SettingsManagerOptions { + onChange?: () => void; + logger?: { log: (...args: any[]) => void; error: (...args: any[]) => void }; +} + +/** + * Manages Claude Code settings from multiple sources with proper precedence. + * + * Settings are loaded from (in order of increasing precedence): + * 1. User settings (~/.claude/settings.json) + * 2. Project settings (/.claude/settings.json) + * 3. Local project settings (/.claude/settings.local.json) + * 4. Enterprise managed settings (platform-specific path) + * + * The manager watches all settings files for changes and automatically reloads. + */ +export class SettingsManager { + private cwd: string; + private userSettings: ClaudeCodeSettings = {}; + private projectSettings: ClaudeCodeSettings = {}; + private localSettings: ClaudeCodeSettings = {}; + private enterpriseSettings: ClaudeCodeSettings = {}; + private mergedSettings: ClaudeCodeSettings = {}; + private watchers: fs.FSWatcher[] = []; + private onChange?: () => void; + private logger: { log: (...args: any[]) => void; error: (...args: any[]) => void }; + private initialized = false; + private debounceTimer: ReturnType | null = null; + + constructor(cwd: string, options?: SettingsManagerOptions) { + this.cwd = cwd; + this.onChange = options?.onChange; + this.logger = options?.logger ?? console; + } + + /** + * Initialize the settings manager by loading all settings and setting up file watchers + */ + async initialize(): Promise { + if (this.initialized) { + return; + } + + await this.loadAllSettings(); + this.setupWatchers(); + this.initialized = true; + } + + /** + * Returns the path to the user settings file + */ + private getUserSettingsPath(): string { + return path.join(CLAUDE_CONFIG_DIR, "settings.json"); + } + + /** + * Returns the path to the project settings file + */ + private getProjectSettingsPath(): string { + return path.join(this.cwd, ".claude", "settings.json"); + } + + /** + * Returns the path to the local project settings file + */ + private getLocalSettingsPath(): string { + return path.join(this.cwd, ".claude", "settings.local.json"); + } + + /** + * Loads settings from all sources + */ + private async loadAllSettings(): Promise { + const [userSettings, projectSettings, localSettings, enterpriseSettings] = await Promise.all([ + loadSettingsFile(this.getUserSettingsPath()), + loadSettingsFile(this.getProjectSettingsPath()), + loadSettingsFile(this.getLocalSettingsPath()), + loadSettingsFile(getManagedSettingsPath()), + ]); + + this.userSettings = userSettings; + this.projectSettings = projectSettings; + this.localSettings = localSettings; + this.enterpriseSettings = enterpriseSettings; + + this.mergeSettings(); + } + + /** + * Merges all settings sources with proper precedence. + * For permissions, rules from all sources are combined. + * Deny rules always take precedence during permission checks. + */ + private mergeSettings(): void { + const allSettings = [ + this.userSettings, + this.projectSettings, + this.localSettings, + this.enterpriseSettings, + ]; + + const merged: ClaudeCodeSettings = { + permissions: { + allow: [], + deny: [], + ask: [], + }, + }; + + for (const settings of allSettings) { + if (settings.permissions) { + if (settings.permissions.allow) { + merged.permissions!.allow!.push(...settings.permissions.allow); + } + if (settings.permissions.deny) { + merged.permissions!.deny!.push(...settings.permissions.deny); + } + if (settings.permissions.ask) { + merged.permissions!.ask!.push(...settings.permissions.ask); + } + if (settings.permissions.additionalDirectories) { + merged.permissions!.additionalDirectories = [ + ...(merged.permissions!.additionalDirectories || []), + ...settings.permissions.additionalDirectories, + ]; + } + if (settings.permissions.defaultMode) { + merged.permissions!.defaultMode = settings.permissions.defaultMode; + } + } + + if (settings.env) { + merged.env = { ...merged.env, ...settings.env }; + } + } + + this.mergedSettings = merged; + } + + /** + * Sets up file watchers for all settings files + */ + private setupWatchers(): void { + const paths = [ + this.getUserSettingsPath(), + this.getProjectSettingsPath(), + this.getLocalSettingsPath(), + getManagedSettingsPath(), + ]; + + for (const filePath of paths) { + if (!filePath) continue; + + try { + const dir = path.dirname(filePath); + const filename = path.basename(filePath); + + if (fs.existsSync(dir)) { + const watcher = fs.watch(dir, (eventType, changedFilename) => { + if (changedFilename === filename) { + this.handleSettingsChange(); + } + }); + + watcher.on("error", (error) => { + this.logger.error(`Settings watcher error for ${filePath}:`, error); + }); + + this.watchers.push(watcher); + } + } catch (error) { + this.logger.error(`Failed to set up watcher for ${filePath}:`, error); + } + } + } + + /** + * Handles settings file changes with debouncing to avoid rapid reloads + */ + private handleSettingsChange(): void { + if (this.debounceTimer) { + clearTimeout(this.debounceTimer); + } + + this.debounceTimer = setTimeout(async () => { + this.debounceTimer = null; + try { + await this.loadAllSettings(); + this.onChange?.(); + } catch (error) { + this.logger.error("Failed to reload settings:", error); + } + }, 100); + } + + /** + * Checks if a tool invocation is allowed based on the loaded settings. + * + * @param toolName - The tool name (can be ACP-prefixed like mcp__acp__Read or plain like Read) + * @param toolInput - The tool input object + * @returns The permission decision and matching rule info + */ + checkPermission(toolName: string, toolInput: unknown): PermissionCheckResult { + if (!toolName.startsWith(ACP_TOOL_NAME_PREFIX)) { + return { decision: "ask" }; + } + + const permissions = this.mergedSettings.permissions; + + if (!permissions) { + return { decision: "ask" }; + } + + // Check deny rules first (highest priority) + for (const rule of permissions.deny || []) { + const parsed = parseRule(rule); + if (matchesRule(parsed, toolName, toolInput, this.cwd)) { + return { decision: "deny", rule, source: "deny" }; + } + } + + // Check allow rules + for (const rule of permissions.allow || []) { + const parsed = parseRule(rule); + if (matchesRule(parsed, toolName, toolInput, this.cwd)) { + return { decision: "allow", rule, source: "allow" }; + } + } + + // Check ask rules + for (const rule of permissions.ask || []) { + const parsed = parseRule(rule); + if (matchesRule(parsed, toolName, toolInput, this.cwd)) { + return { decision: "ask", rule, source: "ask" }; + } + } + + // No matching rule - default to ask + return { decision: "ask" }; + } + + /** + * Returns the current merged settings + */ + getSettings(): ClaudeCodeSettings { + return this.mergedSettings; + } + + /** + * Returns the current working directory + */ + getCwd(): string { + return this.cwd; + } + + /** + * Updates the working directory and reloads project-specific settings + */ + async setCwd(cwd: string): Promise { + if (this.cwd === cwd) { + return; + } + + this.dispose(); + this.cwd = cwd; + this.initialized = false; + await this.initialize(); + } + + /** + * Disposes of file watchers and cleans up resources + */ + dispose(): void { + if (this.debounceTimer) { + clearTimeout(this.debounceTimer); + this.debounceTimer = null; + } + + for (const watcher of this.watchers) { + watcher.close(); + } + this.watchers = []; + this.initialized = false; + } +} diff --git a/src/tests/acp-agent.test.ts b/src/tests/acp-agent.test.ts index 0cb2ee42..e92f8abc 100644 --- a/src/tests/acp-agent.test.ts +++ b/src/tests/acp-agent.test.ts @@ -493,50 +493,6 @@ describe("tool conversions", () => { }); }); - it("should handle WebFetch tool calls", () => { - const tool_use = { - type: "tool_use", - id: "toolu_01LxEjDn8ci9SAc3qG7LbbXV", - name: "WebFetch", - input: { - url: "https://agentclientprotocol.com", - prompt: - "Please provide a comprehensive summary of the content on this page, including what the Agent Client Protocol is, its main features, documentation links, and any other relevant information.", - }, - }; - - expect(toolInfoFromToolUse(tool_use, {})).toStrictEqual({ - kind: "fetch", - title: "Fetch https://agentclientprotocol.com", - content: [ - { - content: { - text: "Please provide a comprehensive summary of the content on this page, including what the Agent Client Protocol is, its main features, documentation links, and any other relevant information.", - type: "text", - }, - type: "content", - }, - ], - }); - }); - - it("should handle WebSearch tool calls", () => { - const tool_use = { - type: "tool_use", - id: "toolu_01NYMwiZFbdoQFxYxuQDFZXQ", - name: "WebSearch", - input: { - query: "agentclientprotocol.com", - }, - }; - - expect(toolInfoFromToolUse(tool_use, {})).toStrictEqual({ - kind: "fetch", - title: '"agentclientprotocol.com"', - content: [], - }); - }); - it("should handle KillBash entries", () => { const tool_use = { type: "tool_use", diff --git a/src/tests/settings.test.ts b/src/tests/settings.test.ts new file mode 100644 index 00000000..a70af10f --- /dev/null +++ b/src/tests/settings.test.ts @@ -0,0 +1,617 @@ +import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import { SettingsManager } from "../settings.js"; +import * as fs from "node:fs"; +import * as path from "node:path"; +import * as os from "node:os"; + +describe("SettingsManager", () => { + let tempDir: string; + let settingsManager: SettingsManager; + + beforeEach(async () => { + tempDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "settings-test-")); + }); + + afterEach(async () => { + settingsManager?.dispose(); + await fs.promises.rm(tempDir, { recursive: true, force: true }); + }); + + describe("permission checking", () => { + it("should return 'ask' when no settings exist", async () => { + settingsManager = new SettingsManager(tempDir); + await settingsManager.initialize(); + + const result = settingsManager.checkPermission("mcp__acp__Read", { + file_path: "/some/file.txt", + }); + expect(result.decision).toBe("ask"); + }); + + it("should return 'ask' for non-ACP tools (permission checks only apply to mcp__acp__* tools)", async () => { + const claudeDir = path.join(tempDir, ".claude"); + await fs.promises.mkdir(claudeDir, { recursive: true }); + await fs.promises.writeFile( + path.join(claudeDir, "settings.json"), + JSON.stringify({ + permissions: { + deny: ["Read"], + }, + }), + ); + + settingsManager = new SettingsManager(tempDir); + await settingsManager.initialize(); + + // Non-ACP tools should always return 'ask' regardless of rules + const result = settingsManager.checkPermission("Read", { file_path: "/some/file.txt" }); + expect(result.decision).toBe("ask"); + }); + + it("should allow tool use when matching allow rule exists", async () => { + const claudeDir = path.join(tempDir, ".claude"); + await fs.promises.mkdir(claudeDir, { recursive: true }); + await fs.promises.writeFile( + path.join(claudeDir, "settings.json"), + JSON.stringify({ + permissions: { + allow: ["Read"], + }, + }), + ); + + settingsManager = new SettingsManager(tempDir); + await settingsManager.initialize(); + + const result = settingsManager.checkPermission("mcp__acp__Read", { + file_path: "/some/file.txt", + }); + expect(result.decision).toBe("allow"); + expect(result.rule).toBe("Read"); + }); + + it("should deny tool use when matching deny rule exists", async () => { + const claudeDir = path.join(tempDir, ".claude"); + await fs.promises.mkdir(claudeDir, { recursive: true }); + await fs.promises.writeFile( + path.join(claudeDir, "settings.json"), + JSON.stringify({ + permissions: { + deny: ["Read(./.env)"], + }, + }), + ); + + settingsManager = new SettingsManager(tempDir); + await settingsManager.initialize(); + + const result = settingsManager.checkPermission("mcp__acp__Read", { + file_path: path.join(tempDir, ".env"), + }); + expect(result.decision).toBe("deny"); + expect(result.rule).toBe("Read(./.env)"); + }); + + it("should prioritize deny rules over allow rules", async () => { + const claudeDir = path.join(tempDir, ".claude"); + await fs.promises.mkdir(claudeDir, { recursive: true }); + await fs.promises.writeFile( + path.join(claudeDir, "settings.json"), + JSON.stringify({ + permissions: { + allow: ["Read"], + deny: ["Read(./.env)"], + }, + }), + ); + + settingsManager = new SettingsManager(tempDir); + await settingsManager.initialize(); + + // .env should be denied + const envResult = settingsManager.checkPermission("mcp__acp__Read", { + file_path: path.join(tempDir, ".env"), + }); + expect(envResult.decision).toBe("deny"); + + // other files should be allowed + const otherResult = settingsManager.checkPermission("mcp__acp__Read", { + file_path: path.join(tempDir, "other.txt"), + }); + expect(otherResult.decision).toBe("allow"); + }); + + it("should handle ACP-prefixed tool names", async () => { + const claudeDir = path.join(tempDir, ".claude"); + await fs.promises.mkdir(claudeDir, { recursive: true }); + await fs.promises.writeFile( + path.join(claudeDir, "settings.json"), + JSON.stringify({ + permissions: { + allow: ["Read"], + }, + }), + ); + + settingsManager = new SettingsManager(tempDir); + await settingsManager.initialize(); + + const result = settingsManager.checkPermission("mcp__acp__Read", { + file_path: "/some/file.txt", + }); + expect(result.decision).toBe("allow"); + }); + }); + + describe("Bash permission rules", () => { + it("should match exact Bash commands without :* wildcard", async () => { + const claudeDir = path.join(tempDir, ".claude"); + await fs.promises.mkdir(claudeDir, { recursive: true }); + await fs.promises.writeFile( + path.join(claudeDir, "settings.json"), + JSON.stringify({ + permissions: { + // Per docs: Bash(npm run build) matches the EXACT command "npm run build" + allow: ["Bash(npm run lint)"], + }, + }), + ); + + settingsManager = new SettingsManager(tempDir); + await settingsManager.initialize(); + + // Exact match should be allowed + const exactResult = settingsManager.checkPermission("mcp__acp__Bash", { + command: "npm run lint", + }); + expect(exactResult.decision).toBe("allow"); + + // Command with extra arguments should NOT match (exact match only) + const withArgsResult = settingsManager.checkPermission("mcp__acp__Bash", { + command: "npm run lint --fix", + }); + expect(withArgsResult.decision).toBe("ask"); + + // Similar command should NOT match (exact match only) + const similarResult = settingsManager.checkPermission("mcp__acp__Bash", { + command: "npm run linting", + }); + expect(similarResult.decision).toBe("ask"); + + // Different command should not match + const differentResult = settingsManager.checkPermission("mcp__acp__Bash", { + command: "npm run test", + }); + expect(differentResult.decision).toBe("ask"); + }); + + it("should match Bash commands with :* wildcard suffix (prefix matching)", async () => { + const claudeDir = path.join(tempDir, ".claude"); + await fs.promises.mkdir(claudeDir, { recursive: true }); + await fs.promises.writeFile( + path.join(claudeDir, "settings.json"), + JSON.stringify({ + permissions: { + // The :* suffix is a convention to make prefix matching explicit + allow: ["Bash(npm run:*)"], + }, + }), + ); + + settingsManager = new SettingsManager(tempDir); + await settingsManager.initialize(); + + // Any command starting with "npm run" should match (prefix matching with :*) + const lintResult = settingsManager.checkPermission("mcp__acp__Bash", { + command: "npm run lint", + }); + expect(lintResult.decision).toBe("allow"); + + const testResult = settingsManager.checkPermission("mcp__acp__Bash", { + command: "npm run test", + }); + expect(testResult.decision).toBe("allow"); + + // Commands with additional args also match + const withArgsResult = settingsManager.checkPermission("mcp__acp__Bash", { + command: "npm run test --watch", + }); + expect(withArgsResult.decision).toBe("allow"); + + // Non-matching command + const installResult = settingsManager.checkPermission("mcp__acp__Bash", { + command: "npm install", + }); + expect(installResult.decision).toBe("ask"); + }); + + it("should not allow shell operators to bypass prefix matching", async () => { + const claudeDir = path.join(tempDir, ".claude"); + await fs.promises.mkdir(claudeDir, { recursive: true }); + await fs.promises.writeFile( + path.join(claudeDir, "settings.json"), + JSON.stringify({ + permissions: { + allow: ["Bash(safe-cmd:*)"], + }, + }), + ); + + settingsManager = new SettingsManager(tempDir); + await settingsManager.initialize(); + + // Normal prefix match should work + const normalResult = settingsManager.checkPermission("mcp__acp__Bash", { + command: "safe-cmd --flag", + }); + expect(normalResult.decision).toBe("allow"); + + // Shell operators should NOT be allowed (per docs: Claude Code is aware of shell operators) + const andResult = settingsManager.checkPermission("mcp__acp__Bash", { + command: "safe-cmd && malicious-cmd", + }); + expect(andResult.decision).toBe("ask"); + + const orResult = settingsManager.checkPermission("mcp__acp__Bash", { + command: "safe-cmd || malicious-cmd", + }); + expect(orResult.decision).toBe("ask"); + + const semicolonResult = settingsManager.checkPermission("mcp__acp__Bash", { + command: "safe-cmd; malicious-cmd", + }); + expect(semicolonResult.decision).toBe("ask"); + + const pipeResult = settingsManager.checkPermission("mcp__acp__Bash", { + command: "safe-cmd | malicious-cmd", + }); + expect(pipeResult.decision).toBe("ask"); + + const subshellResult = settingsManager.checkPermission("mcp__acp__Bash", { + command: "safe-cmd $(malicious-cmd)", + }); + expect(subshellResult.decision).toBe("ask"); + + const backtickResult = settingsManager.checkPermission("mcp__acp__Bash", { + command: "safe-cmd `malicious-cmd`", + }); + expect(backtickResult.decision).toBe("ask"); + + const newlineResult = settingsManager.checkPermission("mcp__acp__Bash", { + command: "safe-cmd\nmalicious-cmd", + }); + expect(newlineResult.decision).toBe("ask"); + }); + + it("should deny dangerous Bash commands", async () => { + const claudeDir = path.join(tempDir, ".claude"); + await fs.promises.mkdir(claudeDir, { recursive: true }); + await fs.promises.writeFile( + path.join(claudeDir, "settings.json"), + JSON.stringify({ + permissions: { + deny: ["Bash(curl:*)", "Bash(wget:*)"], + }, + }), + ); + + settingsManager = new SettingsManager(tempDir); + await settingsManager.initialize(); + + const curlResult = settingsManager.checkPermission("mcp__acp__Bash", { + command: "curl https://example.com", + }); + expect(curlResult.decision).toBe("deny"); + + const wgetResult = settingsManager.checkPermission("mcp__acp__Bash", { + command: "wget https://example.com", + }); + expect(wgetResult.decision).toBe("deny"); + + const lsResult = settingsManager.checkPermission("mcp__acp__Bash", { command: "ls -la" }); + expect(lsResult.decision).toBe("ask"); + }); + }); + + describe("file path glob matching", () => { + it("should match exact file paths", async () => { + const claudeDir = path.join(tempDir, ".claude"); + await fs.promises.mkdir(claudeDir, { recursive: true }); + await fs.promises.writeFile( + path.join(claudeDir, "settings.json"), + JSON.stringify({ + permissions: { + deny: ["Read(./.env)"], + }, + }), + ); + + settingsManager = new SettingsManager(tempDir); + await settingsManager.initialize(); + + const envResult = settingsManager.checkPermission("mcp__acp__Read", { + file_path: path.join(tempDir, ".env"), + }); + expect(envResult.decision).toBe("deny"); + + const otherResult = settingsManager.checkPermission("mcp__acp__Read", { + file_path: path.join(tempDir, ".envrc"), + }); + expect(otherResult.decision).toBe("ask"); + }); + + it("should match glob patterns with single wildcard", async () => { + const claudeDir = path.join(tempDir, ".claude"); + await fs.promises.mkdir(claudeDir, { recursive: true }); + await fs.promises.writeFile( + path.join(claudeDir, "settings.json"), + JSON.stringify({ + permissions: { + deny: ["Read(./.env.*)"], + }, + }), + ); + + settingsManager = new SettingsManager(tempDir); + await settingsManager.initialize(); + + const envLocalResult = settingsManager.checkPermission("mcp__acp__Read", { + file_path: path.join(tempDir, ".env.local"), + }); + expect(envLocalResult.decision).toBe("deny"); + + const envProdResult = settingsManager.checkPermission("mcp__acp__Read", { + file_path: path.join(tempDir, ".env.production"), + }); + expect(envProdResult.decision).toBe("deny"); + + // Plain .env should not match .env.* + const plainEnvResult = settingsManager.checkPermission("mcp__acp__Read", { + file_path: path.join(tempDir, ".env"), + }); + expect(plainEnvResult.decision).toBe("ask"); + }); + + it("should match glob patterns with double wildcard (recursive)", async () => { + const claudeDir = path.join(tempDir, ".claude"); + await fs.promises.mkdir(claudeDir, { recursive: true }); + await fs.promises.writeFile( + path.join(claudeDir, "settings.json"), + JSON.stringify({ + permissions: { + deny: ["Read(./secrets/**)"], + }, + }), + ); + + settingsManager = new SettingsManager(tempDir); + await settingsManager.initialize(); + + const secretResult = settingsManager.checkPermission("mcp__acp__Read", { + file_path: path.join(tempDir, "secrets", "api-key.txt"), + }); + expect(secretResult.decision).toBe("deny"); + + const nestedSecretResult = settingsManager.checkPermission("mcp__acp__Read", { + file_path: path.join(tempDir, "secrets", "deep", "nested", "key.txt"), + }); + expect(nestedSecretResult.decision).toBe("deny"); + + const otherResult = settingsManager.checkPermission("mcp__acp__Read", { + file_path: path.join(tempDir, "public", "file.txt"), + }); + expect(otherResult.decision).toBe("ask"); + }); + + it("should handle home directory expansion", async () => { + const claudeDir = path.join(tempDir, ".claude"); + await fs.promises.mkdir(claudeDir, { recursive: true }); + await fs.promises.writeFile( + path.join(claudeDir, "settings.json"), + JSON.stringify({ + permissions: { + allow: ["Read(~/.zshrc)"], + }, + }), + ); + + settingsManager = new SettingsManager(tempDir); + await settingsManager.initialize(); + + const zshrcResult = settingsManager.checkPermission("mcp__acp__Read", { + file_path: path.join(os.homedir(), ".zshrc"), + }); + expect(zshrcResult.decision).toBe("allow"); + }); + }); + + describe("settings merging", () => { + it("should merge project and local settings", async () => { + const claudeDir = path.join(tempDir, ".claude"); + await fs.promises.mkdir(claudeDir, { recursive: true }); + + // Project settings + await fs.promises.writeFile( + path.join(claudeDir, "settings.json"), + JSON.stringify({ + permissions: { + allow: ["Read"], + }, + }), + ); + + // Local settings + await fs.promises.writeFile( + path.join(claudeDir, "settings.local.json"), + JSON.stringify({ + permissions: { + deny: ["Read(./.env)"], + }, + }), + ); + + settingsManager = new SettingsManager(tempDir); + await settingsManager.initialize(); + + // Read should be allowed in general + const readResult = settingsManager.checkPermission("mcp__acp__Read", { + file_path: path.join(tempDir, "file.txt"), + }); + expect(readResult.decision).toBe("allow"); + + // But .env should be denied (local settings take precedence) + const envResult = settingsManager.checkPermission("mcp__acp__Read", { + file_path: path.join(tempDir, ".env"), + }); + expect(envResult.decision).toBe("deny"); + }); + }); + + describe("ask rules", () => { + it("should return 'ask' for matching ask rules", async () => { + const claudeDir = path.join(tempDir, ".claude"); + await fs.promises.mkdir(claudeDir, { recursive: true }); + await fs.promises.writeFile( + path.join(claudeDir, "settings.json"), + JSON.stringify({ + permissions: { + ask: ["Bash(git push:*)"], + }, + }), + ); + + settingsManager = new SettingsManager(tempDir); + await settingsManager.initialize(); + + const result = settingsManager.checkPermission("mcp__acp__Bash", { + command: "git push origin main", + }); + expect(result.decision).toBe("ask"); + expect(result.rule).toBe("Bash(git push:*)"); + }); + }); + + describe("Edit and Write tools", () => { + it("should handle Edit tool permissions", async () => { + const claudeDir = path.join(tempDir, ".claude"); + await fs.promises.mkdir(claudeDir, { recursive: true }); + await fs.promises.writeFile( + path.join(claudeDir, "settings.json"), + JSON.stringify({ + permissions: { + deny: ["Edit(./package-lock.json)"], + }, + }), + ); + + settingsManager = new SettingsManager(tempDir); + await settingsManager.initialize(); + + const lockFileResult = settingsManager.checkPermission("mcp__acp__Edit", { + file_path: path.join(tempDir, "package-lock.json"), + }); + expect(lockFileResult.decision).toBe("deny"); + + const otherResult = settingsManager.checkPermission("mcp__acp__Edit", { + file_path: path.join(tempDir, "package.json"), + }); + expect(otherResult.decision).toBe("ask"); + }); + + it("should handle mcp__acp__Edit and mcp__acp__Write tool names", async () => { + const claudeDir = path.join(tempDir, ".claude"); + await fs.promises.mkdir(claudeDir, { recursive: true }); + await fs.promises.writeFile( + path.join(claudeDir, "settings.json"), + JSON.stringify({ + permissions: { + allow: ["Edit", "Write"], + }, + }), + ); + + settingsManager = new SettingsManager(tempDir); + await settingsManager.initialize(); + + const editResult = settingsManager.checkPermission("mcp__acp__Edit", { + file_path: "/some/file.ts", + }); + expect(editResult.decision).toBe("allow"); + + const writeResult = settingsManager.checkPermission("mcp__acp__Write", { + file_path: "/some/file.ts", + }); + expect(writeResult.decision).toBe("allow"); + }); + }); + + describe("getSettings", () => { + it("should return merged settings", async () => { + const claudeDir = path.join(tempDir, ".claude"); + await fs.promises.mkdir(claudeDir, { recursive: true }); + await fs.promises.writeFile( + path.join(claudeDir, "settings.json"), + JSON.stringify({ + permissions: { + allow: ["Read", "Bash(npm:*)"], + deny: ["Read(./.env)"], + }, + env: { + FOO: "bar", + }, + }), + ); + + settingsManager = new SettingsManager(tempDir); + await settingsManager.initialize(); + + const settings = settingsManager.getSettings(); + expect(settings.permissions?.allow).toContain("Read"); + expect(settings.permissions?.allow).toContain("Bash(npm:*)"); + expect(settings.permissions?.deny).toContain("Read(./.env)"); + expect(settings.env?.FOO).toBe("bar"); + }); + }); + + describe("setCwd", () => { + it("should reload settings when cwd changes", async () => { + const claudeDir1 = path.join(tempDir, ".claude"); + await fs.promises.mkdir(claudeDir1, { recursive: true }); + await fs.promises.writeFile( + path.join(claudeDir1, "settings.json"), + JSON.stringify({ + permissions: { + allow: ["Read"], + }, + }), + ); + + settingsManager = new SettingsManager(tempDir); + await settingsManager.initialize(); + + let result = settingsManager.checkPermission("mcp__acp__Read", { file_path: "/file.txt" }); + expect(result.decision).toBe("allow"); + + // Create a new temp directory with different settings + const tempDir2 = await fs.promises.mkdtemp(path.join(os.tmpdir(), "settings-test-2-")); + const claudeDir2 = path.join(tempDir2, ".claude"); + await fs.promises.mkdir(claudeDir2, { recursive: true }); + await fs.promises.writeFile( + path.join(claudeDir2, "settings.json"), + JSON.stringify({ + permissions: { + deny: ["Read"], + }, + }), + ); + + await settingsManager.setCwd(tempDir2); + + result = settingsManager.checkPermission("mcp__acp__Read", { file_path: "/file.txt" }); + expect(result.decision).toBe("deny"); + + // Cleanup second temp dir + await fs.promises.rm(tempDir2, { recursive: true, force: true }); + }); + }); +}); diff --git a/src/tools.ts b/src/tools.ts index 46d5c4e7..7d100e20 100644 --- a/src/tools.ts +++ b/src/tools.ts @@ -1,6 +1,28 @@ import { PlanEntry, ToolCallContent, ToolCallLocation, ToolKind } from "@agentclientprotocol/sdk"; -import { replaceAndCalculateLocation, SYSTEM_REMINDER, toolNames } from "./mcp-server.js"; +import { replaceAndCalculateLocation, SYSTEM_REMINDER } from "./mcp-server.js"; import { ToolResultBlockParam, WebSearchToolResultBlockParam } from "@anthropic-ai/sdk/resources"; + +const acpUnqualifiedToolNames = { + read: "Read", + edit: "Edit", + write: "Write", + bash: "Bash", + killShell: "KillShell", + bashOutput: "BashOutput", +}; + +export const ACP_TOOL_NAME_PREFIX = "mcp__acp__"; +export const acpToolNames = { + read: ACP_TOOL_NAME_PREFIX + acpUnqualifiedToolNames.read, + edit: ACP_TOOL_NAME_PREFIX + acpUnqualifiedToolNames.edit, + write: ACP_TOOL_NAME_PREFIX + acpUnqualifiedToolNames.write, + bash: ACP_TOOL_NAME_PREFIX + acpUnqualifiedToolNames.bash, + killShell: ACP_TOOL_NAME_PREFIX + acpUnqualifiedToolNames.killShell, + bashOutput: ACP_TOOL_NAME_PREFIX + acpUnqualifiedToolNames.bashOutput, +}; + +export const EDIT_TOOL_NAMES = [acpToolNames.edit, acpToolNames.write]; + import { BetaBashCodeExecutionToolResultBlockParam, BetaCodeExecutionToolResultBlockParam, @@ -12,6 +34,7 @@ import { } from "@anthropic-ai/sdk/resources/beta.mjs"; import { HookCallback } from "@anthropic-ai/claude-agent-sdk"; import { Logger } from "./acp-agent.js"; +import { SettingsManager } from "./settings.js"; interface ToolInfo { title: string; @@ -75,7 +98,7 @@ export function toolInfoFromToolUse( }; case "Bash": - case toolNames.bash: + case acpToolNames.bash: return { title: input?.command ? "`" + input.command.replaceAll("`", "\\`") + "`" : "Terminal", kind: "execute", @@ -91,7 +114,7 @@ export function toolInfoFromToolUse( }; case "BashOutput": - case toolNames.bashOutput: + case acpToolNames.bashOutput: return { title: "Tail Logs", kind: "execute", @@ -99,14 +122,14 @@ export function toolInfoFromToolUse( }; case "KillShell": - case toolNames.killShell: + case acpToolNames.killShell: return { title: "Kill Process", kind: "execute", content: [], }; - case toolNames.read: { + case acpToolNames.read: { let limit = ""; if (input.limit) { limit = @@ -152,7 +175,7 @@ export function toolInfoFromToolUse( locations: [], }; - case toolNames.edit: + case acpToolNames.edit: case "Edit": { const path = input?.file_path ?? input?.file_path; let oldText = input.old_string ?? null; @@ -198,7 +221,7 @@ export function toolInfoFromToolUse( }; } - case toolNames.write: { + case acpToolNames.write: { let content: ToolCallContent[] = []; if (input && input.file_path) { content = [ @@ -420,7 +443,7 @@ export function toolUpdateFromToolResult( ): ToolUpdate { switch (toolUse?.name) { case "Read": - case toolNames.read: + case acpToolNames.read: if (Array.isArray(toolResult.content) && toolResult.content.length > 0) { return { content: toolResult.content.map((content: any) => ({ @@ -449,11 +472,11 @@ export function toolUpdateFromToolResult( } return {}; - case toolNames.bash: + case acpToolNames.bash: case "edit": case "Edit": - case toolNames.edit: - case toolNames.write: + case acpToolNames.edit: + case acpToolNames.write: case "Write": { if ( "is_error" in toolResult && @@ -596,3 +619,54 @@ export const createPostToolUseHook = } return { continue: true }; }; + +/** + * Creates a PreToolUse hook that checks permissions using the SettingsManager. + * This runs before the SDK's built-in permission rules, allowing us to enforce + * our own permission settings for ACP-prefixed tools. + */ +export const createPreToolUseHook = + (settingsManager: SettingsManager, logger: Logger = console): HookCallback => + async (input: any, _toolUseID: string | undefined) => { + if (input.hook_event_name !== "PreToolUse") { + return { continue: true }; + } + + const toolName = input.tool_name; + const toolInput = input.tool_input; + + const permissionCheck = settingsManager.checkPermission(toolName, toolInput); + + if (permissionCheck.decision !== "ask") { + logger.log( + `[PreToolUseHook] Tool: ${toolName}, Decision: ${permissionCheck.decision}, Rule: ${permissionCheck.rule}`, + ); + } + + switch (permissionCheck.decision) { + case "allow": + return { + continue: true, + hookSpecificOutput: { + hookEventName: "PreToolUse" as const, + permissionDecision: "allow" as const, + permissionDecisionReason: `Allowed by settings rule: ${permissionCheck.rule}`, + }, + }; + + case "deny": + return { + continue: true, + hookSpecificOutput: { + hookEventName: "PreToolUse" as const, + permissionDecision: "deny" as const, + permissionDecisionReason: `Denied by settings rule: ${permissionCheck.rule}`, + }, + }; + + case "ask": + default: + // Let the normal permission flow continue + return { continue: true }; + } + }; diff --git a/src/utils.ts b/src/utils.ts index 60bbd22b..d81e6215 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -3,8 +3,8 @@ import { Readable, Writable } from "node:stream"; import { WritableStream, ReadableStream } from "node:stream/web"; import { readFileSync } from "node:fs"; -import { platform } from "node:os"; import { Logger } from "./acp-agent.js"; +import { ClaudeCodeSettings, getManagedSettingsPath } from "./settings.js"; // Useful for bridging push-based and async-iterator-based code. export class Pushable implements AsyncIterable { @@ -90,39 +90,15 @@ export function sleep(time: number): Promise { return new Promise((resolve) => setTimeout(resolve, time)); } -interface ManagedSettings { - permissions?: { - allow?: string[]; - deny?: string[]; - }; - env?: Record; -} - -// Following the rules in https://docs.anthropic.com/en/docs/claude-code/settings#settings-files -// This can be removed once the SDK supports it natively. -function getManagedSettingsPath(): string { - const os = platform(); - switch (os) { - case "darwin": - return "/Library/Application Support/ClaudeCode/managed-settings.json"; - case "linux": // including WSL - return "/etc/claude-code/managed-settings.json"; - case "win32": - return "C:\\ProgramData\\ClaudeCode\\managed-settings.json"; - default: - return "/etc/claude-code/managed-settings.json"; - } -} - -export function loadManagedSettings(): ManagedSettings | null { +export function loadManagedSettings(): ClaudeCodeSettings | null { try { - return JSON.parse(readFileSync(getManagedSettingsPath(), "utf8")) as ManagedSettings; + return JSON.parse(readFileSync(getManagedSettingsPath(), "utf8")) as ClaudeCodeSettings; } catch { return null; } } -export function applyEnvironmentSettings(settings: ManagedSettings): void { +export function applyEnvironmentSettings(settings: ClaudeCodeSettings): void { if (settings.env) { for (const [key, value] of Object.entries(settings.env)) { process.env[key] = value;