mirror of
https://github.com/agentclientprotocol/claude-agent-acp.git
synced 2026-10-27 12:40:32 +00:00
Compare commits
44 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c9d5d3bb9c | ||
|
|
cee383c67c | ||
|
|
041aa44d00 | ||
|
|
3db76b2d20 | ||
|
|
db965a31aa | ||
|
|
5280246773 | ||
|
|
9f3dd402ea | ||
|
|
d19e399018 | ||
|
|
24d1050f7a | ||
|
|
a94598dd89 | ||
|
|
8228c471c8 | ||
|
|
c2200eec80 | ||
|
|
3e5f0b4830 | ||
|
|
172565f19d | ||
|
|
09106474ea | ||
|
|
2b60bad254 | ||
|
|
e81ecbd96e | ||
|
|
e1f08905f7 | ||
|
|
634f1ef6a4 | ||
|
|
86d333b8cc | ||
|
|
9f6fef55dd | ||
|
|
c3e1418d89 | ||
|
|
3b4cd4b2d5 | ||
|
|
9320e05891 | ||
|
|
3f44543019 | ||
|
|
4db3d855bf | ||
|
|
e57893f6d0 | ||
|
|
6a93d7e786 | ||
|
|
c3c0f4da36 | ||
|
|
73d308e4ad | ||
|
|
49b28a25eb | ||
|
|
8516df7188 | ||
|
|
e31a16228a | ||
|
|
c00782c103 | ||
|
|
4211f8cf05 | ||
|
|
ac2119a23a | ||
|
|
427117dce6 | ||
|
|
b5a7e0f28d | ||
|
|
8ef0b4e4c6 | ||
|
|
959cd6c8e2 | ||
|
|
ef209d9d12 | ||
|
|
91b00b71e3 | ||
|
|
101a527732 | ||
|
|
59c5a0a8b8 |
2
.github/workflows/publish.yml
vendored
2
.github/workflows/publish.yml
vendored
@@ -32,5 +32,3 @@ jobs:
|
||||
- name: Create Release
|
||||
id: create_release
|
||||
uses: softprops/action-gh-release@6da8fa9354ddfdc4aeace5fc48d7f679b5214090
|
||||
with:
|
||||
draft: true
|
||||
|
||||
79
CHANGELOG.md
79
CHANGELOG.md
@@ -1,5 +1,84 @@
|
||||
# Changelog
|
||||
|
||||
## 0.13.0
|
||||
|
||||
- Update to @anthropic-ai/claude-agent-sdk@0.2.6
|
||||
- Update to @agentclientprotocol/sdk@0.13.0
|
||||
|
||||
## 0.12.6
|
||||
|
||||
- Fix model selection
|
||||
|
||||
## 0.12.5
|
||||
|
||||
- Update to @anthropic-ai/claude-agent-sdk@v0.1.70
|
||||
- Unstable implementation of resuming sessions
|
||||
|
||||
## 0.12.4
|
||||
|
||||
- Update to @anthropic-ai/claude-agent-sdk@v0.1.67
|
||||
- Better respect permissions specified in settings files
|
||||
- Unstable implementation of forking
|
||||
|
||||
## 0.12.3
|
||||
|
||||
- Update to @anthropic-ai/claude-agent-sdk@v0.1.65
|
||||
- Update to @agentclientprotocol/sdk@0.9.0
|
||||
- Allow agent to write plans and todos to its config directory
|
||||
- Fix experimental resume ids
|
||||
|
||||
## 0.12.2
|
||||
|
||||
- Fix duplicate tool use IDs error
|
||||
|
||||
## 0.12.1
|
||||
|
||||
- Update to @anthropic-ai/claude-agent-sdk@v0.1.61
|
||||
- Update to @agentclientprotocol/sdk@0.8.0
|
||||
|
||||
## 0.12.0
|
||||
|
||||
- Update to @anthropic-ai/claude-agent-sdk@v0.1.59
|
||||
- Brings Opus to Claude Pro plans
|
||||
- Support "Don't Ask" profile
|
||||
- Unify ACP + Claude Code session ids
|
||||
|
||||
## 0.11.0
|
||||
|
||||
- Update to @anthropic-ai/claude-agent-sdk@v0.1.57
|
||||
- Removed dependency on @anthropic-ai/claude-code since this is no longer needed
|
||||
|
||||
## 0.10.10
|
||||
|
||||
- Update to @agentclientprotocol/sdk@0.7.0
|
||||
|
||||
## 0.10.9
|
||||
|
||||
- Update to @anthropic-ai/claude-agent-sdk@v0.1.55
|
||||
- Allow defining a custom logger when used as a library
|
||||
- Allow specifying custom options when used as a library
|
||||
- Add `CLAUDECODE=1` to terminal invocations to match default Claude Code behavior
|
||||
|
||||
## 0.10.8
|
||||
|
||||
- Update to @anthropic-ai/claude-agent-sdk@v0.1.51 (adds support for Opus 4.5)
|
||||
|
||||
## 0.10.7
|
||||
|
||||
- Fix read/edit tool error handling so upstream errors surface
|
||||
- Update to @anthropic-ai/claude-agent-sdk@v0.1.50
|
||||
|
||||
## 0.10.6
|
||||
|
||||
- Disable experimental terminal auth support for now, as it was causing issues on Windows. Will revisit with a fix later.
|
||||
- Update to @anthropic-ai/claude-agent-sdk@v0.1.46
|
||||
|
||||
## 0.10.5
|
||||
|
||||
- Better error messages at end of turn if there were any
|
||||
- Add experimental support for disabling built-in tools via \_meta flag
|
||||
- Update to @anthropic-ai/claude-agent-sdk@v0.1.44
|
||||
|
||||
## 0.10.4
|
||||
|
||||
- Fix tool call titles not appearing during approval in some cases
|
||||
|
||||
12
README.md
12
README.md
@@ -4,7 +4,7 @@
|
||||
|
||||
Use [Claude Code](https://www.anthropic.com/claude-code) from [ACP-compatible](https://agentclientprotocol.com) clients such as [Zed](https://zed.dev)!
|
||||
|
||||
This tool implements an ACP agent by using the official [Claude Code SDK](https://docs.anthropic.com/en/docs/claude-code/sdk/sdk-overview), supporting:
|
||||
This tool implements an ACP agent by using the official [Claude Agent SDK](https://platform.claude.com/docs/en/agent-sdk/overview), supporting:
|
||||
|
||||
- Context @-mentions
|
||||
- Images
|
||||
@@ -32,20 +32,14 @@ Read the docs on [External Agent](https://zed.dev/docs/ai/external-agents) suppo
|
||||
|
||||
### Other clients
|
||||
|
||||
- Emacs via [agent-shell.el](https://github.com/xenodium/agent-shell)
|
||||
- [marimo notebook](https://github.com/marimo-team/marimo)
|
||||
- Neovim
|
||||
- via [CodeCompanion.nvim](https://codecompanion.olimorris.dev/configuration/adapters#setup-claude-code-via-acp)
|
||||
- via [yetone/avante.nvim](https://github.com/yetone/avante.nvim)
|
||||
|
||||
[Submit a PR](https://github.com/zed-industries/claude-code-acp/pulls) to add yours!
|
||||
Or try it with any of the other [ACP compatible clients](https://agentclientprotocol.com/overview/clients)!
|
||||
|
||||
#### Installation
|
||||
|
||||
Install the adapter from `npm`:
|
||||
|
||||
```bash
|
||||
npm install @zed-industries/claude-code-acp
|
||||
npm install -g @zed-industries/claude-code-acp
|
||||
```
|
||||
|
||||
You can then use `claude-code-acp` as a regular ACP agent:
|
||||
|
||||
1719
package-lock.json
generated
1719
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
31
package.json
31
package.json
@@ -3,7 +3,7 @@
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
},
|
||||
"version": "0.10.4",
|
||||
"version": "0.13.0",
|
||||
"description": "An ACP-compatible coding agent powered by the Claude Code SDK (TypeScript)",
|
||||
"main": "dist/lib.js",
|
||||
"bin": {
|
||||
@@ -51,26 +51,23 @@
|
||||
"author": "Zed Industries",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@agentclientprotocol/sdk": "0.5.1",
|
||||
"@anthropic-ai/claude-agent-sdk": "0.1.42",
|
||||
"@anthropic-ai/claude-code": "2.0.42",
|
||||
"@modelcontextprotocol/sdk": "1.22.0",
|
||||
"diff": "8.0.2",
|
||||
"uuid": "13.0.0"
|
||||
"@agentclientprotocol/sdk": "0.13.0",
|
||||
"@anthropic-ai/claude-agent-sdk": "0.2.6",
|
||||
"@modelcontextprotocol/sdk": "1.25.2",
|
||||
"diff": "8.0.3",
|
||||
"minimatch": "10.1.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@anthropic-ai/sdk": "0.69.0",
|
||||
"@types/node": "24.10.1",
|
||||
"@types/uuid": "10.0.0",
|
||||
"@typescript-eslint/eslint-plugin": "8.46.4",
|
||||
"@typescript-eslint/parser": "8.46.4",
|
||||
"@vitest/ui": "4.0.9",
|
||||
"eslint": "9.39.1",
|
||||
"@anthropic-ai/sdk": "0.71.2",
|
||||
"@types/node": "25.0.7",
|
||||
"@typescript-eslint/eslint-plugin": "8.53.0",
|
||||
"@typescript-eslint/parser": "8.53.0",
|
||||
"eslint": "9.39.2",
|
||||
"eslint-config-prettier": "10.1.8",
|
||||
"globals": "16.5.0",
|
||||
"prettier": "3.6.2",
|
||||
"globals": "17.0.0",
|
||||
"prettier": "3.7.4",
|
||||
"ts-node": "10.9.2",
|
||||
"typescript": "5.9.3",
|
||||
"vitest": "4.0.9"
|
||||
"vitest": "4.0.17"
|
||||
}
|
||||
}
|
||||
|
||||
608
src/acp-agent.ts
608
src/acp-agent.ts
@@ -5,6 +5,8 @@ import {
|
||||
AvailableCommand,
|
||||
CancelNotification,
|
||||
ClientCapabilities,
|
||||
ForkSessionRequest,
|
||||
ForkSessionResponse,
|
||||
InitializeRequest,
|
||||
InitializeResponse,
|
||||
ndJsonStream,
|
||||
@@ -15,7 +17,10 @@ import {
|
||||
ReadTextFileRequest,
|
||||
ReadTextFileResponse,
|
||||
RequestError,
|
||||
ResumeSessionRequest,
|
||||
ResumeSessionResponse,
|
||||
SessionModelState,
|
||||
SessionNotification,
|
||||
SetSessionModelRequest,
|
||||
SetSessionModelResponse,
|
||||
SetSessionModeRequest,
|
||||
@@ -25,6 +30,7 @@ import {
|
||||
WriteTextFileRequest,
|
||||
WriteTextFileResponse,
|
||||
} from "@agentclientprotocol/sdk";
|
||||
import { SettingsManager } from "./settings.js";
|
||||
import {
|
||||
CanUseTool,
|
||||
McpServerConfig,
|
||||
@@ -38,28 +44,39 @@ import {
|
||||
import * as fs from "node:fs";
|
||||
import * as path from "node:path";
|
||||
import * as os from "node:os";
|
||||
import { v7 as uuidv7 } from "uuid";
|
||||
import { nodeToWebReadable, nodeToWebWritable, Pushable, unreachable } from "./utils.js";
|
||||
import { SessionNotification } from "@agentclientprotocol/sdk";
|
||||
import { createMcpServer, EDIT_TOOL_NAMES, toolNames } from "./mcp-server.js";
|
||||
import { createMcpServer } from "./mcp-server.js";
|
||||
import { EDIT_TOOL_NAMES, acpToolNames } from "./tools.js";
|
||||
import {
|
||||
toolInfoFromToolUse,
|
||||
planEntries,
|
||||
toolUpdateFromToolResult,
|
||||
ClaudePlanEntry,
|
||||
registerHookCallback,
|
||||
postToolUseHook,
|
||||
createPostToolUseHook,
|
||||
createPreToolUseHook,
|
||||
} from "./tools.js";
|
||||
import { ContentBlockParam } from "@anthropic-ai/sdk/resources";
|
||||
import { BetaContentBlock, BetaRawContentBlockDelta } from "@anthropic-ai/sdk/resources/beta.mjs";
|
||||
import packageJson from "../package.json" with { type: "json" };
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { randomUUID } from "node:crypto";
|
||||
|
||||
export const CLAUDE_CONFIG_DIR = process.env.CLAUDE ?? path.join(os.homedir(), ".claude");
|
||||
|
||||
/**
|
||||
* Logger interface for customizing logging output
|
||||
*/
|
||||
export interface Logger {
|
||||
log: (...args: any[]) => void;
|
||||
error: (...args: any[]) => void;
|
||||
}
|
||||
|
||||
type Session = {
|
||||
query: Query;
|
||||
input: Pushable<SDKUserMessage>;
|
||||
cancelled: boolean;
|
||||
permissionMode: PermissionMode;
|
||||
settingsManager: SettingsManager;
|
||||
};
|
||||
|
||||
type BackgroundTerminal =
|
||||
@@ -74,13 +91,37 @@ type BackgroundTerminal =
|
||||
};
|
||||
|
||||
/**
|
||||
* Extra metadata that the agent provides for each tool_call / tool_update update, under the `_meta.claudeCode` key.
|
||||
* Extra metadata that can be given to Claude Code when creating a new session.
|
||||
*/
|
||||
export type NewSessionMeta = {
|
||||
claudeCode?: {
|
||||
/**
|
||||
* Options forwarded to Claude Code when starting a new session.
|
||||
* Those parameters will be ignored and managed by ACP:
|
||||
* - cwd
|
||||
* - includePartialMessages
|
||||
* - allowDangerouslySkipPermissions
|
||||
* - permissionMode
|
||||
* - canUseTool
|
||||
* - executable
|
||||
* Those parameters will be used and updated to work with ACP:
|
||||
* - hooks (merged with ACP's hooks)
|
||||
* - mcpServers (merged with ACP's mcpServers)
|
||||
*/
|
||||
options?: Options;
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Extra metadata that the agent provides for each tool_call / tool_update update.
|
||||
*/
|
||||
export type ToolUpdateMeta = {
|
||||
/* The name of the tool that was used in Claude Code. */
|
||||
toolName: string;
|
||||
/* The structured output provided by Claude Code. */
|
||||
toolResponse?: unknown;
|
||||
claudeCode?: {
|
||||
/* The name of the tool that was used in Claude Code. */
|
||||
toolName: string;
|
||||
/* The structured output provided by Claude Code. */
|
||||
toolResponse?: unknown;
|
||||
};
|
||||
};
|
||||
|
||||
type ToolUseCache = {
|
||||
@@ -92,8 +133,6 @@ type ToolUseCache = {
|
||||
};
|
||||
};
|
||||
|
||||
const DEFAULT_MODEL_ID = "default";
|
||||
|
||||
// Bypass Permissions doesn't work if we are a root/sudo user
|
||||
const IS_ROOT = (process.geteuid?.() ?? process.getuid?.()) === 0;
|
||||
|
||||
@@ -104,15 +143,15 @@ export class ClaudeAcpAgent implements Agent {
|
||||
};
|
||||
client: AgentSideConnection;
|
||||
toolUseCache: ToolUseCache;
|
||||
fileContentCache: { [key: string]: string };
|
||||
backgroundTerminals: { [key: string]: BackgroundTerminal } = {};
|
||||
clientCapabilities?: ClientCapabilities;
|
||||
logger: Logger;
|
||||
|
||||
constructor(client: AgentSideConnection) {
|
||||
constructor(client: AgentSideConnection, logger?: Logger) {
|
||||
this.sessions = {};
|
||||
this.client = client;
|
||||
this.toolUseCache = {};
|
||||
this.fileContentCache = {};
|
||||
this.logger = logger ?? console;
|
||||
}
|
||||
|
||||
async initialize(request: InitializeRequest): Promise<InitializeResponse> {
|
||||
@@ -126,17 +165,17 @@ export class ClaudeAcpAgent implements Agent {
|
||||
};
|
||||
|
||||
// If client supports terminal-auth capability, use that instead.
|
||||
if (request.clientCapabilities?._meta?.["terminal-auth"] === true) {
|
||||
const cliPath = fileURLToPath(import.meta.resolve("@anthropic-ai/claude-agent-sdk/cli.js"));
|
||||
// if (request.clientCapabilities?._meta?.["terminal-auth"] === true) {
|
||||
// const cliPath = fileURLToPath(import.meta.resolve("@anthropic-ai/claude-agent-sdk/cli.js"));
|
||||
|
||||
authMethod._meta = {
|
||||
"terminal-auth": {
|
||||
command: cliPath,
|
||||
args: ["/login"],
|
||||
label: "Claude Code Login",
|
||||
},
|
||||
};
|
||||
}
|
||||
// authMethod._meta = {
|
||||
// "terminal-auth": {
|
||||
// command: "node",
|
||||
// args: [cliPath, "/login"],
|
||||
// label: "Claude Code Login",
|
||||
// },
|
||||
// };
|
||||
// }
|
||||
|
||||
return {
|
||||
protocolVersion: 1,
|
||||
@@ -149,6 +188,10 @@ export class ClaudeAcpAgent implements Agent {
|
||||
http: true,
|
||||
sse: true,
|
||||
},
|
||||
sessionCapabilities: {
|
||||
fork: {},
|
||||
resume: {},
|
||||
},
|
||||
},
|
||||
agentInfo: {
|
||||
name: packageJson.name,
|
||||
@@ -158,6 +201,7 @@ export class ClaudeAcpAgent implements Agent {
|
||||
authMethods: [authMethod],
|
||||
};
|
||||
}
|
||||
|
||||
async newSession(params: NewSessionRequest): Promise<NewSessionResponse> {
|
||||
if (
|
||||
fs.existsSync(path.resolve(os.homedir(), ".claude.json.backup")) &&
|
||||
@@ -166,164 +210,39 @@ export class ClaudeAcpAgent implements Agent {
|
||||
throw RequestError.authRequired();
|
||||
}
|
||||
|
||||
const sessionId = uuidv7();
|
||||
const input = new Pushable<SDKUserMessage>();
|
||||
|
||||
const mcpServers: Record<string, McpServerConfig> = {};
|
||||
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,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const server = createMcpServer(this, sessionId, this.clientCapabilities);
|
||||
mcpServers["acp"] = {
|
||||
type: "sdk",
|
||||
name: "acp",
|
||||
instance: server,
|
||||
};
|
||||
|
||||
let systemPrompt: Options["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 options: Options = {
|
||||
cwd: params.cwd,
|
||||
includePartialMessages: true,
|
||||
mcpServers,
|
||||
systemPrompt,
|
||||
settingSources: ["user", "project", "local"],
|
||||
// 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),
|
||||
stderr: (err) => console.error(err),
|
||||
// note: although not documented by the types, passing an absolute path
|
||||
// here works to find zed's managed node version.
|
||||
executable: process.execPath as any,
|
||||
...(process.env.CLAUDE_CODE_EXECUTABLE && {
|
||||
pathToClaudeCodeExecutable: process.env.CLAUDE_CODE_EXECUTABLE,
|
||||
}),
|
||||
hooks: {
|
||||
PostToolUse: [
|
||||
{
|
||||
hooks: [postToolUseHook],
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
const allowedTools = [];
|
||||
const disallowedTools = [];
|
||||
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");
|
||||
}
|
||||
|
||||
if (allowedTools.length > 0) {
|
||||
options.allowedTools = allowedTools;
|
||||
}
|
||||
if (disallowedTools.length > 0) {
|
||||
options.disallowedTools = disallowedTools;
|
||||
}
|
||||
|
||||
const q = query({
|
||||
prompt: input,
|
||||
options,
|
||||
return await this.createSession(params, {
|
||||
// Revisit these meta values once we support resume
|
||||
resume: (params._meta as NewSessionMeta | undefined)?.claudeCode?.options?.resume,
|
||||
});
|
||||
}
|
||||
|
||||
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 = [
|
||||
async unstable_forkSession(params: ForkSessionRequest): Promise<ForkSessionResponse> {
|
||||
return await this.createSession(
|
||||
{
|
||||
id: "default",
|
||||
name: "Always Ask",
|
||||
description: "Prompts for permission on first use of each tool",
|
||||
cwd: params.cwd,
|
||||
mcpServers: params.mcpServers ?? [],
|
||||
_meta: params._meta,
|
||||
},
|
||||
{
|
||||
id: "acceptEdits",
|
||||
name: "Accept Edits",
|
||||
description: "Automatically accepts file edit permissions for the session",
|
||||
resume: params.sessionId,
|
||||
forkSession: true,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
async unstable_resumeSession(params: ResumeSessionRequest): Promise<ResumeSessionResponse> {
|
||||
const response = await this.createSession(
|
||||
{
|
||||
cwd: params.cwd,
|
||||
mcpServers: params.mcpServers ?? [],
|
||||
_meta: params._meta,
|
||||
},
|
||||
{
|
||||
id: "plan",
|
||||
name: "Plan Mode",
|
||||
description: "Claude can analyze but not modify files or execute commands",
|
||||
resume: params.sessionId,
|
||||
},
|
||||
];
|
||||
// Only works in non-root mode
|
||||
if (!IS_ROOT) {
|
||||
availableModes.push({
|
||||
id: "bypassPermissions",
|
||||
name: "Bypass Permissions",
|
||||
description: "Skips all permission prompts",
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
return {
|
||||
sessionId,
|
||||
models,
|
||||
modes: {
|
||||
currentModeId: permissionMode,
|
||||
availableModes,
|
||||
},
|
||||
};
|
||||
return response;
|
||||
}
|
||||
|
||||
async authenticate(_params: AuthenticateRequest): Promise<void> {
|
||||
@@ -348,6 +267,7 @@ export class ClaudeAcpAgent implements Agent {
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
switch (message.type) {
|
||||
case "system":
|
||||
switch (message.subtype) {
|
||||
@@ -359,7 +279,8 @@ export class ClaudeAcpAgent implements Agent {
|
||||
// Todo: process via status api: https://docs.claude.com/en/docs/claude-code/hooks#hook-output
|
||||
break;
|
||||
default:
|
||||
unreachable(message);
|
||||
unreachable(message, this.logger);
|
||||
break;
|
||||
}
|
||||
break;
|
||||
case "result": {
|
||||
@@ -367,21 +288,37 @@ export class ClaudeAcpAgent implements Agent {
|
||||
return { stopReason: "cancelled" };
|
||||
}
|
||||
|
||||
// todo!() how is rate-limiting handled?
|
||||
switch (message.subtype) {
|
||||
case "success": {
|
||||
if (message.result.includes("Please run /login")) {
|
||||
throw RequestError.authRequired();
|
||||
}
|
||||
if (message.is_error) {
|
||||
throw RequestError.internalError(undefined, message.result);
|
||||
}
|
||||
return { stopReason: "end_turn" };
|
||||
}
|
||||
case "error_during_execution":
|
||||
if (message.is_error) {
|
||||
throw RequestError.internalError(
|
||||
undefined,
|
||||
message.errors.join(", ") || message.subtype,
|
||||
);
|
||||
}
|
||||
return { stopReason: "end_turn" };
|
||||
case "error_max_budget_usd":
|
||||
case "error_max_turns":
|
||||
case "error_max_structured_output_retries":
|
||||
if (message.is_error) {
|
||||
throw RequestError.internalError(
|
||||
undefined,
|
||||
message.errors.join(", ") || message.subtype,
|
||||
);
|
||||
}
|
||||
return { stopReason: "max_turn_requests" };
|
||||
default:
|
||||
unreachable(message);
|
||||
unreachable(message, this.logger);
|
||||
break;
|
||||
}
|
||||
break;
|
||||
}
|
||||
@@ -390,8 +327,8 @@ export class ClaudeAcpAgent implements Agent {
|
||||
message,
|
||||
params.sessionId,
|
||||
this.toolUseCache,
|
||||
this.fileContentCache,
|
||||
this.client,
|
||||
this.logger,
|
||||
)) {
|
||||
await this.client.sessionUpdate(notification);
|
||||
}
|
||||
@@ -409,7 +346,7 @@ export class ClaudeAcpAgent implements Agent {
|
||||
typeof message.message.content === "string" &&
|
||||
message.message.content.includes("<local-command-stdout>")
|
||||
) {
|
||||
console.log(message.message.content);
|
||||
this.logger.log(message.message.content);
|
||||
break;
|
||||
}
|
||||
|
||||
@@ -417,7 +354,7 @@ export class ClaudeAcpAgent implements Agent {
|
||||
typeof message.message.content === "string" &&
|
||||
message.message.content.includes("<local-command-stderr>")
|
||||
) {
|
||||
console.error(message.message.content);
|
||||
this.logger.error(message.message.content);
|
||||
break;
|
||||
}
|
||||
// Skip these user messages for now, since they seem to just be messages we don't want in the feed
|
||||
@@ -453,8 +390,8 @@ export class ClaudeAcpAgent implements Agent {
|
||||
message.message.role,
|
||||
params.sessionId,
|
||||
this.toolUseCache,
|
||||
this.fileContentCache,
|
||||
this.client,
|
||||
this.logger,
|
||||
)) {
|
||||
await this.client.sessionUpdate(notification);
|
||||
}
|
||||
@@ -466,6 +403,7 @@ export class ClaudeAcpAgent implements Agent {
|
||||
break;
|
||||
default:
|
||||
unreachable(message);
|
||||
break;
|
||||
}
|
||||
}
|
||||
throw new Error("Session did not end in result");
|
||||
@@ -479,7 +417,9 @@ export class ClaudeAcpAgent implements Agent {
|
||||
await this.sessions[params.sessionId].query.interrupt();
|
||||
}
|
||||
|
||||
async setSessionModel(params: SetSessionModelRequest): Promise<SetSessionModelResponse | void> {
|
||||
async unstable_setSessionModel(
|
||||
params: SetSessionModelRequest,
|
||||
): Promise<SetSessionModelResponse | void> {
|
||||
if (!this.sessions[params.sessionId]) {
|
||||
throw new Error("Session not found");
|
||||
}
|
||||
@@ -495,6 +435,7 @@ export class ClaudeAcpAgent implements Agent {
|
||||
case "default":
|
||||
case "acceptEdits":
|
||||
case "bypassPermissions":
|
||||
case "dontAsk":
|
||||
case "plan":
|
||||
this.sessions[params.sessionId].permissionMode = params.modeId;
|
||||
try {
|
||||
@@ -513,20 +454,16 @@ export class ClaudeAcpAgent implements Agent {
|
||||
|
||||
async readTextFile(params: ReadTextFileRequest): Promise<ReadTextFileResponse> {
|
||||
const response = await this.client.readTextFile(params);
|
||||
if (!params.limit && !params.line) {
|
||||
this.fileContentCache[params.path] = response.content;
|
||||
}
|
||||
return response;
|
||||
}
|
||||
|
||||
async writeTextFile(params: WriteTextFileRequest): Promise<WriteTextFileResponse> {
|
||||
const response = await this.client.writeTextFile(params);
|
||||
this.fileContentCache[params.path] = params.content;
|
||||
return response;
|
||||
}
|
||||
|
||||
canUseTool(sessionId: string): CanUseTool {
|
||||
return async (toolName, toolInput, { suggestions, toolUseID }) => {
|
||||
return async (toolName, toolInput, { signal, suggestions, toolUseID }) => {
|
||||
const session = this.sessions[sessionId];
|
||||
if (!session) {
|
||||
return {
|
||||
@@ -551,11 +488,13 @@ export class ClaudeAcpAgent implements Agent {
|
||||
toolCall: {
|
||||
toolCallId: toolUseID,
|
||||
rawInput: toolInput,
|
||||
title: toolInfoFromToolUse({ name: toolName, input: toolInput }, this.fileContentCache)
|
||||
.title,
|
||||
title: toolInfoFromToolUse({ name: toolName, input: toolInput }).title,
|
||||
},
|
||||
});
|
||||
|
||||
if (signal.aborted || response.outcome?.outcome === "cancelled") {
|
||||
throw new Error("Tool use aborted");
|
||||
}
|
||||
if (
|
||||
response.outcome?.outcome === "selected" &&
|
||||
(response.outcome.optionId === "default" || response.outcome.optionId === "acceptEdits")
|
||||
@@ -612,10 +551,12 @@ export class ClaudeAcpAgent implements Agent {
|
||||
toolCall: {
|
||||
toolCallId: toolUseID,
|
||||
rawInput: toolInput,
|
||||
title: toolInfoFromToolUse({ name: toolName, input: toolInput }, this.fileContentCache)
|
||||
.title,
|
||||
title: toolInfoFromToolUse({ name: toolName, input: toolInput }).title,
|
||||
},
|
||||
});
|
||||
if (signal.aborted || response.outcome?.outcome === "cancelled") {
|
||||
throw new Error("Tool use aborted");
|
||||
}
|
||||
if (
|
||||
response.outcome?.outcome === "selected" &&
|
||||
(response.outcome.optionId === "allow" || response.outcome.optionId === "allow_always")
|
||||
@@ -648,14 +589,260 @@ export class ClaudeAcpAgent implements Agent {
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
private async createSession(
|
||||
params: NewSessionRequest,
|
||||
creationOpts: { resume?: string; forkSession?: boolean } = {},
|
||||
): Promise<NewSessionResponse> {
|
||||
// We want to create a new session id unless it is resume,
|
||||
// but not resume + forkSession.
|
||||
let sessionId;
|
||||
if (creationOpts.forkSession) {
|
||||
sessionId = randomUUID();
|
||||
} else if (creationOpts.resume) {
|
||||
sessionId = creationOpts.resume;
|
||||
} else {
|
||||
sessionId = randomUUID();
|
||||
}
|
||||
|
||||
const input = new Pushable<SDKUserMessage>();
|
||||
|
||||
const settingsManager = new SettingsManager(params.cwd, {
|
||||
logger: this.logger,
|
||||
});
|
||||
await settingsManager.initialize();
|
||||
|
||||
const mcpServers: Record<string, McpServerConfig> = {};
|
||||
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: Options["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 as NewSessionMeta | undefined)?.claudeCode?.options;
|
||||
const extraArgs = { ...userProvidedOptions?.extraArgs };
|
||||
if (creationOpts?.resume === undefined || creationOpts?.forkSession) {
|
||||
// Set our own session id if not resuming an existing session.
|
||||
extraArgs["session-id"] = sessionId;
|
||||
}
|
||||
|
||||
const options: 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 as any,
|
||||
...(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,
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
async function getAvailableModels(query: Query): Promise<SessionModelState> {
|
||||
const models = await query.supportedModels();
|
||||
|
||||
// Query doesn't give us access to the currently selected model, so we default to "default" if it is there,
|
||||
// otherwise we just choose the first model in the list.
|
||||
const currentModel = models.find((model) => model.value === DEFAULT_MODEL_ID) || models[0];
|
||||
// Query doesn't give us access to the currently selected model, so we just choose the first model in the list.
|
||||
const currentModel = models[0];
|
||||
await query.setModel(currentModel.value);
|
||||
|
||||
const availableModels = models.map((model) => ({
|
||||
@@ -803,8 +990,8 @@ export function toAcpNotifications(
|
||||
role: "assistant" | "user",
|
||||
sessionId: string,
|
||||
toolUseCache: ToolUseCache,
|
||||
fileContentCache: { [key: string]: string },
|
||||
client: AgentSideConnection,
|
||||
logger: Logger,
|
||||
): SessionNotification[] {
|
||||
if (typeof content === "string") {
|
||||
return [
|
||||
@@ -880,8 +1067,8 @@ export function toAcpNotifications(
|
||||
claudeCode: {
|
||||
toolResponse,
|
||||
toolName: toolUse.name,
|
||||
} satisfies ToolUpdateMeta,
|
||||
},
|
||||
},
|
||||
} satisfies ToolUpdateMeta,
|
||||
toolCallId: toolUseId,
|
||||
sessionUpdate: "tool_call_update",
|
||||
};
|
||||
@@ -890,7 +1077,7 @@ export function toAcpNotifications(
|
||||
update,
|
||||
});
|
||||
} else {
|
||||
console.error(
|
||||
logger.error(
|
||||
`[claude-code-acp] Got a tool response for tool use that wasn't tracked: ${toolUseId}`,
|
||||
);
|
||||
}
|
||||
@@ -907,19 +1094,20 @@ export function toAcpNotifications(
|
||||
_meta: {
|
||||
claudeCode: {
|
||||
toolName: chunk.name,
|
||||
} satisfies ToolUpdateMeta,
|
||||
},
|
||||
},
|
||||
} satisfies ToolUpdateMeta,
|
||||
toolCallId: chunk.id,
|
||||
sessionUpdate: "tool_call",
|
||||
rawInput,
|
||||
status: "pending",
|
||||
...toolInfoFromToolUse(chunk, fileContentCache),
|
||||
...toolInfoFromToolUse(chunk),
|
||||
};
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case "tool_result":
|
||||
case "tool_search_tool_result":
|
||||
case "web_fetch_tool_result":
|
||||
case "web_search_tool_result":
|
||||
case "code_execution_tool_result":
|
||||
@@ -928,7 +1116,7 @@ export function toAcpNotifications(
|
||||
case "mcp_tool_result": {
|
||||
const toolUse = toolUseCache[chunk.tool_use_id];
|
||||
if (!toolUse) {
|
||||
console.error(
|
||||
logger.error(
|
||||
`[claude-code-acp] Got a tool result for tool use that wasn't tracked: ${chunk.tool_use_id}`,
|
||||
);
|
||||
break;
|
||||
@@ -939,8 +1127,8 @@ export function toAcpNotifications(
|
||||
_meta: {
|
||||
claudeCode: {
|
||||
toolName: toolUse.name,
|
||||
} satisfies ToolUpdateMeta,
|
||||
},
|
||||
},
|
||||
} satisfies ToolUpdateMeta,
|
||||
toolCallId: chunk.tool_use_id,
|
||||
sessionUpdate: "tool_call_update",
|
||||
status: "is_error" in chunk && chunk.is_error ? "failed" : "completed",
|
||||
@@ -960,7 +1148,8 @@ export function toAcpNotifications(
|
||||
break;
|
||||
|
||||
default:
|
||||
unreachable(chunk);
|
||||
unreachable(chunk, logger);
|
||||
break;
|
||||
}
|
||||
if (update) {
|
||||
output.push({ sessionId, update });
|
||||
@@ -974,8 +1163,8 @@ export function streamEventToAcpNotifications(
|
||||
message: SDKPartialAssistantMessage,
|
||||
sessionId: string,
|
||||
toolUseCache: ToolUseCache,
|
||||
fileContentCache: { [key: string]: string },
|
||||
client: AgentSideConnection,
|
||||
logger: Logger,
|
||||
): SessionNotification[] {
|
||||
const event = message.event;
|
||||
switch (event.type) {
|
||||
@@ -985,8 +1174,8 @@ export function streamEventToAcpNotifications(
|
||||
"assistant",
|
||||
sessionId,
|
||||
toolUseCache,
|
||||
fileContentCache,
|
||||
client,
|
||||
logger,
|
||||
);
|
||||
case "content_block_delta":
|
||||
return toAcpNotifications(
|
||||
@@ -994,8 +1183,8 @@ export function streamEventToAcpNotifications(
|
||||
"assistant",
|
||||
sessionId,
|
||||
toolUseCache,
|
||||
fileContentCache,
|
||||
client,
|
||||
logger,
|
||||
);
|
||||
// No content
|
||||
case "message_start":
|
||||
@@ -1005,7 +1194,8 @@ export function streamEventToAcpNotifications(
|
||||
return [];
|
||||
|
||||
default:
|
||||
unreachable(event);
|
||||
unreachable(event, logger);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
19
src/lib.ts
19
src/lib.ts
@@ -5,6 +5,7 @@ export {
|
||||
toAcpNotifications,
|
||||
streamEventToAcpNotifications,
|
||||
type ToolUpdateMeta,
|
||||
type NewSessionMeta,
|
||||
} from "./acp-agent.js";
|
||||
export {
|
||||
loadManagedSettings,
|
||||
@@ -14,8 +15,22 @@ export {
|
||||
Pushable,
|
||||
unreachable,
|
||||
} from "./utils.js";
|
||||
export { createMcpServer, toolNames } from "./mcp-server.js";
|
||||
export { toolInfoFromToolUse, planEntries, toolUpdateFromToolResult } from "./tools.js";
|
||||
export { createMcpServer } from "./mcp-server.js";
|
||||
export {
|
||||
toolInfoFromToolUse,
|
||||
planEntries,
|
||||
toolUpdateFromToolResult,
|
||||
createPreToolUseHook,
|
||||
acpToolNames as toolNames,
|
||||
} from "./tools.js";
|
||||
export {
|
||||
SettingsManager,
|
||||
type ClaudeCodeSettings,
|
||||
type PermissionSettings,
|
||||
type PermissionDecision,
|
||||
type PermissionCheckResult,
|
||||
type SettingsManagerOptions,
|
||||
} from "./settings.js";
|
||||
|
||||
// Export types
|
||||
export type { ClaudePlanEntry } from "./tools.js";
|
||||
|
||||
@@ -1,10 +1,24 @@
|
||||
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
||||
import {
|
||||
BashInput,
|
||||
FileEditInput,
|
||||
FileReadInput,
|
||||
FileWriteInput,
|
||||
KillShellInput,
|
||||
} from "@anthropic-ai/claude-agent-sdk/sdk-tools.js";
|
||||
import { z } from "zod";
|
||||
import { ClaudeAcpAgent } from "./acp-agent.js";
|
||||
import { ClientCapabilities, TerminalOutputResponse } from "@agentclientprotocol/sdk";
|
||||
import { CLAUDE_CONFIG_DIR, ClaudeAcpAgent } from "./acp-agent.js";
|
||||
import {
|
||||
ClientCapabilities,
|
||||
ReadTextFileResponse,
|
||||
TerminalOutputResponse,
|
||||
} from "@agentclientprotocol/sdk";
|
||||
import * as diff from "diff";
|
||||
import * as path from "node:path";
|
||||
import * as fs from "node:fs/promises";
|
||||
|
||||
import { sleep, unreachable, extractLinesWithByteLimit } from "./utils.js";
|
||||
import { acpToolNames } from "./tools.js";
|
||||
|
||||
export const SYSTEM_REMINDER = `
|
||||
|
||||
@@ -23,23 +37,67 @@ const unqualifiedToolNames = {
|
||||
bashOutput: "BashOutput",
|
||||
};
|
||||
|
||||
const SERVER_PREFIX = "mcp__acp__";
|
||||
export const toolNames = {
|
||||
read: SERVER_PREFIX + unqualifiedToolNames.read,
|
||||
edit: SERVER_PREFIX + unqualifiedToolNames.edit,
|
||||
write: SERVER_PREFIX + unqualifiedToolNames.write,
|
||||
bash: SERVER_PREFIX + unqualifiedToolNames.bash,
|
||||
killShell: SERVER_PREFIX + unqualifiedToolNames.killShell,
|
||||
bashOutput: SERVER_PREFIX + unqualifiedToolNames.bashOutput,
|
||||
};
|
||||
|
||||
export const EDIT_TOOL_NAMES = [toolNames.edit, toolNames.write];
|
||||
|
||||
export function createMcpServer(
|
||||
agent: ClaudeAcpAgent,
|
||||
sessionId: string,
|
||||
clientCapabilities: ClientCapabilities | undefined,
|
||||
): McpServer {
|
||||
/**
|
||||
* This checks if a given path is related to internal agent persistence and if the agent should be allowed to read/write from here.
|
||||
* We let the agent do normal fs operations on these paths so that it can persist its state.
|
||||
* However, we block access to settings files for security reasons.
|
||||
*/
|
||||
function internalPath(file_path: string) {
|
||||
return (
|
||||
file_path.startsWith(CLAUDE_CONFIG_DIR) &&
|
||||
!file_path.startsWith(path.join(CLAUDE_CONFIG_DIR, "settings.json")) &&
|
||||
!file_path.startsWith(path.join(CLAUDE_CONFIG_DIR, "session-env"))
|
||||
);
|
||||
}
|
||||
|
||||
async function readTextFile(input: FileReadInput): Promise<ReadTextFileResponse> {
|
||||
if (internalPath(input.file_path)) {
|
||||
const content = await fs.readFile(input.file_path, "utf8");
|
||||
|
||||
// eslint-disable-next-line eqeqeq
|
||||
if (input.offset != null || input.limit != null) {
|
||||
const lines = content.split("\n");
|
||||
|
||||
// Apply offset and limit if provided
|
||||
const offset = input.offset ?? 1;
|
||||
const limit = input.limit ?? lines.length;
|
||||
|
||||
// Extract the requested lines (offset is 1-based)
|
||||
const startIndex = Math.max(0, offset - 1);
|
||||
const endIndex = Math.min(lines.length, startIndex + limit);
|
||||
const selectedLines = lines.slice(startIndex, endIndex);
|
||||
|
||||
return { content: selectedLines.join("\n") };
|
||||
} else {
|
||||
return { content };
|
||||
}
|
||||
}
|
||||
|
||||
return agent.readTextFile({
|
||||
sessionId,
|
||||
path: input.file_path,
|
||||
line: input.offset,
|
||||
limit: input.limit,
|
||||
});
|
||||
}
|
||||
|
||||
async function writeTextFile(input: FileWriteInput): Promise<void> {
|
||||
if (internalPath(input.file_path)) {
|
||||
await fs.writeFile(input.file_path, input.content, "utf8");
|
||||
} else {
|
||||
await agent.writeTextFile({
|
||||
sessionId,
|
||||
path: input.file_path,
|
||||
content: input.content,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Create MCP server
|
||||
const server = new McpServer({ name: "acp", version: "1.0.0" }, { capabilities: { tools: {} } });
|
||||
|
||||
@@ -50,7 +108,7 @@ export function createMcpServer(
|
||||
title: unqualifiedToolNames.read,
|
||||
description: `Reads the content of the given file in the project.
|
||||
|
||||
In sessions with ${toolNames.read} always use it instead of Read as it contains the most up-to-date contents.
|
||||
In sessions with ${acpToolNames.read} always use it instead of Read as it contains the most up-to-date contents.
|
||||
|
||||
Reads a file from the local filesystem. If the User provides a path to a file assume that path is valid. It is okay to read a file that does not exist; an error will be returned.
|
||||
|
||||
@@ -60,7 +118,7 @@ Usage:
|
||||
- You can optionally specify a line offset and limit (especially handy for long files), but it's recommended to read the whole file by not providing these parameters
|
||||
- Any files larger than ${defaults.maxFileSize} bytes will be truncated
|
||||
- This tool allows Claude Code to read images (eg PNG, JPG, etc). When reading an image file the contents are presented visually as Claude Code is a multimodal LLM.
|
||||
- This tool can only read files, not directories. To read a directory, use an ls command via the ${toolNames.bash} tool.
|
||||
- This tool can only read files, not directories. To read a directory, use an ls command via the ${acpToolNames.bash} tool.
|
||||
- You have the capability to call multiple tools in a single response. It is always better to speculatively read multiple files as a batch that are potentially useful.`,
|
||||
inputSchema: {
|
||||
file_path: z.string().describe("The absolute path to the file to read"),
|
||||
@@ -87,7 +145,7 @@ Usage:
|
||||
idempotentHint: false,
|
||||
},
|
||||
},
|
||||
async (input) => {
|
||||
async (input: FileReadInput) => {
|
||||
try {
|
||||
const session = agent.sessions[sessionId];
|
||||
if (!session) {
|
||||
@@ -101,19 +159,18 @@ Usage:
|
||||
};
|
||||
}
|
||||
|
||||
const content = await agent.readTextFile({
|
||||
sessionId,
|
||||
path: input.file_path,
|
||||
line: input.offset,
|
||||
limit: input.limit,
|
||||
});
|
||||
const readResponse = await readTextFile(input);
|
||||
|
||||
if (typeof readResponse?.content !== "string") {
|
||||
throw new Error(`No file contents for ${input.file_path}.`);
|
||||
}
|
||||
|
||||
// Extract lines with byte limit enforcement
|
||||
const result = extractLinesWithByteLimit(content.content, defaults.maxFileSize);
|
||||
const result = extractLinesWithByteLimit(readResponse.content, defaults.maxFileSize);
|
||||
|
||||
// Construct informative message about what was read
|
||||
let readInfo = "";
|
||||
if (input.offset > 1 || result.wasLimited) {
|
||||
if ((input.offset && input.offset > 1) || result.wasLimited) {
|
||||
readInfo = "\n\n<file-read-info>";
|
||||
|
||||
if (result.wasLimited) {
|
||||
@@ -158,12 +215,12 @@ Usage:
|
||||
title: unqualifiedToolNames.write,
|
||||
description: `Writes a file to the local filesystem..
|
||||
|
||||
In sessions with ${toolNames.write} always use it instead of Write as it will
|
||||
In sessions with ${acpToolNames.write} always use it instead of Write as it will
|
||||
allow the user to conveniently review changes.
|
||||
|
||||
Usage:
|
||||
- This tool will overwrite the existing file if there is one at the provided path.
|
||||
- If this is an existing file, you MUST use the ${toolNames.read} tool first to read the file's contents. This tool will fail if you did not read the file first.
|
||||
- If this is an existing file, you MUST use the ${acpToolNames.read} tool first to read the file's contents. This tool will fail if you did not read the file first.
|
||||
- ALWAYS prefer editing existing files in the codebase. NEVER write new files unless explicitly required.
|
||||
- NEVER proactively create documentation files (*.md) or README files. Only create documentation files if explicitly requested by the User.
|
||||
- Only use emojis if the user explicitly requests it. Avoid writing emojis to files unless asked.`,
|
||||
@@ -181,7 +238,7 @@ Usage:
|
||||
idempotentHint: false,
|
||||
},
|
||||
},
|
||||
async (input) => {
|
||||
async (input: FileWriteInput) => {
|
||||
try {
|
||||
const session = agent.sessions[sessionId];
|
||||
if (!session) {
|
||||
@@ -194,11 +251,7 @@ Usage:
|
||||
],
|
||||
};
|
||||
}
|
||||
await agent.writeTextFile({
|
||||
sessionId,
|
||||
path: input.file_path,
|
||||
content: input.content,
|
||||
});
|
||||
await writeTextFile(input);
|
||||
|
||||
return {
|
||||
content: [],
|
||||
@@ -222,11 +275,11 @@ Usage:
|
||||
title: unqualifiedToolNames.edit,
|
||||
description: `Performs exact string replacements in files.
|
||||
|
||||
In sessions with ${toolNames.edit} always use it instead of Edit as it will
|
||||
In sessions with ${acpToolNames.edit} always use it instead of Edit as it will
|
||||
allow the user to conveniently review changes.
|
||||
|
||||
Usage:
|
||||
- You must use your \`${toolNames.read}\` tool at least once in the conversation before editing. This tool will error if you attempt an edit without reading the file.
|
||||
- You must use your \`${acpToolNames.read}\` tool at least once in the conversation before editing. This tool will error if you attempt an edit without reading the file.
|
||||
- When editing text from Read tool output, ensure you preserve the exact indentation (tabs/spaces) as it appears.
|
||||
- ALWAYS prefer editing existing files in the codebase. NEVER write new files unless explicitly required.
|
||||
- Only use emojis if the user explicitly requests it. Avoid adding emojis to files unless asked.
|
||||
@@ -242,7 +295,7 @@ Usage:
|
||||
.boolean()
|
||||
.default(false)
|
||||
.optional()
|
||||
.describe("Replace all occurences of old_string (default false)"),
|
||||
.describe("Replace all occurrences of old_string (default false)"),
|
||||
},
|
||||
annotations: {
|
||||
title: "Edit file",
|
||||
@@ -252,48 +305,58 @@ Usage:
|
||||
idempotentHint: false,
|
||||
},
|
||||
},
|
||||
async (input) => {
|
||||
const session = agent.sessions[sessionId];
|
||||
if (!session) {
|
||||
async (input: FileEditInput) => {
|
||||
try {
|
||||
const session = agent.sessions[sessionId];
|
||||
if (!session) {
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: "The user has left the building",
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
const readResponse = await readTextFile({
|
||||
file_path: input.file_path,
|
||||
});
|
||||
|
||||
if (typeof readResponse?.content !== "string") {
|
||||
throw new Error(`No file contents for ${input.file_path}.`);
|
||||
}
|
||||
|
||||
const { newContent } = replaceAndCalculateLocation(readResponse.content, [
|
||||
{
|
||||
oldText: input.old_string,
|
||||
newText: input.new_string,
|
||||
replaceAll: input.replace_all,
|
||||
},
|
||||
]);
|
||||
|
||||
const patch = diff.createPatch(input.file_path, readResponse.content, newContent);
|
||||
|
||||
await writeTextFile({ file_path: input.file_path, content: newContent });
|
||||
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: "The user has left the building",
|
||||
text: patch,
|
||||
},
|
||||
],
|
||||
};
|
||||
} catch (error: any) {
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: "Editing file failed: " + (error?.message ?? String(error)),
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
const { content } = await agent.readTextFile({
|
||||
sessionId,
|
||||
path: input.file_path,
|
||||
});
|
||||
|
||||
const { newContent } = replaceAndCalculateLocation(content, [
|
||||
{
|
||||
oldText: input.old_string,
|
||||
newText: input.new_string,
|
||||
replaceAll: input.replace_all,
|
||||
},
|
||||
]);
|
||||
|
||||
const patch = diff.createPatch(input.file_path, content, newContent);
|
||||
|
||||
await agent.writeTextFile({
|
||||
sessionId,
|
||||
path: input.file_path,
|
||||
content: newContent,
|
||||
});
|
||||
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: patch,
|
||||
},
|
||||
],
|
||||
};
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -305,13 +368,10 @@ Usage:
|
||||
title: unqualifiedToolNames.bash,
|
||||
description: `Executes a bash command
|
||||
|
||||
In sessions with ${toolNames.bash} always use it instead of Bash`,
|
||||
In sessions with ${acpToolNames.bash} always use it instead of Bash`,
|
||||
inputSchema: {
|
||||
command: z.string().describe("The command to execute"),
|
||||
timeout: z
|
||||
.number()
|
||||
.default(2 * 60 * 1000)
|
||||
.describe(`Optional timeout in milliseconds (max ${2 * 60 * 1000})`),
|
||||
timeout: z.number().describe(`Optional timeout in milliseconds (max ${2 * 60 * 1000})`),
|
||||
description: z.string().optional()
|
||||
.describe(`Clear, concise description of what this command does in 5-10 words, in active voice. Examples:
|
||||
Input: ls
|
||||
@@ -329,11 +389,11 @@ Output: Create directory 'foo'`),
|
||||
.boolean()
|
||||
.default(false)
|
||||
.describe(
|
||||
`Set to true to run this command in the background. The tool returns an \`id\` that can be used with the \`${toolNames.bashOutput}\` tool to retrieve the current output, or the \`${toolNames.killShell}\` tool to stop it early.`,
|
||||
`Set to true to run this command in the background. The tool returns an \`id\` that can be used with the \`${acpToolNames.bashOutput}\` tool to retrieve the current output, or the \`${acpToolNames.killShell}\` tool to stop it early.`,
|
||||
),
|
||||
},
|
||||
},
|
||||
async (input, extra) => {
|
||||
async (input: BashInput, extra) => {
|
||||
const session = agent.sessions[sessionId];
|
||||
if (!session) {
|
||||
return {
|
||||
@@ -358,6 +418,7 @@ Output: Create directory 'foo'`),
|
||||
|
||||
const handle = await agent.client.createTerminal({
|
||||
command: input.command,
|
||||
env: [{ name: "CLAUDECODE", value: "1" }],
|
||||
sessionId,
|
||||
outputByteLimit: 32_000,
|
||||
});
|
||||
@@ -386,7 +447,7 @@ Output: Create directory 'foo'`),
|
||||
const statusPromise = Promise.race([
|
||||
handle.waitForExit().then((exitStatus) => ({ status: "exited" as const, exitStatus })),
|
||||
abortPromise.then(() => ({ status: "aborted" as const, exitStatus: null })),
|
||||
sleep(input.timeout).then(async () => {
|
||||
sleep(input.timeout ?? 2 * 60 * 1000).then(async () => {
|
||||
if (agent.backgroundTerminals[handle.id]?.status === "started") {
|
||||
await handle.kill();
|
||||
}
|
||||
@@ -455,23 +516,25 @@ Output: Create directory 'foo'`),
|
||||
{
|
||||
title: unqualifiedToolNames.bashOutput,
|
||||
description: `- Retrieves output from a running or completed background bash shell
|
||||
- Takes a shell_id parameter identifying the shell
|
||||
- Takes a bash_id parameter identifying the shell
|
||||
- Always returns only new output since the last check
|
||||
- Returns stdout and stderr output along with shell status
|
||||
- Use this tool when you need to monitor or check the output of a long-running shell
|
||||
|
||||
In sessions with ${toolNames.bashOutput} always use it instead of BashOutput.`,
|
||||
In sessions with ${acpToolNames.bashOutput} always use it for output from Bash commands instead of TaskOutput.`,
|
||||
inputSchema: {
|
||||
shell_id: z
|
||||
bash_id: z
|
||||
.string()
|
||||
.describe(`The id of the background bash command as returned by \`${toolNames.bash}\``),
|
||||
.describe(
|
||||
`The id of the background bash command as returned by \`${acpToolNames.bash}\``,
|
||||
),
|
||||
},
|
||||
},
|
||||
async (input) => {
|
||||
const bgTerm = agent.backgroundTerminals[input.shell_id];
|
||||
const bgTerm = agent.backgroundTerminals[input.bash_id];
|
||||
|
||||
if (!bgTerm) {
|
||||
throw new Error(`Unknown shell ${input.shell_id}`);
|
||||
throw new Error(`Unknown shell ${input.bash_id}`);
|
||||
}
|
||||
|
||||
if (bgTerm.status === "started") {
|
||||
@@ -515,14 +578,16 @@ In sessions with ${toolNames.bashOutput} always use it instead of BashOutput.`,
|
||||
- Returns a success or failure status
|
||||
- Use this tool when you need to terminate a long-running shell
|
||||
|
||||
In sessions with ${toolNames.killShell} always use it instead of KillShell.`,
|
||||
In sessions with ${acpToolNames.killShell} always use it instead of KillShell.`,
|
||||
inputSchema: {
|
||||
shell_id: z
|
||||
.string()
|
||||
.describe(`The id of the background bash command as returned by \`${toolNames.bash}\``),
|
||||
.describe(
|
||||
`The id of the background bash command as returned by \`${acpToolNames.bash}\``,
|
||||
),
|
||||
},
|
||||
},
|
||||
async (input) => {
|
||||
async (input: KillShellInput) => {
|
||||
const bgTerm = agent.backgroundTerminals[input.shell_id];
|
||||
|
||||
if (!bgTerm) {
|
||||
@@ -563,7 +628,8 @@ In sessions with ${toolNames.killShell} always use it instead of KillShell.`,
|
||||
content: [{ type: "text", text: "Command killed by timeout." }],
|
||||
};
|
||||
default: {
|
||||
return unreachable(bgTerm);
|
||||
unreachable(bgTerm);
|
||||
throw new Error("Unexpected background terminal status");
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
522
src/settings.ts
Normal file
522
src/settings.ts
Normal file
@@ -0,0 +1,522 @@
|
||||
import * as fs from "node:fs";
|
||||
import * as path from "node:path";
|
||||
import * as os from "node:os";
|
||||
import { minimatch } from "minimatch";
|
||||
import { ACP_TOOL_NAME_PREFIX, acpToolNames } from "./tools.js";
|
||||
import { CLAUDE_CONFIG_DIR } from "./acp-agent.js";
|
||||
|
||||
/**
|
||||
* Permission rule format examples:
|
||||
* - "Read" - matches all Read tool calls
|
||||
* - "Read(./.env)" - matches specific path
|
||||
* - "Read(./.env.*)" - glob pattern
|
||||
* - "Read(./secrets/**)" - recursive glob
|
||||
* - "Bash(npm run lint)" - exact command prefix
|
||||
* - "Bash(npm run:*)" - command prefix with wildcard
|
||||
*
|
||||
* Docs: https://code.claude.com/docs/en/iam#tool-specific-permission-rules
|
||||
*/
|
||||
|
||||
export interface PermissionSettings {
|
||||
allow?: string[];
|
||||
deny?: string[];
|
||||
ask?: string[];
|
||||
additionalDirectories?: string[];
|
||||
defaultMode?: string;
|
||||
}
|
||||
|
||||
export interface ClaudeCodeSettings {
|
||||
permissions?: PermissionSettings;
|
||||
env?: Record<string, string>;
|
||||
}
|
||||
|
||||
export type PermissionDecision = "allow" | "deny" | "ask";
|
||||
|
||||
export interface PermissionCheckResult {
|
||||
decision: PermissionDecision;
|
||||
rule?: string;
|
||||
source?: "allow" | "deny" | "ask";
|
||||
}
|
||||
|
||||
interface ParsedRule {
|
||||
toolName: string;
|
||||
argument?: string;
|
||||
isWildcard?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Shell operators that can be used for command chaining/injection
|
||||
* These should cause a prefix match to fail to prevent bypasses like:
|
||||
* - "safe-cmd && malicious-cmd"
|
||||
* - "safe-cmd; malicious-cmd"
|
||||
* - "safe-cmd | malicious-cmd"
|
||||
* - "safe-cmd || malicious-cmd"
|
||||
* - "$(malicious-cmd)"
|
||||
* - "`malicious-cmd`"
|
||||
*/
|
||||
const SHELL_OPERATORS = ["&&", "||", ";", "|", "$(", "`", "\n"];
|
||||
|
||||
/**
|
||||
* Checks if a string contains shell operators that could allow command chaining
|
||||
*/
|
||||
function containsShellOperator(str: string): boolean {
|
||||
return SHELL_OPERATORS.some((op) => str.includes(op));
|
||||
}
|
||||
|
||||
/*
|
||||
* Tools that modify files. Per Claude Code docs:
|
||||
* "Edit rules apply to all built-in tools that edit files."
|
||||
* This means an Edit(...) rule should match Write, MultiEdit, etc.
|
||||
*/
|
||||
const FILE_EDITING_TOOLS = [acpToolNames.edit, acpToolNames.write];
|
||||
|
||||
/**
|
||||
* Tools that read files. Per Claude Code docs:
|
||||
* "Claude will make a best-effort attempt to apply Read rules to all built-in tools
|
||||
* that read files like Grep and Glob."
|
||||
* This means a Read(...) rule should match Grep, Glob, etc.
|
||||
*/
|
||||
const FILE_READING_TOOLS = [acpToolNames.read];
|
||||
|
||||
/**
|
||||
* Functions to extract the relevant argument from tool input for permission matching
|
||||
*/
|
||||
const TOOL_ARG_ACCESSORS: Record<string, (input: unknown) => string | undefined> = {
|
||||
mcp__acp__Read: (input) => (input as { file_path?: string })?.file_path,
|
||||
mcp__acp__Edit: (input) => (input as { file_path?: string })?.file_path,
|
||||
mcp__acp__Write: (input) => (input as { file_path?: string })?.file_path,
|
||||
mcp__acp__Bash: (input) => (input as { command?: string })?.command,
|
||||
};
|
||||
|
||||
/**
|
||||
* Parses a permission rule string into its components
|
||||
* Examples:
|
||||
* "Read" -> { toolName: "Read" }
|
||||
* "Read(./.env)" -> { toolName: "Read", argument: "./.env" }
|
||||
* "Bash(npm run:*)" -> { toolName: "Bash", argument: "npm run", isWildcard: true }
|
||||
*/
|
||||
function parseRule(rule: string): ParsedRule {
|
||||
const match = rule.match(/^(\w+)(?:\((.+)\))?$/);
|
||||
if (!match) {
|
||||
return { toolName: rule };
|
||||
}
|
||||
|
||||
const [, toolName, argument] = match;
|
||||
|
||||
if (argument && argument.endsWith(":*")) {
|
||||
return {
|
||||
toolName,
|
||||
argument: argument.slice(0, -2),
|
||||
isWildcard: true,
|
||||
};
|
||||
}
|
||||
|
||||
return { toolName, argument };
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalizes a path for comparison:
|
||||
* - Expands ~ to home directory
|
||||
* - Resolves relative paths against cwd
|
||||
* - Normalizes path separators
|
||||
*/
|
||||
function normalizePath(filePath: string, cwd: string): string {
|
||||
if (filePath.startsWith("~/")) {
|
||||
filePath = path.join(os.homedir(), filePath.slice(2));
|
||||
} else if (filePath.startsWith("./")) {
|
||||
filePath = path.join(cwd, filePath.slice(2));
|
||||
} else if (!path.isAbsolute(filePath)) {
|
||||
filePath = path.join(cwd, filePath);
|
||||
}
|
||||
return path.normalize(filePath);
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if a file path matches a glob pattern
|
||||
*/
|
||||
function matchesGlob(pattern: string, filePath: string, cwd: string): boolean {
|
||||
const normalizedPattern = normalizePath(pattern, cwd);
|
||||
const normalizedPath = normalizePath(filePath, cwd);
|
||||
|
||||
return minimatch(normalizedPath, normalizedPattern, {
|
||||
dot: true,
|
||||
matchBase: false,
|
||||
nocase: process.platform === "win32",
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if a tool invocation matches a parsed permission rule
|
||||
*/
|
||||
function matchesRule(rule: ParsedRule, toolName: string, toolInput: unknown, cwd: string): boolean {
|
||||
// Per Claude Code docs:
|
||||
// - "Edit rules apply to all built-in tools that edit files."
|
||||
// - "Claude will make a best-effort attempt to apply Read rules to all built-in tools
|
||||
// that read files like Grep, Glob, and LS."
|
||||
const ruleAppliesToTool =
|
||||
rule.toolName === "Bash" ||
|
||||
(rule.toolName === "Edit" && FILE_EDITING_TOOLS.includes(toolName)) ||
|
||||
(rule.toolName === "Read" && FILE_READING_TOOLS.includes(toolName));
|
||||
|
||||
if (!ruleAppliesToTool) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!rule.argument) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const argAccessor = TOOL_ARG_ACCESSORS[toolName];
|
||||
if (!argAccessor) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const actualArg = argAccessor(toolInput);
|
||||
if (!actualArg) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (toolName === acpToolNames.bash) {
|
||||
// Per Claude Code docs: https://code.claude.com/docs/en/iam#tool-specific-permission-rules
|
||||
// - Bash(npm run build) matches the EXACT command "npm run build"
|
||||
// - Bash(npm run test:*) matches commands STARTING WITH "npm run test"
|
||||
// The :* suffix enables prefix matching, without it the match is exact
|
||||
//
|
||||
// Also from docs: "Claude Code is aware of shell operators (like &&) so a prefix match
|
||||
// rule like Bash(safe-cmd:*) won't give it permission to run the command safe-cmd && other-cmd"
|
||||
if (rule.isWildcard) {
|
||||
if (!actualArg.startsWith(rule.argument)) {
|
||||
return false;
|
||||
}
|
||||
// Check that the matched prefix isn't followed by shell operators that could
|
||||
// allow command chaining/injection
|
||||
const remainder = actualArg.slice(rule.argument.length);
|
||||
if (containsShellOperator(remainder)) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
return actualArg === rule.argument;
|
||||
}
|
||||
|
||||
// For file-based tools (Read, Edit, Write), use glob matching
|
||||
return matchesGlob(rule.argument, actualArg, cwd);
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads and parses a JSON settings file, returning an empty object if not found or invalid
|
||||
*/
|
||||
async function loadSettingsFile(filePath: string | null): Promise<ClaudeCodeSettings> {
|
||||
if (!filePath) {
|
||||
return {};
|
||||
}
|
||||
|
||||
try {
|
||||
const content = await fs.promises.readFile(filePath, "utf-8");
|
||||
return JSON.parse(content) as ClaudeCodeSettings;
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the enterprise settings path based on the current platform
|
||||
*/
|
||||
export function getManagedSettingsPath(): string {
|
||||
switch (process.platform) {
|
||||
case "darwin":
|
||||
return "/Library/Application Support/ClaudeCode/managed-settings.json";
|
||||
case "linux":
|
||||
return "/etc/claude-code/managed-settings.json";
|
||||
case "win32":
|
||||
return "C:\\Program Files\\ClaudeCode\\managed-settings.json";
|
||||
default:
|
||||
return "/etc/claude-code/managed-settings.json";
|
||||
}
|
||||
}
|
||||
|
||||
export interface SettingsManagerOptions {
|
||||
onChange?: () => void;
|
||||
logger?: { log: (...args: any[]) => void; error: (...args: any[]) => void };
|
||||
}
|
||||
|
||||
/**
|
||||
* Manages Claude Code settings from multiple sources with proper precedence.
|
||||
*
|
||||
* Settings are loaded from (in order of increasing precedence):
|
||||
* 1. User settings (~/.claude/settings.json)
|
||||
* 2. Project settings (<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 {
|
||||
private cwd: string;
|
||||
private userSettings: ClaudeCodeSettings = {};
|
||||
private projectSettings: ClaudeCodeSettings = {};
|
||||
private localSettings: ClaudeCodeSettings = {};
|
||||
private enterpriseSettings: ClaudeCodeSettings = {};
|
||||
private mergedSettings: ClaudeCodeSettings = {};
|
||||
private watchers: fs.FSWatcher[] = [];
|
||||
private onChange?: () => void;
|
||||
private logger: { log: (...args: any[]) => void; error: (...args: any[]) => void };
|
||||
private initialized = false;
|
||||
private debounceTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
constructor(cwd: string, options?: SettingsManagerOptions) {
|
||||
this.cwd = cwd;
|
||||
this.onChange = options?.onChange;
|
||||
this.logger = options?.logger ?? console;
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize the settings manager by loading all settings and setting up file watchers
|
||||
*/
|
||||
async initialize(): Promise<void> {
|
||||
if (this.initialized) {
|
||||
return;
|
||||
}
|
||||
|
||||
await this.loadAllSettings();
|
||||
this.setupWatchers();
|
||||
this.initialized = true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the path to the user settings file
|
||||
*/
|
||||
private getUserSettingsPath(): string {
|
||||
return path.join(CLAUDE_CONFIG_DIR, "settings.json");
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the path to the project settings file
|
||||
*/
|
||||
private getProjectSettingsPath(): string {
|
||||
return path.join(this.cwd, ".claude", "settings.json");
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the path to the local project settings file
|
||||
*/
|
||||
private getLocalSettingsPath(): string {
|
||||
return path.join(this.cwd, ".claude", "settings.local.json");
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads settings from all sources
|
||||
*/
|
||||
private async loadAllSettings(): Promise<void> {
|
||||
const [userSettings, projectSettings, localSettings, enterpriseSettings] = await Promise.all([
|
||||
loadSettingsFile(this.getUserSettingsPath()),
|
||||
loadSettingsFile(this.getProjectSettingsPath()),
|
||||
loadSettingsFile(this.getLocalSettingsPath()),
|
||||
loadSettingsFile(getManagedSettingsPath()),
|
||||
]);
|
||||
|
||||
this.userSettings = userSettings;
|
||||
this.projectSettings = projectSettings;
|
||||
this.localSettings = localSettings;
|
||||
this.enterpriseSettings = enterpriseSettings;
|
||||
|
||||
this.mergeSettings();
|
||||
}
|
||||
|
||||
/**
|
||||
* Merges all settings sources with proper precedence.
|
||||
* For permissions, rules from all sources are combined.
|
||||
* Deny rules always take precedence during permission checks.
|
||||
*/
|
||||
private mergeSettings(): void {
|
||||
const allSettings = [
|
||||
this.userSettings,
|
||||
this.projectSettings,
|
||||
this.localSettings,
|
||||
this.enterpriseSettings,
|
||||
];
|
||||
|
||||
const merged: ClaudeCodeSettings = {
|
||||
permissions: {
|
||||
allow: [],
|
||||
deny: [],
|
||||
ask: [],
|
||||
},
|
||||
};
|
||||
|
||||
for (const settings of allSettings) {
|
||||
if (settings.permissions) {
|
||||
if (settings.permissions.allow) {
|
||||
merged.permissions!.allow!.push(...settings.permissions.allow);
|
||||
}
|
||||
if (settings.permissions.deny) {
|
||||
merged.permissions!.deny!.push(...settings.permissions.deny);
|
||||
}
|
||||
if (settings.permissions.ask) {
|
||||
merged.permissions!.ask!.push(...settings.permissions.ask);
|
||||
}
|
||||
if (settings.permissions.additionalDirectories) {
|
||||
merged.permissions!.additionalDirectories = [
|
||||
...(merged.permissions!.additionalDirectories || []),
|
||||
...settings.permissions.additionalDirectories,
|
||||
];
|
||||
}
|
||||
if (settings.permissions.defaultMode) {
|
||||
merged.permissions!.defaultMode = settings.permissions.defaultMode;
|
||||
}
|
||||
}
|
||||
|
||||
if (settings.env) {
|
||||
merged.env = { ...merged.env, ...settings.env };
|
||||
}
|
||||
}
|
||||
|
||||
this.mergedSettings = merged;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets up file watchers for all settings files
|
||||
*/
|
||||
private setupWatchers(): void {
|
||||
const paths = [
|
||||
this.getUserSettingsPath(),
|
||||
this.getProjectSettingsPath(),
|
||||
this.getLocalSettingsPath(),
|
||||
getManagedSettingsPath(),
|
||||
];
|
||||
|
||||
for (const filePath of paths) {
|
||||
if (!filePath) continue;
|
||||
|
||||
try {
|
||||
const dir = path.dirname(filePath);
|
||||
const filename = path.basename(filePath);
|
||||
|
||||
if (fs.existsSync(dir)) {
|
||||
const watcher = fs.watch(dir, (eventType, changedFilename) => {
|
||||
if (changedFilename === filename) {
|
||||
this.handleSettingsChange();
|
||||
}
|
||||
});
|
||||
|
||||
watcher.on("error", (error) => {
|
||||
this.logger.error(`Settings watcher error for ${filePath}:`, error);
|
||||
});
|
||||
|
||||
this.watchers.push(watcher);
|
||||
}
|
||||
} catch (error) {
|
||||
this.logger.error(`Failed to set up watcher for ${filePath}:`, error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles settings file changes with debouncing to avoid rapid reloads
|
||||
*/
|
||||
private handleSettingsChange(): void {
|
||||
if (this.debounceTimer) {
|
||||
clearTimeout(this.debounceTimer);
|
||||
}
|
||||
|
||||
this.debounceTimer = setTimeout(async () => {
|
||||
this.debounceTimer = null;
|
||||
try {
|
||||
await this.loadAllSettings();
|
||||
this.onChange?.();
|
||||
} catch (error) {
|
||||
this.logger.error("Failed to reload settings:", error);
|
||||
}
|
||||
}, 100);
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if a tool invocation is allowed based on the loaded settings.
|
||||
*
|
||||
* @param toolName - The tool name (can be ACP-prefixed like mcp__acp__Read or plain like Read)
|
||||
* @param toolInput - The tool input object
|
||||
* @returns The permission decision and matching rule info
|
||||
*/
|
||||
checkPermission(toolName: string, toolInput: unknown): PermissionCheckResult {
|
||||
if (!toolName.startsWith(ACP_TOOL_NAME_PREFIX)) {
|
||||
return { decision: "ask" };
|
||||
}
|
||||
|
||||
const permissions = this.mergedSettings.permissions;
|
||||
|
||||
if (!permissions) {
|
||||
return { decision: "ask" };
|
||||
}
|
||||
|
||||
// Check deny rules first (highest priority)
|
||||
for (const rule of permissions.deny || []) {
|
||||
const parsed = parseRule(rule);
|
||||
if (matchesRule(parsed, toolName, toolInput, this.cwd)) {
|
||||
return { decision: "deny", rule, source: "deny" };
|
||||
}
|
||||
}
|
||||
|
||||
// Check allow rules
|
||||
for (const rule of permissions.allow || []) {
|
||||
const parsed = parseRule(rule);
|
||||
if (matchesRule(parsed, toolName, toolInput, this.cwd)) {
|
||||
return { decision: "allow", rule, source: "allow" };
|
||||
}
|
||||
}
|
||||
|
||||
// Check ask rules
|
||||
for (const rule of permissions.ask || []) {
|
||||
const parsed = parseRule(rule);
|
||||
if (matchesRule(parsed, toolName, toolInput, this.cwd)) {
|
||||
return { decision: "ask", rule, source: "ask" };
|
||||
}
|
||||
}
|
||||
|
||||
// No matching rule - default to ask
|
||||
return { decision: "ask" };
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the current merged settings
|
||||
*/
|
||||
getSettings(): ClaudeCodeSettings {
|
||||
return this.mergedSettings;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the current working directory
|
||||
*/
|
||||
getCwd(): string {
|
||||
return this.cwd;
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates the working directory and reloads project-specific settings
|
||||
*/
|
||||
async setCwd(cwd: string): Promise<void> {
|
||||
if (this.cwd === cwd) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.dispose();
|
||||
this.cwd = cwd;
|
||||
this.initialized = false;
|
||||
await this.initialize();
|
||||
}
|
||||
|
||||
/**
|
||||
* Disposes of file watchers and cleans up resources
|
||||
*/
|
||||
dispose(): void {
|
||||
if (this.debounceTimer) {
|
||||
clearTimeout(this.debounceTimer);
|
||||
this.debounceTimer = null;
|
||||
}
|
||||
|
||||
for (const watcher of this.watchers) {
|
||||
watcher.close();
|
||||
}
|
||||
this.watchers = [];
|
||||
this.initialized = false;
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
description: Say hello
|
||||
argument-hint: [name]
|
||||
argument-hint: name
|
||||
---
|
||||
|
||||
Respond with "Hello $1" and nothing else.
|
||||
|
||||
@@ -20,6 +20,7 @@ import { nodeToWebWritable, nodeToWebReadable } from "../utils.js";
|
||||
import { markdownEscape, toolInfoFromToolUse, toolUpdateFromToolResult } from "../tools.js";
|
||||
import { toAcpNotifications, promptToClaude } from "../acp-agent.js";
|
||||
import { query, SDKAssistantMessage } from "@anthropic-ai/claude-agent-sdk";
|
||||
import { randomUUID } from "crypto";
|
||||
|
||||
describe.skipIf(!process.env.RUN_INTEGRATION_TESTS)("ACP subprocess integration", () => {
|
||||
let child: ReturnType<typeof spawn>;
|
||||
@@ -165,7 +166,7 @@ describe.skipIf(!process.env.RUN_INTEGRATION_TESTS)("ACP subprocess integration"
|
||||
expect(commands).toContainEqual({
|
||||
name: "say-hello",
|
||||
description: "Say hello (project)",
|
||||
input: { hint: "[name]" },
|
||||
input: { hint: "name" },
|
||||
});
|
||||
|
||||
await connection.prompt({
|
||||
@@ -250,7 +251,7 @@ describe("tool conversions", () => {
|
||||
},
|
||||
};
|
||||
|
||||
expect(toolInfoFromToolUse(tool_use, {})).toStrictEqual({
|
||||
expect(toolInfoFromToolUse(tool_use)).toStrictEqual({
|
||||
kind: "execute",
|
||||
title: "`rm README.md.rm`",
|
||||
content: [
|
||||
@@ -275,7 +276,7 @@ describe("tool conversions", () => {
|
||||
},
|
||||
};
|
||||
|
||||
expect(toolInfoFromToolUse(tool_use, {})).toStrictEqual({
|
||||
expect(toolInfoFromToolUse(tool_use)).toStrictEqual({
|
||||
kind: "search",
|
||||
title: "Find `*/**.ts`",
|
||||
content: [],
|
||||
@@ -296,7 +297,7 @@ describe("tool conversions", () => {
|
||||
},
|
||||
};
|
||||
|
||||
expect(toolInfoFromToolUse(tool_use, {})).toStrictEqual({
|
||||
expect(toolInfoFromToolUse(tool_use)).toStrictEqual({
|
||||
kind: "think",
|
||||
title: "Handle user's work request",
|
||||
content: [
|
||||
@@ -321,7 +322,7 @@ describe("tool conversions", () => {
|
||||
},
|
||||
};
|
||||
|
||||
expect(toolInfoFromToolUse(tool_use, {})).toStrictEqual({
|
||||
expect(toolInfoFromToolUse(tool_use)).toStrictEqual({
|
||||
kind: "search",
|
||||
title: "List the `/Users/test/github/claude-code-acp` directory's contents",
|
||||
content: [],
|
||||
@@ -339,7 +340,7 @@ describe("tool conversions", () => {
|
||||
},
|
||||
};
|
||||
|
||||
expect(toolInfoFromToolUse(tool_use, {})).toStrictEqual({
|
||||
expect(toolInfoFromToolUse(tool_use)).toStrictEqual({
|
||||
kind: "search",
|
||||
title: 'grep ".*"',
|
||||
content: [],
|
||||
@@ -357,7 +358,7 @@ describe("tool conversions", () => {
|
||||
},
|
||||
};
|
||||
|
||||
expect(toolInfoFromToolUse(tool_use, {})).toStrictEqual({
|
||||
expect(toolInfoFromToolUse(tool_use)).toStrictEqual({
|
||||
kind: "edit",
|
||||
title: "Write /Users/test/project/example.txt",
|
||||
content: [
|
||||
@@ -383,7 +384,7 @@ describe("tool conversions", () => {
|
||||
},
|
||||
};
|
||||
|
||||
expect(toolInfoFromToolUse(tool_use, {})).toStrictEqual({
|
||||
expect(toolInfoFromToolUse(tool_use)).toStrictEqual({
|
||||
kind: "edit",
|
||||
title: "Write /Users/test/project/config.json",
|
||||
content: [
|
||||
@@ -408,7 +409,7 @@ describe("tool conversions", () => {
|
||||
},
|
||||
};
|
||||
|
||||
expect(toolInfoFromToolUse(tool_use, {})).toStrictEqual({
|
||||
expect(toolInfoFromToolUse(tool_use)).toStrictEqual({
|
||||
kind: "read",
|
||||
title: "Read File",
|
||||
content: [],
|
||||
@@ -426,7 +427,7 @@ describe("tool conversions", () => {
|
||||
},
|
||||
};
|
||||
|
||||
expect(toolInfoFromToolUse(tool_use, {})).toStrictEqual({
|
||||
expect(toolInfoFromToolUse(tool_use)).toStrictEqual({
|
||||
kind: "read",
|
||||
title: "Read /Users/test/project/data.json",
|
||||
content: [],
|
||||
@@ -445,7 +446,7 @@ describe("tool conversions", () => {
|
||||
},
|
||||
};
|
||||
|
||||
expect(toolInfoFromToolUse(tool_use, {})).toStrictEqual({
|
||||
expect(toolInfoFromToolUse(tool_use)).toStrictEqual({
|
||||
kind: "read",
|
||||
title: "Read /Users/test/project/large.txt (1 - 100)",
|
||||
content: [],
|
||||
@@ -465,7 +466,7 @@ describe("tool conversions", () => {
|
||||
},
|
||||
};
|
||||
|
||||
expect(toolInfoFromToolUse(tool_use, {})).toStrictEqual({
|
||||
expect(toolInfoFromToolUse(tool_use)).toStrictEqual({
|
||||
kind: "read",
|
||||
title: "Read /Users/test/project/large.txt (51 - 150)",
|
||||
content: [],
|
||||
@@ -484,7 +485,7 @@ describe("tool conversions", () => {
|
||||
},
|
||||
};
|
||||
|
||||
expect(toolInfoFromToolUse(tool_use, {})).toStrictEqual({
|
||||
expect(toolInfoFromToolUse(tool_use)).toStrictEqual({
|
||||
kind: "read",
|
||||
title: "Read /Users/test/project/large.txt (from line 201)",
|
||||
content: [],
|
||||
@@ -492,50 +493,6 @@ describe("tool conversions", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("should handle WebFetch tool calls", () => {
|
||||
const tool_use = {
|
||||
type: "tool_use",
|
||||
id: "toolu_01LxEjDn8ci9SAc3qG7LbbXV",
|
||||
name: "WebFetch",
|
||||
input: {
|
||||
url: "https://agentclientprotocol.com",
|
||||
prompt:
|
||||
"Please provide a comprehensive summary of the content on this page, including what the Agent Client Protocol is, its main features, documentation links, and any other relevant information.",
|
||||
},
|
||||
};
|
||||
|
||||
expect(toolInfoFromToolUse(tool_use, {})).toStrictEqual({
|
||||
kind: "fetch",
|
||||
title: "Fetch https://agentclientprotocol.com",
|
||||
content: [
|
||||
{
|
||||
content: {
|
||||
text: "Please provide a comprehensive summary of the content on this page, including what the Agent Client Protocol is, its main features, documentation links, and any other relevant information.",
|
||||
type: "text",
|
||||
},
|
||||
type: "content",
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it("should handle WebSearch tool calls", () => {
|
||||
const tool_use = {
|
||||
type: "tool_use",
|
||||
id: "toolu_01NYMwiZFbdoQFxYxuQDFZXQ",
|
||||
name: "WebSearch",
|
||||
input: {
|
||||
query: "agentclientprotocol.com",
|
||||
},
|
||||
};
|
||||
|
||||
expect(toolInfoFromToolUse(tool_use, {})).toStrictEqual({
|
||||
kind: "fetch",
|
||||
title: '"agentclientprotocol.com"',
|
||||
content: [],
|
||||
});
|
||||
});
|
||||
|
||||
it("should handle KillBash entries", () => {
|
||||
const tool_use = {
|
||||
type: "tool_use",
|
||||
@@ -546,7 +503,7 @@ describe("tool conversions", () => {
|
||||
},
|
||||
};
|
||||
|
||||
expect(toolInfoFromToolUse(tool_use, {})).toStrictEqual({
|
||||
expect(toolInfoFromToolUse(tool_use)).toStrictEqual({
|
||||
kind: "execute",
|
||||
title: `Kill Process`,
|
||||
content: [],
|
||||
@@ -563,7 +520,7 @@ describe("tool conversions", () => {
|
||||
},
|
||||
};
|
||||
|
||||
expect(toolInfoFromToolUse(tool_use, {})).toStrictEqual({
|
||||
expect(toolInfoFromToolUse(tool_use)).toStrictEqual({
|
||||
kind: "execute",
|
||||
title: `Tail Logs`,
|
||||
content: [],
|
||||
@@ -656,8 +613,8 @@ describe("tool conversions", () => {
|
||||
received.message.role,
|
||||
"test",
|
||||
{},
|
||||
{},
|
||||
{} as AgentSideConnection,
|
||||
console,
|
||||
),
|
||||
).toStrictEqual([
|
||||
{
|
||||
@@ -829,17 +786,29 @@ describe("prompt conversion", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("SDK behavior", () => {
|
||||
it.skipIf(!process.env.RUN_INTEGRATION_TESTS)(
|
||||
"query has a 'default' model",
|
||||
async () => {
|
||||
const q = query({ prompt: "hi" });
|
||||
const models = await q.supportedModels();
|
||||
const defaultModel = models.find((m) => m.value === "default");
|
||||
expect(defaultModel).toBeDefined();
|
||||
},
|
||||
10000,
|
||||
);
|
||||
describe.skipIf(!process.env.RUN_INTEGRATION_TESTS)("SDK behavior", () => {
|
||||
it("query has a 'default' model", async () => {
|
||||
const q = query({ prompt: "hi" });
|
||||
const models = await q.supportedModels();
|
||||
const defaultModel = models.find((m) => m.value === "default");
|
||||
expect(defaultModel).toBeDefined();
|
||||
}, 10000);
|
||||
|
||||
it("custom session id", async () => {
|
||||
const sessionId = randomUUID();
|
||||
const q = query({
|
||||
prompt: "hi",
|
||||
options: {
|
||||
systemPrompt: { type: "preset", preset: "claude_code" },
|
||||
extraArgs: { "session-id": sessionId },
|
||||
settingSources: ["user", "project", "local"],
|
||||
includePartialMessages: true,
|
||||
},
|
||||
});
|
||||
|
||||
const { value } = await q.next();
|
||||
expect(value).toMatchObject({ type: "system", subtype: "init", session_id: sessionId });
|
||||
}, 10000);
|
||||
});
|
||||
|
||||
describe("permission requests", () => {
|
||||
@@ -877,7 +846,7 @@ describe("permission requests", () => {
|
||||
|
||||
for (const testCase of testCases) {
|
||||
// Get the tool info that would be used in requestPermission
|
||||
const toolInfo = toolInfoFromToolUse(testCase.toolUse, {});
|
||||
const toolInfo = toolInfoFromToolUse(testCase.toolUse);
|
||||
|
||||
// Verify toolInfo has a title
|
||||
expect(toolInfo.title).toBeDefined();
|
||||
|
||||
617
src/tests/settings.test.ts
Normal file
617
src/tests/settings.test.ts
Normal file
@@ -0,0 +1,617 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
||||
import { SettingsManager } from "../settings.js";
|
||||
import * as fs from "node:fs";
|
||||
import * as path from "node:path";
|
||||
import * as os from "node:os";
|
||||
|
||||
describe("SettingsManager", () => {
|
||||
let tempDir: string;
|
||||
let settingsManager: SettingsManager;
|
||||
|
||||
beforeEach(async () => {
|
||||
tempDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "settings-test-"));
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
settingsManager?.dispose();
|
||||
await fs.promises.rm(tempDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe("permission checking", () => {
|
||||
it("should return 'ask' when no settings exist", async () => {
|
||||
settingsManager = new SettingsManager(tempDir);
|
||||
await settingsManager.initialize();
|
||||
|
||||
const result = settingsManager.checkPermission("mcp__acp__Read", {
|
||||
file_path: "/some/file.txt",
|
||||
});
|
||||
expect(result.decision).toBe("ask");
|
||||
});
|
||||
|
||||
it("should return 'ask' for non-ACP tools (permission checks only apply to mcp__acp__* tools)", async () => {
|
||||
const claudeDir = path.join(tempDir, ".claude");
|
||||
await fs.promises.mkdir(claudeDir, { recursive: true });
|
||||
await fs.promises.writeFile(
|
||||
path.join(claudeDir, "settings.json"),
|
||||
JSON.stringify({
|
||||
permissions: {
|
||||
deny: ["Read"],
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
settingsManager = new SettingsManager(tempDir);
|
||||
await settingsManager.initialize();
|
||||
|
||||
// Non-ACP tools should always return 'ask' regardless of rules
|
||||
const result = settingsManager.checkPermission("Read", { file_path: "/some/file.txt" });
|
||||
expect(result.decision).toBe("ask");
|
||||
});
|
||||
|
||||
it("should allow tool use when matching allow rule exists", async () => {
|
||||
const claudeDir = path.join(tempDir, ".claude");
|
||||
await fs.promises.mkdir(claudeDir, { recursive: true });
|
||||
await fs.promises.writeFile(
|
||||
path.join(claudeDir, "settings.json"),
|
||||
JSON.stringify({
|
||||
permissions: {
|
||||
allow: ["Read"],
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
settingsManager = new SettingsManager(tempDir);
|
||||
await settingsManager.initialize();
|
||||
|
||||
const result = settingsManager.checkPermission("mcp__acp__Read", {
|
||||
file_path: "/some/file.txt",
|
||||
});
|
||||
expect(result.decision).toBe("allow");
|
||||
expect(result.rule).toBe("Read");
|
||||
});
|
||||
|
||||
it("should deny tool use when matching deny rule exists", async () => {
|
||||
const claudeDir = path.join(tempDir, ".claude");
|
||||
await fs.promises.mkdir(claudeDir, { recursive: true });
|
||||
await fs.promises.writeFile(
|
||||
path.join(claudeDir, "settings.json"),
|
||||
JSON.stringify({
|
||||
permissions: {
|
||||
deny: ["Read(./.env)"],
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
settingsManager = new SettingsManager(tempDir);
|
||||
await settingsManager.initialize();
|
||||
|
||||
const result = settingsManager.checkPermission("mcp__acp__Read", {
|
||||
file_path: path.join(tempDir, ".env"),
|
||||
});
|
||||
expect(result.decision).toBe("deny");
|
||||
expect(result.rule).toBe("Read(./.env)");
|
||||
});
|
||||
|
||||
it("should prioritize deny rules over allow rules", async () => {
|
||||
const claudeDir = path.join(tempDir, ".claude");
|
||||
await fs.promises.mkdir(claudeDir, { recursive: true });
|
||||
await fs.promises.writeFile(
|
||||
path.join(claudeDir, "settings.json"),
|
||||
JSON.stringify({
|
||||
permissions: {
|
||||
allow: ["Read"],
|
||||
deny: ["Read(./.env)"],
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
settingsManager = new SettingsManager(tempDir);
|
||||
await settingsManager.initialize();
|
||||
|
||||
// .env should be denied
|
||||
const envResult = settingsManager.checkPermission("mcp__acp__Read", {
|
||||
file_path: path.join(tempDir, ".env"),
|
||||
});
|
||||
expect(envResult.decision).toBe("deny");
|
||||
|
||||
// other files should be allowed
|
||||
const otherResult = settingsManager.checkPermission("mcp__acp__Read", {
|
||||
file_path: path.join(tempDir, "other.txt"),
|
||||
});
|
||||
expect(otherResult.decision).toBe("allow");
|
||||
});
|
||||
|
||||
it("should handle ACP-prefixed tool names", async () => {
|
||||
const claudeDir = path.join(tempDir, ".claude");
|
||||
await fs.promises.mkdir(claudeDir, { recursive: true });
|
||||
await fs.promises.writeFile(
|
||||
path.join(claudeDir, "settings.json"),
|
||||
JSON.stringify({
|
||||
permissions: {
|
||||
allow: ["Read"],
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
settingsManager = new SettingsManager(tempDir);
|
||||
await settingsManager.initialize();
|
||||
|
||||
const result = settingsManager.checkPermission("mcp__acp__Read", {
|
||||
file_path: "/some/file.txt",
|
||||
});
|
||||
expect(result.decision).toBe("allow");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Bash permission rules", () => {
|
||||
it("should match exact Bash commands without :* wildcard", async () => {
|
||||
const claudeDir = path.join(tempDir, ".claude");
|
||||
await fs.promises.mkdir(claudeDir, { recursive: true });
|
||||
await fs.promises.writeFile(
|
||||
path.join(claudeDir, "settings.json"),
|
||||
JSON.stringify({
|
||||
permissions: {
|
||||
// Per docs: Bash(npm run build) matches the EXACT command "npm run build"
|
||||
allow: ["Bash(npm run lint)"],
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
settingsManager = new SettingsManager(tempDir);
|
||||
await settingsManager.initialize();
|
||||
|
||||
// Exact match should be allowed
|
||||
const exactResult = settingsManager.checkPermission("mcp__acp__Bash", {
|
||||
command: "npm run lint",
|
||||
});
|
||||
expect(exactResult.decision).toBe("allow");
|
||||
|
||||
// Command with extra arguments should NOT match (exact match only)
|
||||
const withArgsResult = settingsManager.checkPermission("mcp__acp__Bash", {
|
||||
command: "npm run lint --fix",
|
||||
});
|
||||
expect(withArgsResult.decision).toBe("ask");
|
||||
|
||||
// Similar command should NOT match (exact match only)
|
||||
const similarResult = settingsManager.checkPermission("mcp__acp__Bash", {
|
||||
command: "npm run linting",
|
||||
});
|
||||
expect(similarResult.decision).toBe("ask");
|
||||
|
||||
// Different command should not match
|
||||
const differentResult = settingsManager.checkPermission("mcp__acp__Bash", {
|
||||
command: "npm run test",
|
||||
});
|
||||
expect(differentResult.decision).toBe("ask");
|
||||
});
|
||||
|
||||
it("should match Bash commands with :* wildcard suffix (prefix matching)", async () => {
|
||||
const claudeDir = path.join(tempDir, ".claude");
|
||||
await fs.promises.mkdir(claudeDir, { recursive: true });
|
||||
await fs.promises.writeFile(
|
||||
path.join(claudeDir, "settings.json"),
|
||||
JSON.stringify({
|
||||
permissions: {
|
||||
// The :* suffix is a convention to make prefix matching explicit
|
||||
allow: ["Bash(npm run:*)"],
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
settingsManager = new SettingsManager(tempDir);
|
||||
await settingsManager.initialize();
|
||||
|
||||
// Any command starting with "npm run" should match (prefix matching with :*)
|
||||
const lintResult = settingsManager.checkPermission("mcp__acp__Bash", {
|
||||
command: "npm run lint",
|
||||
});
|
||||
expect(lintResult.decision).toBe("allow");
|
||||
|
||||
const testResult = settingsManager.checkPermission("mcp__acp__Bash", {
|
||||
command: "npm run test",
|
||||
});
|
||||
expect(testResult.decision).toBe("allow");
|
||||
|
||||
// Commands with additional args also match
|
||||
const withArgsResult = settingsManager.checkPermission("mcp__acp__Bash", {
|
||||
command: "npm run test --watch",
|
||||
});
|
||||
expect(withArgsResult.decision).toBe("allow");
|
||||
|
||||
// Non-matching command
|
||||
const installResult = settingsManager.checkPermission("mcp__acp__Bash", {
|
||||
command: "npm install",
|
||||
});
|
||||
expect(installResult.decision).toBe("ask");
|
||||
});
|
||||
|
||||
it("should not allow shell operators to bypass prefix matching", async () => {
|
||||
const claudeDir = path.join(tempDir, ".claude");
|
||||
await fs.promises.mkdir(claudeDir, { recursive: true });
|
||||
await fs.promises.writeFile(
|
||||
path.join(claudeDir, "settings.json"),
|
||||
JSON.stringify({
|
||||
permissions: {
|
||||
allow: ["Bash(safe-cmd:*)"],
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
settingsManager = new SettingsManager(tempDir);
|
||||
await settingsManager.initialize();
|
||||
|
||||
// Normal prefix match should work
|
||||
const normalResult = settingsManager.checkPermission("mcp__acp__Bash", {
|
||||
command: "safe-cmd --flag",
|
||||
});
|
||||
expect(normalResult.decision).toBe("allow");
|
||||
|
||||
// Shell operators should NOT be allowed (per docs: Claude Code is aware of shell operators)
|
||||
const andResult = settingsManager.checkPermission("mcp__acp__Bash", {
|
||||
command: "safe-cmd && malicious-cmd",
|
||||
});
|
||||
expect(andResult.decision).toBe("ask");
|
||||
|
||||
const orResult = settingsManager.checkPermission("mcp__acp__Bash", {
|
||||
command: "safe-cmd || malicious-cmd",
|
||||
});
|
||||
expect(orResult.decision).toBe("ask");
|
||||
|
||||
const semicolonResult = settingsManager.checkPermission("mcp__acp__Bash", {
|
||||
command: "safe-cmd; malicious-cmd",
|
||||
});
|
||||
expect(semicolonResult.decision).toBe("ask");
|
||||
|
||||
const pipeResult = settingsManager.checkPermission("mcp__acp__Bash", {
|
||||
command: "safe-cmd | malicious-cmd",
|
||||
});
|
||||
expect(pipeResult.decision).toBe("ask");
|
||||
|
||||
const subshellResult = settingsManager.checkPermission("mcp__acp__Bash", {
|
||||
command: "safe-cmd $(malicious-cmd)",
|
||||
});
|
||||
expect(subshellResult.decision).toBe("ask");
|
||||
|
||||
const backtickResult = settingsManager.checkPermission("mcp__acp__Bash", {
|
||||
command: "safe-cmd `malicious-cmd`",
|
||||
});
|
||||
expect(backtickResult.decision).toBe("ask");
|
||||
|
||||
const newlineResult = settingsManager.checkPermission("mcp__acp__Bash", {
|
||||
command: "safe-cmd\nmalicious-cmd",
|
||||
});
|
||||
expect(newlineResult.decision).toBe("ask");
|
||||
});
|
||||
|
||||
it("should deny dangerous Bash commands", async () => {
|
||||
const claudeDir = path.join(tempDir, ".claude");
|
||||
await fs.promises.mkdir(claudeDir, { recursive: true });
|
||||
await fs.promises.writeFile(
|
||||
path.join(claudeDir, "settings.json"),
|
||||
JSON.stringify({
|
||||
permissions: {
|
||||
deny: ["Bash(curl:*)", "Bash(wget:*)"],
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
settingsManager = new SettingsManager(tempDir);
|
||||
await settingsManager.initialize();
|
||||
|
||||
const curlResult = settingsManager.checkPermission("mcp__acp__Bash", {
|
||||
command: "curl https://example.com",
|
||||
});
|
||||
expect(curlResult.decision).toBe("deny");
|
||||
|
||||
const wgetResult = settingsManager.checkPermission("mcp__acp__Bash", {
|
||||
command: "wget https://example.com",
|
||||
});
|
||||
expect(wgetResult.decision).toBe("deny");
|
||||
|
||||
const lsResult = settingsManager.checkPermission("mcp__acp__Bash", { command: "ls -la" });
|
||||
expect(lsResult.decision).toBe("ask");
|
||||
});
|
||||
});
|
||||
|
||||
describe("file path glob matching", () => {
|
||||
it("should match exact file paths", async () => {
|
||||
const claudeDir = path.join(tempDir, ".claude");
|
||||
await fs.promises.mkdir(claudeDir, { recursive: true });
|
||||
await fs.promises.writeFile(
|
||||
path.join(claudeDir, "settings.json"),
|
||||
JSON.stringify({
|
||||
permissions: {
|
||||
deny: ["Read(./.env)"],
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
settingsManager = new SettingsManager(tempDir);
|
||||
await settingsManager.initialize();
|
||||
|
||||
const envResult = settingsManager.checkPermission("mcp__acp__Read", {
|
||||
file_path: path.join(tempDir, ".env"),
|
||||
});
|
||||
expect(envResult.decision).toBe("deny");
|
||||
|
||||
const otherResult = settingsManager.checkPermission("mcp__acp__Read", {
|
||||
file_path: path.join(tempDir, ".envrc"),
|
||||
});
|
||||
expect(otherResult.decision).toBe("ask");
|
||||
});
|
||||
|
||||
it("should match glob patterns with single wildcard", async () => {
|
||||
const claudeDir = path.join(tempDir, ".claude");
|
||||
await fs.promises.mkdir(claudeDir, { recursive: true });
|
||||
await fs.promises.writeFile(
|
||||
path.join(claudeDir, "settings.json"),
|
||||
JSON.stringify({
|
||||
permissions: {
|
||||
deny: ["Read(./.env.*)"],
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
settingsManager = new SettingsManager(tempDir);
|
||||
await settingsManager.initialize();
|
||||
|
||||
const envLocalResult = settingsManager.checkPermission("mcp__acp__Read", {
|
||||
file_path: path.join(tempDir, ".env.local"),
|
||||
});
|
||||
expect(envLocalResult.decision).toBe("deny");
|
||||
|
||||
const envProdResult = settingsManager.checkPermission("mcp__acp__Read", {
|
||||
file_path: path.join(tempDir, ".env.production"),
|
||||
});
|
||||
expect(envProdResult.decision).toBe("deny");
|
||||
|
||||
// Plain .env should not match .env.*
|
||||
const plainEnvResult = settingsManager.checkPermission("mcp__acp__Read", {
|
||||
file_path: path.join(tempDir, ".env"),
|
||||
});
|
||||
expect(plainEnvResult.decision).toBe("ask");
|
||||
});
|
||||
|
||||
it("should match glob patterns with double wildcard (recursive)", async () => {
|
||||
const claudeDir = path.join(tempDir, ".claude");
|
||||
await fs.promises.mkdir(claudeDir, { recursive: true });
|
||||
await fs.promises.writeFile(
|
||||
path.join(claudeDir, "settings.json"),
|
||||
JSON.stringify({
|
||||
permissions: {
|
||||
deny: ["Read(./secrets/**)"],
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
settingsManager = new SettingsManager(tempDir);
|
||||
await settingsManager.initialize();
|
||||
|
||||
const secretResult = settingsManager.checkPermission("mcp__acp__Read", {
|
||||
file_path: path.join(tempDir, "secrets", "api-key.txt"),
|
||||
});
|
||||
expect(secretResult.decision).toBe("deny");
|
||||
|
||||
const nestedSecretResult = settingsManager.checkPermission("mcp__acp__Read", {
|
||||
file_path: path.join(tempDir, "secrets", "deep", "nested", "key.txt"),
|
||||
});
|
||||
expect(nestedSecretResult.decision).toBe("deny");
|
||||
|
||||
const otherResult = settingsManager.checkPermission("mcp__acp__Read", {
|
||||
file_path: path.join(tempDir, "public", "file.txt"),
|
||||
});
|
||||
expect(otherResult.decision).toBe("ask");
|
||||
});
|
||||
|
||||
it("should handle home directory expansion", async () => {
|
||||
const claudeDir = path.join(tempDir, ".claude");
|
||||
await fs.promises.mkdir(claudeDir, { recursive: true });
|
||||
await fs.promises.writeFile(
|
||||
path.join(claudeDir, "settings.json"),
|
||||
JSON.stringify({
|
||||
permissions: {
|
||||
allow: ["Read(~/.zshrc)"],
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
settingsManager = new SettingsManager(tempDir);
|
||||
await settingsManager.initialize();
|
||||
|
||||
const zshrcResult = settingsManager.checkPermission("mcp__acp__Read", {
|
||||
file_path: path.join(os.homedir(), ".zshrc"),
|
||||
});
|
||||
expect(zshrcResult.decision).toBe("allow");
|
||||
});
|
||||
});
|
||||
|
||||
describe("settings merging", () => {
|
||||
it("should merge project and local settings", async () => {
|
||||
const claudeDir = path.join(tempDir, ".claude");
|
||||
await fs.promises.mkdir(claudeDir, { recursive: true });
|
||||
|
||||
// Project settings
|
||||
await fs.promises.writeFile(
|
||||
path.join(claudeDir, "settings.json"),
|
||||
JSON.stringify({
|
||||
permissions: {
|
||||
allow: ["Read"],
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
// Local settings
|
||||
await fs.promises.writeFile(
|
||||
path.join(claudeDir, "settings.local.json"),
|
||||
JSON.stringify({
|
||||
permissions: {
|
||||
deny: ["Read(./.env)"],
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
settingsManager = new SettingsManager(tempDir);
|
||||
await settingsManager.initialize();
|
||||
|
||||
// Read should be allowed in general
|
||||
const readResult = settingsManager.checkPermission("mcp__acp__Read", {
|
||||
file_path: path.join(tempDir, "file.txt"),
|
||||
});
|
||||
expect(readResult.decision).toBe("allow");
|
||||
|
||||
// But .env should be denied (local settings take precedence)
|
||||
const envResult = settingsManager.checkPermission("mcp__acp__Read", {
|
||||
file_path: path.join(tempDir, ".env"),
|
||||
});
|
||||
expect(envResult.decision).toBe("deny");
|
||||
});
|
||||
});
|
||||
|
||||
describe("ask rules", () => {
|
||||
it("should return 'ask' for matching ask rules", async () => {
|
||||
const claudeDir = path.join(tempDir, ".claude");
|
||||
await fs.promises.mkdir(claudeDir, { recursive: true });
|
||||
await fs.promises.writeFile(
|
||||
path.join(claudeDir, "settings.json"),
|
||||
JSON.stringify({
|
||||
permissions: {
|
||||
ask: ["Bash(git push:*)"],
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
settingsManager = new SettingsManager(tempDir);
|
||||
await settingsManager.initialize();
|
||||
|
||||
const result = settingsManager.checkPermission("mcp__acp__Bash", {
|
||||
command: "git push origin main",
|
||||
});
|
||||
expect(result.decision).toBe("ask");
|
||||
expect(result.rule).toBe("Bash(git push:*)");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Edit and Write tools", () => {
|
||||
it("should handle Edit tool permissions", async () => {
|
||||
const claudeDir = path.join(tempDir, ".claude");
|
||||
await fs.promises.mkdir(claudeDir, { recursive: true });
|
||||
await fs.promises.writeFile(
|
||||
path.join(claudeDir, "settings.json"),
|
||||
JSON.stringify({
|
||||
permissions: {
|
||||
deny: ["Edit(./package-lock.json)"],
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
settingsManager = new SettingsManager(tempDir);
|
||||
await settingsManager.initialize();
|
||||
|
||||
const lockFileResult = settingsManager.checkPermission("mcp__acp__Edit", {
|
||||
file_path: path.join(tempDir, "package-lock.json"),
|
||||
});
|
||||
expect(lockFileResult.decision).toBe("deny");
|
||||
|
||||
const otherResult = settingsManager.checkPermission("mcp__acp__Edit", {
|
||||
file_path: path.join(tempDir, "package.json"),
|
||||
});
|
||||
expect(otherResult.decision).toBe("ask");
|
||||
});
|
||||
|
||||
it("should handle mcp__acp__Edit and mcp__acp__Write tool names", async () => {
|
||||
const claudeDir = path.join(tempDir, ".claude");
|
||||
await fs.promises.mkdir(claudeDir, { recursive: true });
|
||||
await fs.promises.writeFile(
|
||||
path.join(claudeDir, "settings.json"),
|
||||
JSON.stringify({
|
||||
permissions: {
|
||||
allow: ["Edit", "Write"],
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
settingsManager = new SettingsManager(tempDir);
|
||||
await settingsManager.initialize();
|
||||
|
||||
const editResult = settingsManager.checkPermission("mcp__acp__Edit", {
|
||||
file_path: "/some/file.ts",
|
||||
});
|
||||
expect(editResult.decision).toBe("allow");
|
||||
|
||||
const writeResult = settingsManager.checkPermission("mcp__acp__Write", {
|
||||
file_path: "/some/file.ts",
|
||||
});
|
||||
expect(writeResult.decision).toBe("allow");
|
||||
});
|
||||
});
|
||||
|
||||
describe("getSettings", () => {
|
||||
it("should return merged settings", async () => {
|
||||
const claudeDir = path.join(tempDir, ".claude");
|
||||
await fs.promises.mkdir(claudeDir, { recursive: true });
|
||||
await fs.promises.writeFile(
|
||||
path.join(claudeDir, "settings.json"),
|
||||
JSON.stringify({
|
||||
permissions: {
|
||||
allow: ["Read", "Bash(npm:*)"],
|
||||
deny: ["Read(./.env)"],
|
||||
},
|
||||
env: {
|
||||
FOO: "bar",
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
settingsManager = new SettingsManager(tempDir);
|
||||
await settingsManager.initialize();
|
||||
|
||||
const settings = settingsManager.getSettings();
|
||||
expect(settings.permissions?.allow).toContain("Read");
|
||||
expect(settings.permissions?.allow).toContain("Bash(npm:*)");
|
||||
expect(settings.permissions?.deny).toContain("Read(./.env)");
|
||||
expect(settings.env?.FOO).toBe("bar");
|
||||
});
|
||||
});
|
||||
|
||||
describe("setCwd", () => {
|
||||
it("should reload settings when cwd changes", async () => {
|
||||
const claudeDir1 = path.join(tempDir, ".claude");
|
||||
await fs.promises.mkdir(claudeDir1, { recursive: true });
|
||||
await fs.promises.writeFile(
|
||||
path.join(claudeDir1, "settings.json"),
|
||||
JSON.stringify({
|
||||
permissions: {
|
||||
allow: ["Read"],
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
settingsManager = new SettingsManager(tempDir);
|
||||
await settingsManager.initialize();
|
||||
|
||||
let result = settingsManager.checkPermission("mcp__acp__Read", { file_path: "/file.txt" });
|
||||
expect(result.decision).toBe("allow");
|
||||
|
||||
// Create a new temp directory with different settings
|
||||
const tempDir2 = await fs.promises.mkdtemp(path.join(os.tmpdir(), "settings-test-2-"));
|
||||
const claudeDir2 = path.join(tempDir2, ".claude");
|
||||
await fs.promises.mkdir(claudeDir2, { recursive: true });
|
||||
await fs.promises.writeFile(
|
||||
path.join(claudeDir2, "settings.json"),
|
||||
JSON.stringify({
|
||||
permissions: {
|
||||
deny: ["Read"],
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
await settingsManager.setCwd(tempDir2);
|
||||
|
||||
result = settingsManager.checkPermission("mcp__acp__Read", { file_path: "/file.txt" });
|
||||
expect(result.decision).toBe("deny");
|
||||
|
||||
// Cleanup second temp dir
|
||||
await fs.promises.rm(tempDir2, { recursive: true, force: true });
|
||||
});
|
||||
});
|
||||
});
|
||||
246
src/tools.ts
246
src/tools.ts
@@ -1,15 +1,41 @@
|
||||
import { PlanEntry, ToolCallContent, ToolCallLocation, ToolKind } from "@agentclientprotocol/sdk";
|
||||
import { replaceAndCalculateLocation, SYSTEM_REMINDER, toolNames } from "./mcp-server.js";
|
||||
import { SYSTEM_REMINDER } from "./mcp-server.js";
|
||||
import * as diff from "diff";
|
||||
import { ToolResultBlockParam, WebSearchToolResultBlockParam } from "@anthropic-ai/sdk/resources";
|
||||
|
||||
const acpUnqualifiedToolNames = {
|
||||
read: "Read",
|
||||
edit: "Edit",
|
||||
write: "Write",
|
||||
bash: "Bash",
|
||||
killShell: "KillShell",
|
||||
bashOutput: "BashOutput",
|
||||
};
|
||||
|
||||
export const ACP_TOOL_NAME_PREFIX = "mcp__acp__";
|
||||
export const acpToolNames = {
|
||||
read: ACP_TOOL_NAME_PREFIX + acpUnqualifiedToolNames.read,
|
||||
edit: ACP_TOOL_NAME_PREFIX + acpUnqualifiedToolNames.edit,
|
||||
write: ACP_TOOL_NAME_PREFIX + acpUnqualifiedToolNames.write,
|
||||
bash: ACP_TOOL_NAME_PREFIX + acpUnqualifiedToolNames.bash,
|
||||
killShell: ACP_TOOL_NAME_PREFIX + acpUnqualifiedToolNames.killShell,
|
||||
bashOutput: ACP_TOOL_NAME_PREFIX + acpUnqualifiedToolNames.bashOutput,
|
||||
};
|
||||
|
||||
export const EDIT_TOOL_NAMES = [acpToolNames.edit, acpToolNames.write];
|
||||
|
||||
import {
|
||||
BetaBashCodeExecutionToolResultBlockParam,
|
||||
BetaCodeExecutionToolResultBlockParam,
|
||||
BetaRequestMCPToolResultBlockParam,
|
||||
BetaTextEditorCodeExecutionToolResultBlockParam,
|
||||
BetaToolSearchToolResultBlockParam,
|
||||
BetaWebFetchToolResultBlockParam,
|
||||
BetaWebSearchToolResultBlockParam,
|
||||
} from "@anthropic-ai/sdk/resources/beta.mjs";
|
||||
import { HookCallback } from "@anthropic-ai/claude-agent-sdk";
|
||||
import { Logger } from "./acp-agent.js";
|
||||
import { SettingsManager } from "./settings.js";
|
||||
|
||||
interface ToolInfo {
|
||||
title: string;
|
||||
@@ -24,10 +50,7 @@ interface ToolUpdate {
|
||||
locations?: ToolCallLocation[];
|
||||
}
|
||||
|
||||
export function toolInfoFromToolUse(
|
||||
toolUse: any,
|
||||
cachedFileContent: { [key: string]: string },
|
||||
): ToolInfo {
|
||||
export function toolInfoFromToolUse(toolUse: any): ToolInfo {
|
||||
const name = toolUse.name;
|
||||
const input = toolUse.input;
|
||||
|
||||
@@ -72,7 +95,7 @@ export function toolInfoFromToolUse(
|
||||
};
|
||||
|
||||
case "Bash":
|
||||
case toolNames.bash:
|
||||
case acpToolNames.bash:
|
||||
return {
|
||||
title: input?.command ? "`" + input.command.replaceAll("`", "\\`") + "`" : "Terminal",
|
||||
kind: "execute",
|
||||
@@ -88,7 +111,7 @@ export function toolInfoFromToolUse(
|
||||
};
|
||||
|
||||
case "BashOutput":
|
||||
case toolNames.bashOutput:
|
||||
case acpToolNames.bashOutput:
|
||||
return {
|
||||
title: "Tail Logs",
|
||||
kind: "execute",
|
||||
@@ -96,14 +119,14 @@ export function toolInfoFromToolUse(
|
||||
};
|
||||
|
||||
case "KillShell":
|
||||
case toolNames.killShell:
|
||||
case acpToolNames.killShell:
|
||||
return {
|
||||
title: "Kill Process",
|
||||
kind: "execute",
|
||||
content: [],
|
||||
};
|
||||
|
||||
case toolNames.read: {
|
||||
case acpToolNames.read: {
|
||||
let limit = "";
|
||||
if (input.limit) {
|
||||
limit =
|
||||
@@ -131,7 +154,14 @@ export function toolInfoFromToolUse(
|
||||
title: "Read File",
|
||||
kind: "read",
|
||||
content: [],
|
||||
locations: [{ path: input.file_path, line: input.offset ?? 0 }],
|
||||
locations: input.file_path
|
||||
? [
|
||||
{
|
||||
path: input.file_path,
|
||||
line: input.offset ?? 0,
|
||||
},
|
||||
]
|
||||
: [],
|
||||
};
|
||||
|
||||
case "LS":
|
||||
@@ -142,30 +172,10 @@ export function toolInfoFromToolUse(
|
||||
locations: [],
|
||||
};
|
||||
|
||||
case toolNames.edit:
|
||||
case acpToolNames.edit:
|
||||
case "Edit": {
|
||||
const path = input?.file_path ?? input?.file_path;
|
||||
let oldText = input.old_string ?? null;
|
||||
let newText = input.new_string ?? "";
|
||||
let affectedLines: number[] = [];
|
||||
|
||||
if (path && oldText) {
|
||||
try {
|
||||
const oldContent = cachedFileContent[path] || "";
|
||||
const newContent = replaceAndCalculateLocation(oldContent, [
|
||||
{
|
||||
oldText,
|
||||
newText,
|
||||
replaceAll: false,
|
||||
},
|
||||
]);
|
||||
oldText = oldContent;
|
||||
newText = newContent.newContent;
|
||||
affectedLines = newContent.lineNumbers;
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
}
|
||||
}
|
||||
return {
|
||||
title: path ? `Edit \`${path}\`` : "Edit",
|
||||
kind: "edit",
|
||||
@@ -175,20 +185,16 @@ export function toolInfoFromToolUse(
|
||||
{
|
||||
type: "diff",
|
||||
path,
|
||||
oldText,
|
||||
newText,
|
||||
oldText: input.old_string ?? null,
|
||||
newText: input.new_string ?? "",
|
||||
},
|
||||
]
|
||||
: [],
|
||||
locations: path
|
||||
? affectedLines.length > 0
|
||||
? affectedLines.map((line) => ({ line, path }))
|
||||
: [{ path }]
|
||||
: [],
|
||||
locations: path ? [{ path }] : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
case toolNames.write: {
|
||||
case acpToolNames.write: {
|
||||
let content: ToolCallContent[] = [];
|
||||
if (input && input.file_path) {
|
||||
content = [
|
||||
@@ -299,7 +305,9 @@ export function toolInfoFromToolUse(
|
||||
label += " -P";
|
||||
}
|
||||
|
||||
label += ` "${input.pattern}"`;
|
||||
if (input.pattern) {
|
||||
label += ` "${input.pattern}"`;
|
||||
}
|
||||
|
||||
if (input.path) {
|
||||
label += ` ${input.path}`;
|
||||
@@ -404,12 +412,23 @@ export function toolUpdateFromToolResult(
|
||||
| BetaCodeExecutionToolResultBlockParam
|
||||
| BetaBashCodeExecutionToolResultBlockParam
|
||||
| BetaTextEditorCodeExecutionToolResultBlockParam
|
||||
| BetaRequestMCPToolResultBlockParam,
|
||||
| BetaRequestMCPToolResultBlockParam
|
||||
| BetaToolSearchToolResultBlockParam,
|
||||
toolUse: any | undefined,
|
||||
): ToolUpdate {
|
||||
if (
|
||||
"is_error" in toolResult &&
|
||||
toolResult.is_error &&
|
||||
toolResult.content &&
|
||||
toolResult.content.length > 0
|
||||
) {
|
||||
// Only return errors
|
||||
return toAcpContentUpdate(toolResult.content, true);
|
||||
}
|
||||
|
||||
switch (toolUse?.name) {
|
||||
case "Read":
|
||||
case toolNames.read:
|
||||
case acpToolNames.read:
|
||||
if (Array.isArray(toolResult.content) && toolResult.content.length > 0) {
|
||||
return {
|
||||
content: toolResult.content.map((content: any) => ({
|
||||
@@ -438,21 +457,60 @@ export function toolUpdateFromToolResult(
|
||||
}
|
||||
return {};
|
||||
|
||||
case toolNames.bash:
|
||||
case acpToolNames.edit: {
|
||||
const content: ToolCallContent[] = [];
|
||||
const locations: ToolCallLocation[] = [];
|
||||
|
||||
if (
|
||||
Array.isArray(toolResult.content) &&
|
||||
toolResult.content.length > 0 &&
|
||||
"text" in toolResult.content[0] &&
|
||||
typeof toolResult.content[0].text === "string"
|
||||
) {
|
||||
const patches = diff.parsePatch(toolResult.content[0].text);
|
||||
console.error(JSON.stringify(patches));
|
||||
for (const { oldFileName, newFileName, hunks } of patches) {
|
||||
for (const { lines, newStart } of hunks) {
|
||||
const oldText = [];
|
||||
const newText = [];
|
||||
for (const line of lines) {
|
||||
if (line.startsWith("-")) {
|
||||
oldText.push(line.slice(1));
|
||||
} else if (line.startsWith("+")) {
|
||||
newText.push(line.slice(1));
|
||||
} else {
|
||||
oldText.push(line.slice(1));
|
||||
newText.push(line.slice(1));
|
||||
}
|
||||
}
|
||||
if (oldText.length > 0 || newText.length > 0) {
|
||||
locations.push({ path: newFileName || oldFileName, line: newStart });
|
||||
content.push({
|
||||
type: "diff",
|
||||
path: newFileName || oldFileName,
|
||||
oldText: oldText.join("\n") || null,
|
||||
newText: newText.join("\n"),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const result: ToolUpdate = {};
|
||||
if (content.length > 0) {
|
||||
result.content = content;
|
||||
}
|
||||
if (locations.length > 0) {
|
||||
result.locations = locations;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
case acpToolNames.bash:
|
||||
case "edit":
|
||||
case "Edit":
|
||||
case toolNames.edit:
|
||||
case toolNames.write:
|
||||
case acpToolNames.write:
|
||||
case "Write": {
|
||||
if (
|
||||
"is_error" in toolResult &&
|
||||
toolResult.is_error &&
|
||||
toolResult.content &&
|
||||
toolResult.content.length > 0
|
||||
) {
|
||||
// Only return errors
|
||||
return toAcpContentUpdate(toolResult.content, true);
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
@@ -570,19 +628,69 @@ export const registerHookCallback = (
|
||||
};
|
||||
|
||||
/* A callback for Claude Code that is called when receiving a PostToolUse hook */
|
||||
export const postToolUseHook: HookCallback = async (
|
||||
input: any,
|
||||
toolUseID: string | undefined,
|
||||
): Promise<{ continue: boolean }> => {
|
||||
if (input.hook_event_name === "PostToolUse" && toolUseID) {
|
||||
const onPostToolUseHook = toolUseCallbacks[toolUseID]?.onPostToolUseHook;
|
||||
if (onPostToolUseHook) {
|
||||
await onPostToolUseHook(toolUseID, input.tool_input, input.tool_response);
|
||||
delete toolUseCallbacks[toolUseID]; // Cleanup after execution
|
||||
} else {
|
||||
console.error(`No onPostToolUseHook found for tool use ID: ${toolUseID}`);
|
||||
delete toolUseCallbacks[toolUseID];
|
||||
export const createPostToolUseHook =
|
||||
(logger: Logger = console): HookCallback =>
|
||||
async (input: any, toolUseID: string | undefined): Promise<{ continue: boolean }> => {
|
||||
if (input.hook_event_name === "PostToolUse" && toolUseID) {
|
||||
const onPostToolUseHook = toolUseCallbacks[toolUseID]?.onPostToolUseHook;
|
||||
if (onPostToolUseHook) {
|
||||
await onPostToolUseHook(toolUseID, input.tool_input, input.tool_response);
|
||||
delete toolUseCallbacks[toolUseID]; // Cleanup after execution
|
||||
} else {
|
||||
logger.error(`No onPostToolUseHook found for tool use ID: ${toolUseID}`);
|
||||
delete toolUseCallbacks[toolUseID];
|
||||
}
|
||||
}
|
||||
}
|
||||
return { continue: true };
|
||||
};
|
||||
return { continue: true };
|
||||
};
|
||||
|
||||
/**
|
||||
* Creates a PreToolUse hook that checks permissions using the SettingsManager.
|
||||
* This runs before the SDK's built-in permission rules, allowing us to enforce
|
||||
* our own permission settings for ACP-prefixed tools.
|
||||
*/
|
||||
export const createPreToolUseHook =
|
||||
(settingsManager: SettingsManager, logger: Logger = console): HookCallback =>
|
||||
async (input: any, _toolUseID: string | undefined) => {
|
||||
if (input.hook_event_name !== "PreToolUse") {
|
||||
return { continue: true };
|
||||
}
|
||||
|
||||
const toolName = input.tool_name;
|
||||
const toolInput = input.tool_input;
|
||||
|
||||
const permissionCheck = settingsManager.checkPermission(toolName, toolInput);
|
||||
|
||||
if (permissionCheck.decision !== "ask") {
|
||||
logger.log(
|
||||
`[PreToolUseHook] Tool: ${toolName}, Decision: ${permissionCheck.decision}, Rule: ${permissionCheck.rule}`,
|
||||
);
|
||||
}
|
||||
|
||||
switch (permissionCheck.decision) {
|
||||
case "allow":
|
||||
return {
|
||||
continue: true,
|
||||
hookSpecificOutput: {
|
||||
hookEventName: "PreToolUse" as const,
|
||||
permissionDecision: "allow" as const,
|
||||
permissionDecisionReason: `Allowed by settings rule: ${permissionCheck.rule}`,
|
||||
},
|
||||
};
|
||||
|
||||
case "deny":
|
||||
return {
|
||||
continue: true,
|
||||
hookSpecificOutput: {
|
||||
hookEventName: "PreToolUse" as const,
|
||||
permissionDecision: "deny" as const,
|
||||
permissionDecisionReason: `Denied by settings rule: ${permissionCheck.rule}`,
|
||||
},
|
||||
};
|
||||
|
||||
case "ask":
|
||||
default:
|
||||
// Let the normal permission flow continue
|
||||
return { continue: true };
|
||||
}
|
||||
};
|
||||
|
||||
37
src/utils.ts
37
src/utils.ts
@@ -3,7 +3,8 @@
|
||||
import { Readable, Writable } from "node:stream";
|
||||
import { WritableStream, ReadableStream } from "node:stream/web";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { platform } from "node:os";
|
||||
import { Logger } from "./acp-agent.js";
|
||||
import { ClaudeCodeSettings, getManagedSettingsPath } from "./settings.js";
|
||||
|
||||
// Useful for bridging push-based and async-iterator-based code.
|
||||
export class Pushable<T> implements AsyncIterable<T> {
|
||||
@@ -75,53 +76,29 @@ export function nodeToWebReadable(nodeStream: Readable): ReadableStream<Uint8Arr
|
||||
});
|
||||
}
|
||||
|
||||
export function unreachable(value: never): never {
|
||||
export function unreachable(value: never, logger: Logger = console) {
|
||||
let valueAsString;
|
||||
try {
|
||||
valueAsString = JSON.stringify(value);
|
||||
} catch {
|
||||
valueAsString = value;
|
||||
}
|
||||
throw new Error(`Unexpected case: ${valueAsString}`);
|
||||
logger.error(`Unexpected case: ${valueAsString}`);
|
||||
}
|
||||
|
||||
export function sleep(time: number): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, time));
|
||||
}
|
||||
|
||||
interface ManagedSettings {
|
||||
permissions?: {
|
||||
allow?: string[];
|
||||
deny?: string[];
|
||||
};
|
||||
env?: Record<string, string>;
|
||||
}
|
||||
|
||||
// Following the rules in https://docs.anthropic.com/en/docs/claude-code/settings#settings-files
|
||||
// This can be removed once the SDK supports it natively.
|
||||
function getManagedSettingsPath(): string {
|
||||
const os = platform();
|
||||
switch (os) {
|
||||
case "darwin":
|
||||
return "/Library/Application Support/ClaudeCode/managed-settings.json";
|
||||
case "linux": // including WSL
|
||||
return "/etc/claude-code/managed-settings.json";
|
||||
case "win32":
|
||||
return "C:\\ProgramData\\ClaudeCode\\managed-settings.json";
|
||||
default:
|
||||
return "/etc/claude-code/managed-settings.json";
|
||||
}
|
||||
}
|
||||
|
||||
export function loadManagedSettings(): ManagedSettings | null {
|
||||
export function loadManagedSettings(): ClaudeCodeSettings | null {
|
||||
try {
|
||||
return JSON.parse(readFileSync(getManagedSettingsPath(), "utf8")) as ManagedSettings;
|
||||
return JSON.parse(readFileSync(getManagedSettingsPath(), "utf8")) as ClaudeCodeSettings;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function applyEnvironmentSettings(settings: ManagedSettings): void {
|
||||
export function applyEnvironmentSettings(settings: ClaudeCodeSettings): void {
|
||||
if (settings.env) {
|
||||
for (const [key, value] of Object.entries(settings.env)) {
|
||||
process.env[key] = value;
|
||||
|
||||
Reference in New Issue
Block a user