From 3bd9abff4ac331a56ab436eb32e7097889a6c7b5 Mon Sep 17 00:00:00 2001 From: atsushi-ishibashi Date: Wed, 2 Sep 2026 11:47:17 +0900 Subject: [PATCH] feat(claude): disallow built-in tools per session Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01CxUv9z3CoRfN6FvWTdnzeC --- docs/content/docs/agents/claude.mdx | 8 ++ examples/claude/client.ts | 20 +++- software/claude/src/adapter.ts | 28 ++++++ software/claude/src/disallowed-tools.ts | 103 +++++++++++++++++++ software/claude/tests/adapter.test.mjs | 125 ++++++++++++++++++++++++ 5 files changed, 283 insertions(+), 1 deletion(-) create mode 100644 software/claude/src/disallowed-tools.ts diff --git a/docs/content/docs/agents/claude.mdx b/docs/content/docs/agents/claude.mdx index 4486ab7e17..68dd7a428f 100644 --- a/docs/content/docs/agents/claude.mdx +++ b/docs/content/docs/agents/claude.mdx @@ -43,6 +43,14 @@ Expose extra tools to the agent by passing `mcpServers` to `openSession`. Both l **Pre-install `npx`-launched servers.** A local server started with `npx -y …` writes install progress to **stdout** on its first run, which corrupts the MCP stdio handshake (you'll see `Connection closed`). Pre-install it in the VM so `npx` is silent — `await agent.process.exec("npm install -g @modelcontextprotocol/server-filesystem")` before the session — or pin the package and point `command` at the installed binary. +## Disallowing built-in tools + +Set `ACP_DISALLOWED_TOOLS` on the session's `env` to keep named built-in tools out of the model's context. Without it, a tool that cannot work in your deployment stays visible to the model, which keeps calling it and keeps failing. + + + +The value is a comma-separated list of tool names (`WebFetch,WebSearch`), or a JSON array of strings when a name itself contains a comma (`["Bash(git commit:*)"]`). Unset keeps every tool enabled; a malformed value fails the session rather than silently leaving the tools on. + ## Customizing the agent Claude Code is a built-in agent, but it's just a software package under the hood. To ship your own ACP adapter, swap the underlying agent SDK, or register a tweaked build as a new agent, see [Custom Agents](/agentos/docs/agents/custom). diff --git a/examples/claude/client.ts b/examples/claude/client.ts index 76c0973080..d73312ce6b 100644 --- a/examples/claude/client.ts +++ b/examples/claude/client.ts @@ -92,6 +92,24 @@ async function withMcp() { // docs:end mcp } +// ── Disallowing built-in tools ──────────────────────────────────── +// +// Some built-in Claude Code tools cannot work in every deployment. `WebFetch` +// is useless behind a network policy that blocks egress, for example. Naming +// them keeps the tools out of the model's context instead of letting it retry +// calls that always fail. +async function withoutWebTools() { + // docs:start disallowed-tools + await agent.sessions.open({ + agent: "claude", + env: { + ANTHROPIC_API_KEY: process.env.ANTHROPIC_API_KEY!, + ACP_DISALLOWED_TOOLS: "WebFetch,WebSearch", + }, + }); + // docs:end disallowed-tools +} + // ── Skills + MCP together ───────────────────────────────────────── async function withSkillAndMcp() { const skill = `--- @@ -146,4 +164,4 @@ Write commit messages in the imperative mood and keep the subject under 50 chara console.log(result.message?.content ?? []); } -export { quickStart, withSkill, withMcp, withSkillAndMcp }; +export { quickStart, withSkill, withMcp, withoutWebTools, withSkillAndMcp }; diff --git a/software/claude/src/adapter.ts b/software/claude/src/adapter.ts index 68d8abb435..d34b97d8d3 100644 --- a/software/claude/src/adapter.ts +++ b/software/claude/src/adapter.ts @@ -6,6 +6,9 @@ * The upstream adapter delegates to an SDK that supports effort controls, but * that adapter version does not publish them through ACP configOptions. Keep * the extension here until AgentOS can consume a newer VM-compatible release. + * + * The launcher also translates the adapter-neutral `ACP_DISALLOWED_TOOLS` launch + * contract into the SDK's `disallowedTools` option. */ import { @@ -16,6 +19,11 @@ import { applyEnvironmentSettings, loadManagedSettings, } from "@agentclientprotocol/claude-agent-acp/dist/utils.js"; +import { + DISALLOWED_TOOLS_ENV, + parseDisallowedTools, + withDisallowedTools, +} from "./disallowed-tools.js"; type EffortLevel = "low" | "medium" | "high" | "xhigh" | "max"; type ModelInfo = { @@ -144,12 +152,32 @@ type AgentPrototype = { setSessionConfigOption( params: Record, ): Promise>; + createSession( + params: Record, + creationOpts?: Record, + ): Promise>; }; const prototype = ClaudeAcpAgent.prototype as unknown as AgentPrototype; const upstreamNewSession = prototype.newSession; const upstreamResumeSession = prototype.unstable_resumeSession; const upstreamSetConfigOption = prototype.setSessionConfigOption; +const upstreamCreateSession = prototype.createSession; + +// Parse the launch contract once, before the ACP server starts, so a malformed +// policy fails the adapter with a named error on stderr instead of silently +// leaving the tools enabled for every session on this process. +const disallowedTools = parseDisallowedTools(process.env[DISALLOWED_TOOLS_ENV]); + +// Every entry point (new, resume, load, fork) funnels through createSession, so +// patching it once applies the policy to restored sessions too. +prototype.createSession = async function (params, creationOpts) { + return await upstreamCreateSession.call( + this, + withDisallowedTools(params, disallowedTools), + creationOpts, + ); +}; prototype.newSession = async function (params) { const response = await upstreamNewSession.call(this, params); diff --git a/software/claude/src/disallowed-tools.ts b/software/claude/src/disallowed-tools.ts new file mode 100644 index 0000000000..2940ae15ce --- /dev/null +++ b/software/claude/src/disallowed-tools.ts @@ -0,0 +1,103 @@ +/** + * Adapter-neutral launch contract for disallowing built-in agent tools. + * + * The sidecar owns no agent-specific tool policy: it forwards the caller's + * session `env` to the packaged adapter, and this AgentOS-owned launcher + * translates the neutral `ACP_DISALLOWED_TOOLS` value into the Claude Agent + * SDK's `disallowedTools` option. Mirrors how `ACP_APPEND_SYSTEM_PROMPT` is + * translated into each upstream adapter's own prompt flag. + */ + +export const DISALLOWED_TOOLS_ENV = "ACP_DISALLOWED_TOOLS"; + +function invalid(detail: string): Error { + return new Error( + `Invalid ${DISALLOWED_TOOLS_ENV}: ${detail}. Provide a comma-separated list of tool names (\`WebFetch,WebSearch\`) or a JSON array of strings (\`["WebFetch","WebSearch"]\`).`, + ); +} + +function normalize(names: string[]): string[] { + const seen = new Set(); + for (const name of names) { + seen.add(name); + } + return [...seen]; +} + +/** + * Parse the launch contract value into an ordered, de-duplicated tool list. + * + * An unset or blank value keeps the adapter's default behavior. A value that is + * present but unusable is a hard error rather than a silent empty list, so a + * mistyped policy fails the session instead of quietly leaving the tool enabled. + */ +export function parseDisallowedTools(raw: string | undefined): string[] { + const value = raw?.trim(); + if (!value) return []; + if (value.startsWith("{")) { + throw invalid("JSON object values are not supported"); + } + if (value.startsWith("[")) { + let parsed: unknown; + try { + parsed = JSON.parse(value); + } catch (error) { + throw invalid(`${(error as Error).message}`); + } + if (!Array.isArray(parsed)) throw invalid("JSON value is not an array"); + const names = parsed.map((entry) => { + if (typeof entry !== "string") { + throw invalid(`JSON array entry is not a string: ${JSON.stringify(entry)}`); + } + const name = entry.trim(); + if (!name) throw invalid("JSON array contains an empty tool name"); + return name; + }); + return normalize(names); + } + const names = value + .split(",") + .map((entry) => entry.trim()) + .filter((entry) => entry.length > 0); + if (names.length === 0) throw invalid("no tool names were found"); + return normalize(names); +} + +function asRecord(value: unknown): Record { + return value && typeof value === "object" && !Array.isArray(value) + ? (value as Record) + : {}; +} + +/** + * Merge the launch-contract tools into the session's `_meta.claudeCode.options` + * without mutating the caller's params. Caller-supplied entries are preserved; + * the upstream adapter appends its own always-disallowed tools afterwards. + */ +export function withDisallowedTools>( + params: T, + disallowedTools: string[], +): T { + if (disallowedTools.length === 0) return params; + const meta = asRecord(params._meta); + const claudeCode = asRecord(meta.claudeCode); + const options = asRecord(claudeCode.options); + const existing = Array.isArray(options.disallowedTools) + ? options.disallowedTools.filter( + (entry): entry is string => typeof entry === "string", + ) + : []; + return { + ...params, + _meta: { + ...meta, + claudeCode: { + ...claudeCode, + options: { + ...options, + disallowedTools: normalize([...existing, ...disallowedTools]), + }, + }, + }, + }; +} diff --git a/software/claude/tests/adapter.test.mjs b/software/claude/tests/adapter.test.mjs index 5e8b10c6a8..c6dea26dd8 100644 --- a/software/claude/tests/adapter.test.mjs +++ b/software/claude/tests/adapter.test.mjs @@ -10,6 +10,11 @@ import { ndJsonStream, } from "@agentclientprotocol/sdk"; import { LLMock } from "@copilotkit/llmock"; +import { + DISALLOWED_TOOLS_ENV, + parseDisallowedTools, + withDisallowedTools, +} from "../dist/disallowed-tools.js"; const packageDir = resolvePath(import.meta.dirname, ".."); const adapterPath = resolvePath(packageDir, "dist", "adapter.js"); @@ -239,3 +244,123 @@ test("published Claude Agent ACP prompts a second process while the first remain await mock.stop(); } }); + +test("parses the disallowed-tools launch contract in both accepted forms", () => { + assert.deepEqual(parseDisallowedTools(undefined), []); + assert.deepEqual(parseDisallowedTools(" "), []); + assert.deepEqual(parseDisallowedTools("WebFetch, WebSearch ,WebFetch"), [ + "WebFetch", + "WebSearch", + ]); + assert.deepEqual(parseDisallowedTools('["WebFetch", "Bash(git commit:*)"]'), [ + "WebFetch", + "Bash(git commit:*)", + ]); +}); + +test("rejects a malformed disallowed-tools value instead of ignoring it", () => { + for (const value of [",,,", "[", '["WebFetch", 3]', '[" "]', '{"a":1}']) { + assert.throws( + () => parseDisallowedTools(value), + (error) => error.message.includes(DISALLOWED_TOOLS_ENV), + `expected ${value} to fail with a named error`, + ); + } +}); + +test("merges the launch contract into caller-supplied session meta", () => { + const params = { + cwd: "/home/agentos", + _meta: { claudeCode: { options: { disallowedTools: ["Bash"], model: "x" } } }, + }; + const merged = withDisallowedTools(params, ["WebFetch", "Bash"]); + + assert.deepEqual(merged._meta.claudeCode.options.disallowedTools, [ + "Bash", + "WebFetch", + ]); + assert.equal(merged._meta.claudeCode.options.model, "x"); + assert.equal(merged.cwd, "/home/agentos"); + assert.deepEqual(params._meta.claudeCode.options.disallowedTools, ["Bash"]); + assert.equal(withDisallowedTools(params, []), params); +}); + +test("published Claude Agent ACP withholds disallowed built-in tools from the model", async () => { + async function toolsSentToModel(extraEnv) { + const mock = new LLMock({ port: 0, logLevel: "silent" }); + mock.addFixtures([ + { + match: { userMessage: "Reply with disallowed-tools" }, + response: { content: "disallowed-tools" }, + }, + ]); + const baseUrl = await mock.start(); + try { + await withAdapter( + async (connection) => { + await connection.initialize({ + protocolVersion: PROTOCOL_VERSION, + clientCapabilities: {}, + clientInfo: { name: "agentos-test", version: "0.0.1" }, + }); + const session = await connection.newSession({ + cwd: packageDir, + mcpServers: [], + }); + const result = await connection.prompt({ + sessionId: session.sessionId, + prompt: [{ type: "text", text: "Reply with disallowed-tools" }], + }); + assert.equal(result.stopReason, "end_turn"); + }, + { ANTHROPIC_BASE_URL: baseUrl, ...extraEnv }, + ); + // Claude Code also issues small toolless background calls; the main + // turn is the request that carries the tool schema. + const request = mock + .getRequests() + .find((entry) => (entry.body?.tools ?? []).length > 0); + assert.ok(request, "expected a tool-carrying request to reach the mock model"); + return new Set( + request.body.tools.map((tool) => tool.function?.name ?? tool.name), + ); + } finally { + await mock.stop(); + } + } + + const enabled = await toolsSentToModel({}); + assert.ok(enabled.has("WebFetch")); + assert.ok(enabled.has("WebSearch")); + assert.ok(enabled.has("Read")); + + const disabled = await toolsSentToModel({ + [DISALLOWED_TOOLS_ENV]: "WebFetch,WebSearch", + }); + assert.equal(disabled.has("WebFetch"), false); + assert.equal(disabled.has("WebSearch"), false); + assert.ok(disabled.has("Read")); +}); + +test("published Claude Agent ACP fails fast on a malformed disallowed-tools value", async () => { + const child = spawn(process.execPath, [adapterPath], { + cwd: packageDir, + env: { + ...process.env, + CLAUDE_CODE_EXECUTABLE: claudePath, + ANTHROPIC_API_KEY: "agentos-test-key", + DISABLE_TELEMETRY: "1", + [DISALLOWED_TOOLS_ENV]: ",,,", + }, + stdio: ["pipe", "pipe", "pipe"], + }); + let stderr = ""; + child.stderr.setEncoding("utf8"); + child.stderr.on("data", (chunk) => { + stderr += chunk; + }); + const [code] = await once(child, "exit"); + + assert.notEqual(code, 0); + assert.match(stderr, new RegExp(DISALLOWED_TOOLS_ENV)); +});