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));
+});