Big News: Socket raises $60M Series C at a $1B valuation to secure software supply chains for AI-driven development.Announcement
Sign In

@zed-industries/claude-code-acp

Package Overview
Dependencies
Maintainers
8
Versions
73
Alerts
File Explorer

Advanced tools

Socket logo

Install Socket

Detect and block malicious and high-risk dependencies

Install

@zed-industries/claude-code-acp - npm Package Compare versions

Comparing version
0.12.3
to
0.12.4
+422
dist/settings.js
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) {

// 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";

@@ -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 = {

@@ -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 };
}
};
// 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,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",