Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions docs/content/docs/agents/claude.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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.
</Note>

## 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.

<CodeSnippet file="examples/claude/client.ts" title="client.ts" region="disallowed-tools" />

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).
20 changes: 19 additions & 1 deletion examples/claude/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = `---
Expand Down Expand Up @@ -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 };
28 changes: 28 additions & 0 deletions software/claude/src/adapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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 = {
Expand Down Expand Up @@ -144,12 +152,32 @@ type AgentPrototype = {
setSessionConfigOption(
params: Record<string, unknown>,
): Promise<Record<string, unknown>>;
createSession(
params: Record<string, unknown>,
creationOpts?: Record<string, unknown>,
): Promise<Record<string, unknown>>;
};

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);
Expand Down
103 changes: 103 additions & 0 deletions software/claude/src/disallowed-tools.ts
Original file line number Diff line number Diff line change
@@ -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<string>();
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<string, unknown> {
return value && typeof value === "object" && !Array.isArray(value)
? (value as Record<string, unknown>)
: {};
}

/**
* 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<T extends Record<string, unknown>>(
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]),
},
},
},
};
}
125 changes: 125 additions & 0 deletions software/claude/tests/adapter.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down Expand Up @@ -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));
});