@zed-industries/claude-code-acp
Advanced tools
+422
| 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"; | ||
| /** | ||
| * 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) { | ||
| 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 = { | ||
| mcp__acp__Read: (input) => input?.file_path, | ||
| mcp__acp__Edit: (input) => input?.file_path, | ||
| mcp__acp__Write: (input) => input?.file_path, | ||
| mcp__acp__Bash: (input) => input?.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) { | ||
| 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, cwd) { | ||
| 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, filePath, cwd) { | ||
| 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, toolName, toolInput, cwd) { | ||
| // 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) { | ||
| if (!filePath) { | ||
| return {}; | ||
| } | ||
| try { | ||
| const content = await fs.promises.readFile(filePath, "utf-8"); | ||
| return JSON.parse(content); | ||
| } | ||
| catch { | ||
| return {}; | ||
| } | ||
| } | ||
| /** | ||
| * Gets the enterprise settings path based on the current platform | ||
| */ | ||
| export function getManagedSettingsPath() { | ||
| 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"; | ||
| } | ||
| } | ||
| /** | ||
| * 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 (<cwd>/.claude/settings.json) | ||
| * 3. Local project settings (<cwd>/.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 { | ||
| constructor(cwd, options) { | ||
| this.userSettings = {}; | ||
| this.projectSettings = {}; | ||
| this.localSettings = {}; | ||
| this.enterpriseSettings = {}; | ||
| this.mergedSettings = {}; | ||
| this.watchers = []; | ||
| this.initialized = false; | ||
| this.debounceTimer = null; | ||
| 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() { | ||
| if (this.initialized) { | ||
| return; | ||
| } | ||
| await this.loadAllSettings(); | ||
| this.setupWatchers(); | ||
| this.initialized = true; | ||
| } | ||
| /** | ||
| * Returns the path to the user settings file | ||
| */ | ||
| getUserSettingsPath() { | ||
| return path.join(CLAUDE_CONFIG_DIR, "settings.json"); | ||
| } | ||
| /** | ||
| * Returns the path to the project settings file | ||
| */ | ||
| getProjectSettingsPath() { | ||
| return path.join(this.cwd, ".claude", "settings.json"); | ||
| } | ||
| /** | ||
| * Returns the path to the local project settings file | ||
| */ | ||
| getLocalSettingsPath() { | ||
| return path.join(this.cwd, ".claude", "settings.local.json"); | ||
| } | ||
| /** | ||
| * Loads settings from all sources | ||
| */ | ||
| async loadAllSettings() { | ||
| 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. | ||
| */ | ||
| mergeSettings() { | ||
| const allSettings = [ | ||
| this.userSettings, | ||
| this.projectSettings, | ||
| this.localSettings, | ||
| this.enterpriseSettings, | ||
| ]; | ||
| const merged = { | ||
| 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 | ||
| */ | ||
| setupWatchers() { | ||
| 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 | ||
| */ | ||
| handleSettingsChange() { | ||
| 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, toolInput) { | ||
| 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() { | ||
| return this.mergedSettings; | ||
| } | ||
| /** | ||
| * Returns the current working directory | ||
| */ | ||
| getCwd() { | ||
| return this.cwd; | ||
| } | ||
| /** | ||
| * Updates the working directory and reloads project-specific settings | ||
| */ | ||
| async setCwd(cwd) { | ||
| 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() { | ||
| if (this.debounceTimer) { | ||
| clearTimeout(this.debounceTimer); | ||
| this.debounceTimer = null; | ||
| } | ||
| for (const watcher of this.watchers) { | ||
| watcher.close(); | ||
| } | ||
| this.watchers = []; | ||
| this.initialized = false; | ||
| } | ||
| } |
| 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; | ||
| let 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 }); | ||
| }); | ||
| }); | ||
| }); |
+213
-178
| import { AgentSideConnection, ndJsonStream, RequestError, } from "@agentclientprotocol/sdk"; | ||
| import { SettingsManager } from "./settings.js"; | ||
| import { query, } from "@anthropic-ai/claude-agent-sdk"; | ||
@@ -7,4 +8,5 @@ import * as fs from "node:fs"; | ||
| import { nodeToWebReadable, nodeToWebWritable, Pushable, unreachable } from "./utils.js"; | ||
| import { createMcpServer, EDIT_TOOL_NAMES, toolNames } from "./mcp-server.js"; | ||
| import { toolInfoFromToolUse, planEntries, toolUpdateFromToolResult, registerHookCallback, createPostToolUseHook, } from "./tools.js"; | ||
| import { createMcpServer } from "./mcp-server.js"; | ||
| import { EDIT_TOOL_NAMES, acpToolNames } from "./tools.js"; | ||
| import { toolInfoFromToolUse, planEntries, toolUpdateFromToolResult, registerHookCallback, createPostToolUseHook, createPreToolUseHook, } from "./tools.js"; | ||
| import packageJson from "../package.json" with { type: "json" }; | ||
@@ -55,2 +57,6 @@ import { randomUUID } from "node:crypto"; | ||
| }, | ||
| sessionCapabilities: { | ||
| // TODO: announce fork capability when sessionId handling is fixed | ||
| // fork: {}, | ||
| }, | ||
| }, | ||
@@ -70,180 +76,16 @@ agentInfo: { | ||
| } | ||
| // Extract options from _meta if provided | ||
| const userProvidedOptions = params._meta?.claudeCode?.options; | ||
| const sessionId = userProvidedOptions?.resume || randomUUID(); | ||
| const input = new Pushable(); | ||
| const mcpServers = {}; | ||
| if (Array.isArray(params.mcpServers)) { | ||
| for (const server of params.mcpServers) { | ||
| if ("type" in server) { | ||
| mcpServers[server.name] = { | ||
| type: server.type, | ||
| url: server.url, | ||
| headers: server.headers | ||
| ? Object.fromEntries(server.headers.map((e) => [e.name, e.value])) | ||
| : undefined, | ||
| }; | ||
| } | ||
| else { | ||
| mcpServers[server.name] = { | ||
| type: "stdio", | ||
| command: server.command, | ||
| args: server.args, | ||
| env: server.env | ||
| ? Object.fromEntries(server.env.map((e) => [e.name, e.value])) | ||
| : undefined, | ||
| }; | ||
| } | ||
| } | ||
| } | ||
| // Only add the acp MCP server if built-in tools are not disabled | ||
| if (!params._meta?.disableBuiltInTools) { | ||
| const server = createMcpServer(this, sessionId, this.clientCapabilities); | ||
| mcpServers["acp"] = { | ||
| type: "sdk", | ||
| name: "acp", | ||
| instance: server, | ||
| }; | ||
| } | ||
| let systemPrompt = { type: "preset", preset: "claude_code" }; | ||
| if (params._meta?.systemPrompt) { | ||
| const customPrompt = params._meta.systemPrompt; | ||
| if (typeof customPrompt === "string") { | ||
| systemPrompt = customPrompt; | ||
| } | ||
| else if (typeof customPrompt === "object" && | ||
| "append" in customPrompt && | ||
| typeof customPrompt.append === "string") { | ||
| systemPrompt.append = customPrompt.append; | ||
| } | ||
| } | ||
| const permissionMode = "default"; | ||
| const extraArgs = { ...userProvidedOptions?.extraArgs }; | ||
| if (userProvidedOptions?.resume === undefined) { | ||
| // Set our own session id if not resuming an existing session. | ||
| extraArgs["session-id"] = sessionId; | ||
| } | ||
| const options = { | ||
| systemPrompt, | ||
| settingSources: ["user", "project", "local"], | ||
| stderr: (err) => this.logger.error(err), | ||
| ...userProvidedOptions, | ||
| // Override certain fields that must be controlled by ACP | ||
| return await this.createSession(params, { | ||
| // Revisit these meta values once we support resume | ||
| resume: params._meta?.claudeCode?.options?.resume, | ||
| }); | ||
| } | ||
| async forkSession(params) { | ||
| return await this.createSession({ | ||
| cwd: params.cwd, | ||
| includePartialMessages: true, | ||
| mcpServers: { ...(userProvidedOptions?.mcpServers || {}), ...mcpServers }, | ||
| extraArgs, | ||
| // If we want bypassPermissions to be an option, we have to allow it here. | ||
| // But it doesn't work in root mode, so we only activate it if it will work. | ||
| allowDangerouslySkipPermissions: !IS_ROOT, | ||
| permissionMode, | ||
| canUseTool: this.canUseTool(sessionId), | ||
| // note: although not documented by the types, passing an absolute path | ||
| // here works to find zed's managed node version. | ||
| executable: process.execPath, | ||
| ...(process.env.CLAUDE_CODE_EXECUTABLE && { | ||
| pathToClaudeCodeExecutable: process.env.CLAUDE_CODE_EXECUTABLE, | ||
| }), | ||
| hooks: { | ||
| ...userProvidedOptions?.hooks, | ||
| PostToolUse: [ | ||
| ...(userProvidedOptions?.hooks?.PostToolUse || []), | ||
| { | ||
| hooks: [createPostToolUseHook(this.logger)], | ||
| }, | ||
| ], | ||
| }, | ||
| }; | ||
| const allowedTools = []; | ||
| const disallowedTools = []; | ||
| // Check if built-in tools should be disabled | ||
| const disableBuiltInTools = params._meta?.disableBuiltInTools === true; | ||
| if (!disableBuiltInTools) { | ||
| if (this.clientCapabilities?.fs?.readTextFile) { | ||
| allowedTools.push(toolNames.read); | ||
| disallowedTools.push("Read"); | ||
| } | ||
| if (this.clientCapabilities?.fs?.writeTextFile) { | ||
| disallowedTools.push("Write", "Edit"); | ||
| } | ||
| if (this.clientCapabilities?.terminal) { | ||
| allowedTools.push(toolNames.bashOutput, toolNames.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, "Read", "Write", "Edit", "Bash", "BashOutput", "KillShell", "Glob", "Grep", "Task", "TodoWrite", "ExitPlanMode", "WebSearch", "WebFetch", "AskUserQuestion", "SlashCommand", "Skill", "NotebookEdit"); | ||
| } | ||
| if (allowedTools.length > 0) { | ||
| options.allowedTools = allowedTools; | ||
| } | ||
| if (disallowedTools.length > 0) { | ||
| options.disallowedTools = disallowedTools; | ||
| } | ||
| // Handle abort controller from meta options | ||
| const abortController = userProvidedOptions?.abortController; | ||
| if (abortController?.signal.aborted) { | ||
| throw new Error("Cancelled"); | ||
| } | ||
| const q = query({ | ||
| prompt: input, | ||
| options, | ||
| mcpServers: params.mcpServers ?? [], | ||
| _meta: params._meta, | ||
| }, { | ||
| resume: params.sessionId, | ||
| forkSession: true, | ||
| }); | ||
| this.sessions[sessionId] = { | ||
| query: q, | ||
| input: input, | ||
| cancelled: false, | ||
| permissionMode, | ||
| }; | ||
| const availableCommands = await getAvailableSlashCommands(q); | ||
| const models = await getAvailableModels(q); | ||
| // Needs to happen after we return the session | ||
| setTimeout(() => { | ||
| this.client.sessionUpdate({ | ||
| sessionId, | ||
| update: { | ||
| sessionUpdate: "available_commands_update", | ||
| availableCommands, | ||
| }, | ||
| }); | ||
| }, 0); | ||
| const availableModes = [ | ||
| { | ||
| id: "default", | ||
| name: "Default", | ||
| description: "Standard behavior, prompts for dangerous operations", | ||
| }, | ||
| { | ||
| id: "acceptEdits", | ||
| name: "Accept Edits", | ||
| description: "Auto-accept file edit operations", | ||
| }, | ||
| { | ||
| id: "plan", | ||
| name: "Plan Mode", | ||
| description: "Planning mode, no actual tool execution", | ||
| }, | ||
| { | ||
| id: "dontAsk", | ||
| name: "Don't Ask", | ||
| description: "Don't prompt for permissions, deny if not pre-approved", | ||
| }, | ||
| ]; | ||
| // Only works in non-root mode | ||
| if (!IS_ROOT) { | ||
| availableModes.push({ | ||
| id: "bypassPermissions", | ||
| name: "Bypass Permissions", | ||
| description: "Bypass all permission checks", | ||
| }); | ||
| } | ||
| return { | ||
| sessionId, | ||
| models, | ||
| modes: { | ||
| currentModeId: permissionMode, | ||
| availableModes, | ||
| }, | ||
| }; | ||
| } | ||
@@ -540,2 +382,195 @@ async authenticate(_params) { | ||
| } | ||
| async createSession(params, creationOpts = {}) { | ||
| const sessionId = creationOpts.resume ?? randomUUID(); | ||
| const input = new Pushable(); | ||
| const settingsManager = new SettingsManager(params.cwd, { | ||
| logger: this.logger, | ||
| }); | ||
| await settingsManager.initialize(); | ||
| const mcpServers = {}; | ||
| if (Array.isArray(params.mcpServers)) { | ||
| for (const server of params.mcpServers) { | ||
| if ("type" in server) { | ||
| mcpServers[server.name] = { | ||
| type: server.type, | ||
| url: server.url, | ||
| headers: server.headers | ||
| ? Object.fromEntries(server.headers.map((e) => [e.name, e.value])) | ||
| : undefined, | ||
| }; | ||
| } | ||
| else { | ||
| mcpServers[server.name] = { | ||
| type: "stdio", | ||
| command: server.command, | ||
| args: server.args, | ||
| env: server.env | ||
| ? Object.fromEntries(server.env.map((e) => [e.name, e.value])) | ||
| : undefined, | ||
| }; | ||
| } | ||
| } | ||
| } | ||
| // Only add the acp MCP server if built-in tools are not disabled | ||
| if (!params._meta?.disableBuiltInTools) { | ||
| const server = createMcpServer(this, sessionId, this.clientCapabilities); | ||
| mcpServers["acp"] = { | ||
| type: "sdk", | ||
| name: "acp", | ||
| instance: server, | ||
| }; | ||
| } | ||
| let systemPrompt = { type: "preset", preset: "claude_code" }; | ||
| if (params._meta?.systemPrompt) { | ||
| const customPrompt = params._meta.systemPrompt; | ||
| if (typeof customPrompt === "string") { | ||
| systemPrompt = customPrompt; | ||
| } | ||
| else if (typeof customPrompt === "object" && | ||
| "append" in customPrompt && | ||
| typeof customPrompt.append === "string") { | ||
| systemPrompt.append = customPrompt.append; | ||
| } | ||
| } | ||
| const permissionMode = "default"; | ||
| // Extract options from _meta if provided | ||
| const userProvidedOptions = params._meta?.claudeCode?.options; | ||
| const extraArgs = { ...userProvidedOptions?.extraArgs }; | ||
| if (creationOpts?.resume === undefined) { | ||
| // Set our own session id if not resuming an existing session. | ||
| // TODO: find a way to make this work for fork | ||
| extraArgs["session-id"] = sessionId; | ||
| } | ||
| const options = { | ||
| systemPrompt, | ||
| settingSources: ["user", "project", "local"], | ||
| stderr: (err) => this.logger.error(err), | ||
| ...userProvidedOptions, | ||
| // Override certain fields that must be controlled by ACP | ||
| cwd: params.cwd, | ||
| includePartialMessages: true, | ||
| mcpServers: { ...(userProvidedOptions?.mcpServers || {}), ...mcpServers }, | ||
| extraArgs, | ||
| // If we want bypassPermissions to be an option, we have to allow it here. | ||
| // But it doesn't work in root mode, so we only activate it if it will work. | ||
| allowDangerouslySkipPermissions: !IS_ROOT, | ||
| permissionMode, | ||
| canUseTool: this.canUseTool(sessionId), | ||
| // note: although not documented by the types, passing an absolute path | ||
| // here works to find zed's managed node version. | ||
| executable: process.execPath, | ||
| ...(process.env.CLAUDE_CODE_EXECUTABLE && { | ||
| pathToClaudeCodeExecutable: process.env.CLAUDE_CODE_EXECUTABLE, | ||
| }), | ||
| hooks: { | ||
| ...userProvidedOptions?.hooks, | ||
| PreToolUse: [ | ||
| ...(userProvidedOptions?.hooks?.PreToolUse || []), | ||
| { | ||
| hooks: [createPreToolUseHook(settingsManager, this.logger)], | ||
| }, | ||
| ], | ||
| PostToolUse: [ | ||
| ...(userProvidedOptions?.hooks?.PostToolUse || []), | ||
| { | ||
| hooks: [createPostToolUseHook(this.logger)], | ||
| }, | ||
| ], | ||
| }, | ||
| ...creationOpts, | ||
| }; | ||
| const allowedTools = []; | ||
| const disallowedTools = []; | ||
| // Check if built-in tools should be disabled | ||
| const disableBuiltInTools = params._meta?.disableBuiltInTools === true; | ||
| if (!disableBuiltInTools) { | ||
| if (this.clientCapabilities?.fs?.readTextFile) { | ||
| allowedTools.push(acpToolNames.read); | ||
| disallowedTools.push("Read"); | ||
| } | ||
| if (this.clientCapabilities?.fs?.writeTextFile) { | ||
| disallowedTools.push("Write", "Edit"); | ||
| } | ||
| if (this.clientCapabilities?.terminal) { | ||
| 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(acpToolNames.read, acpToolNames.write, acpToolNames.edit, acpToolNames.bash, acpToolNames.bashOutput, acpToolNames.killShell, "Read", "Write", "Edit", "Bash", "BashOutput", "KillShell", "Glob", "Grep", "Task", "TodoWrite", "ExitPlanMode", "WebSearch", "WebFetch", "AskUserQuestion", "SlashCommand", "Skill", "NotebookEdit"); | ||
| } | ||
| if (allowedTools.length > 0) { | ||
| options.allowedTools = allowedTools; | ||
| } | ||
| if (disallowedTools.length > 0) { | ||
| options.disallowedTools = disallowedTools; | ||
| } | ||
| // Handle abort controller from meta options | ||
| const abortController = userProvidedOptions?.abortController; | ||
| if (abortController?.signal.aborted) { | ||
| throw new Error("Cancelled"); | ||
| } | ||
| const q = query({ | ||
| prompt: input, | ||
| options, | ||
| }); | ||
| this.sessions[sessionId] = { | ||
| query: q, | ||
| input: input, | ||
| cancelled: false, | ||
| permissionMode, | ||
| settingsManager, | ||
| }; | ||
| const availableCommands = await getAvailableSlashCommands(q); | ||
| const models = await getAvailableModels(q); | ||
| // Needs to happen after we return the session | ||
| setTimeout(() => { | ||
| this.client.sessionUpdate({ | ||
| sessionId, | ||
| update: { | ||
| sessionUpdate: "available_commands_update", | ||
| availableCommands, | ||
| }, | ||
| }); | ||
| }, 0); | ||
| const availableModes = [ | ||
| { | ||
| id: "default", | ||
| name: "Default", | ||
| description: "Standard behavior, prompts for dangerous operations", | ||
| }, | ||
| { | ||
| id: "acceptEdits", | ||
| name: "Accept Edits", | ||
| description: "Auto-accept file edit operations", | ||
| }, | ||
| { | ||
| id: "plan", | ||
| name: "Plan Mode", | ||
| description: "Planning mode, no actual tool execution", | ||
| }, | ||
| { | ||
| id: "dontAsk", | ||
| name: "Don't Ask", | ||
| description: "Don't prompt for permissions, deny if not pre-approved", | ||
| }, | ||
| ]; | ||
| // Only works in non-root mode | ||
| if (!IS_ROOT) { | ||
| availableModes.push({ | ||
| id: "bypassPermissions", | ||
| name: "Bypass Permissions", | ||
| description: "Bypass all permission checks", | ||
| }); | ||
| } | ||
| return { | ||
| sessionId, | ||
| models, | ||
| modes: { | ||
| currentModeId: permissionMode, | ||
| availableModes, | ||
| }, | ||
| }; | ||
| } | ||
| } | ||
@@ -542,0 +577,0 @@ async function getAvailableModels(query) { |
+3
-2
| // Export the main agent class and utilities for library usage | ||
| export { ClaudeAcpAgent, runAcp, toAcpNotifications, streamEventToAcpNotifications, } from "./acp-agent.js"; | ||
| export { loadManagedSettings, applyEnvironmentSettings, nodeToWebReadable, nodeToWebWritable, 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, } from "./settings.js"; |
+13
-22
@@ -60,2 +60,3 @@ var __addDisposableResource = (this && this.__addDisposableResource) || function (env, value, async) { | ||
| import { sleep, unreachable, extractLinesWithByteLimit } from "./utils.js"; | ||
| import { acpToolNames } from "./tools.js"; | ||
| export const SYSTEM_REMINDER = ` | ||
@@ -75,12 +76,2 @@ | ||
| }; | ||
| 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, sessionId, clientCapabilities) { | ||
@@ -142,3 +133,3 @@ /** | ||
| 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. | ||
@@ -153,3 +144,3 @@ 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. | ||
| - 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.`, | ||
@@ -236,3 +227,3 @@ inputSchema: { | ||
| 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. | ||
@@ -242,3 +233,3 @@ | ||
| - 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. | ||
@@ -293,7 +284,7 @@ - NEVER proactively create documentation files (*.md) or README files. Only create documentation files if explicitly requested by the User. | ||
| 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. | ||
@@ -377,3 +368,3 @@ - ALWAYS prefer editing existing files in the codebase. NEVER write new files unless explicitly required. | ||
| In sessions with ${toolNames.bash} always use it instead of Bash`, | ||
| In sessions with ${acpToolNames.bash} always use it instead of Bash`, | ||
| inputSchema: { | ||
@@ -398,3 +389,3 @@ command: z.string().describe("The command to execute"), | ||
| .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.`), | ||
| .describe(`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.`), | ||
| }, | ||
@@ -519,7 +510,7 @@ }, async (input, extra) => { | ||
| 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}\``), | ||
| }, | ||
@@ -565,7 +556,7 @@ }, async (input) => { | ||
| 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}\``), | ||
| }, | ||
@@ -572,0 +563,0 @@ }, async (input) => { |
@@ -413,41 +413,2 @@ import { describe, it, expect, beforeAll, afterAll } from "vitest"; | ||
| }); | ||
| 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", () => { | ||
@@ -454,0 +415,0 @@ const tool_use = { |
+69
-11
@@ -1,2 +0,20 @@ | ||
| import { replaceAndCalculateLocation, SYSTEM_REMINDER, toolNames } from "./mcp-server.js"; | ||
| import { replaceAndCalculateLocation, SYSTEM_REMINDER } from "./mcp-server.js"; | ||
| 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]; | ||
| export function toolInfoFromToolUse(toolUse, cachedFileContent, logger = console) { | ||
@@ -41,3 +59,3 @@ const name = toolUse.name; | ||
| case "Bash": | ||
| case toolNames.bash: | ||
| case acpToolNames.bash: | ||
| return { | ||
@@ -56,3 +74,3 @@ title: input?.command ? "`" + input.command.replaceAll("`", "\\`") + "`" : "Terminal", | ||
| case "BashOutput": | ||
| case toolNames.bashOutput: | ||
| case acpToolNames.bashOutput: | ||
| return { | ||
@@ -64,3 +82,3 @@ title: "Tail Logs", | ||
| case "KillShell": | ||
| case toolNames.killShell: | ||
| case acpToolNames.killShell: | ||
| return { | ||
@@ -71,3 +89,3 @@ title: "Kill Process", | ||
| }; | ||
| case toolNames.read: { | ||
| case acpToolNames.read: { | ||
| let limit = ""; | ||
@@ -116,3 +134,3 @@ if (input.limit) { | ||
| }; | ||
| case toolNames.edit: | ||
| case acpToolNames.edit: | ||
| case "Edit": { | ||
@@ -161,3 +179,3 @@ const path = input?.file_path ?? input?.file_path; | ||
| } | ||
| case toolNames.write: { | ||
| case acpToolNames.write: { | ||
| let content = []; | ||
@@ -348,3 +366,3 @@ if (input && input.file_path) { | ||
| case "Read": | ||
| case toolNames.read: | ||
| case acpToolNames.read: | ||
| if (Array.isArray(toolResult.content) && toolResult.content.length > 0) { | ||
@@ -377,7 +395,7 @@ return { | ||
| 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": { | ||
@@ -483,1 +501,41 @@ if ("is_error" in toolResult && | ||
| }; | ||
| /** | ||
| * 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, logger = console) => async (input, _toolUseID) => { | ||
| 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", | ||
| permissionDecision: "allow", | ||
| permissionDecisionReason: `Allowed by settings rule: ${permissionCheck.rule}`, | ||
| }, | ||
| }; | ||
| case "deny": | ||
| return { | ||
| continue: true, | ||
| hookSpecificOutput: { | ||
| hookEventName: "PreToolUse", | ||
| permissionDecision: "deny", | ||
| permissionDecisionReason: `Denied by settings rule: ${permissionCheck.rule}`, | ||
| }, | ||
| }; | ||
| case "ask": | ||
| default: | ||
| // Let the normal permission flow continue | ||
| return { continue: true }; | ||
| } | ||
| }; |
+1
-16
| // A pushable async iterable: allows you to push items and consume them with for-await. | ||
| import { WritableStream, ReadableStream } from "node:stream/web"; | ||
| import { readFileSync } from "node:fs"; | ||
| import { platform } from "node:os"; | ||
| import { getManagedSettingsPath } from "./settings.js"; | ||
| // Useful for bridging push-based and async-iterator-based code. | ||
@@ -86,17 +86,2 @@ export class Pushable { | ||
| } | ||
| // 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() { | ||
| 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() { | ||
@@ -103,0 +88,0 @@ try { |
+6
-5
@@ -6,3 +6,3 @@ { | ||
| }, | ||
| "version": "0.12.3", | ||
| "version": "0.12.4", | ||
| "description": "An ACP-compatible coding agent powered by the Claude Code SDK (TypeScript)", | ||
@@ -55,10 +55,11 @@ "main": "dist/lib.js", | ||
| "dependencies": { | ||
| "@agentclientprotocol/sdk": "0.9.0", | ||
| "@anthropic-ai/claude-agent-sdk": "0.1.65", | ||
| "@agentclientprotocol/sdk": "0.11.0", | ||
| "@anthropic-ai/claude-agent-sdk": "0.1.67", | ||
| "@modelcontextprotocol/sdk": "1.24.3", | ||
| "diff": "8.0.2" | ||
| "diff": "8.0.2", | ||
| "minimatch": "^10.1.1" | ||
| }, | ||
| "devDependencies": { | ||
| "@anthropic-ai/sdk": "0.71.2", | ||
| "@types/node": "25.0.0", | ||
| "@types/node": "25.0.1", | ||
| "@typescript-eslint/eslint-plugin": "8.49.0", | ||
@@ -65,0 +66,0 @@ "@typescript-eslint/parser": "8.49.0", |
Network access
Supply chain riskThis module accesses the network.
Found 1 instance in 1 package
Shell access
Supply chain riskThis module accesses the system shell. Accessing the system shell increases the risk of executing arbitrary code.
Found 1 instance in 1 package
Environment variable access
Supply chain riskPackage accesses environment variables, which may be a sign of credential stuffing or data theft.
Found 4 instances in 1 package
Long strings
Supply chain riskContains long string literals, which may be a sign of obfuscated or packed code.
Found 1 instance in 1 package
URL strings
Supply chain riskPackage contains fragments of external URLs or IP addresses, which the package may be accessing at runtime.
Found 1 instance in 1 package
Network access
Supply chain riskThis module accesses the network.
Found 1 instance in 1 package
Shell access
Supply chain riskThis module accesses the system shell. Accessing the system shell increases the risk of executing arbitrary code.
Found 1 instance in 1 package
Environment variable access
Supply chain riskPackage accesses environment variables, which may be a sign of credential stuffing or data theft.
Found 4 instances in 1 package
Long strings
Supply chain riskContains long string literals, which may be a sign of obfuscated or packed code.
Found 1 instance in 1 package
URL strings
Supply chain riskPackage contains fragments of external URLs or IP addresses, which the package may be accessing at runtime.
Found 1 instance in 1 package
193329
25.11%14
16.67%4300
27.03%3
-40%5
25%+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
- Removed
- Removed