Anthropic Agent SDK security architecture: permissions and tool integration
The Claude Agent SDK (formerly the Claude Code SDK, often searched as the Anthropic Agent SDK) runs the Claude Code binary from your TypeScript or Python app. Tools reach the model three ways: Claude Code’s built-in tools, MCP servers you configure, and in-process tools you define with tool() and createSdkMcpServer(). Every call passes one permission check. The best place for your own policy check is a PreToolUse hook, because it is the only step that sees every call.
Facts below come from the official Agent SDK docs, read on 2026-11-07. For the hook types, see the companion Claude Agent SDK hooks reference.
The two processes
The overview calls the SDK “a library that runs the Claude Code binary.” The TypeScript reference refers to “the CLI subprocess” throughout, and the SDK bundles a native binary as an optional dependency. So there are two sides:
| Runs in your Node process | Runs in the Claude Code subprocess |
|---|---|
options.hooks callbacks |
The agent loop and model calls |
canUseTool callback |
Built-in tools: Bash, Read, Write, Edit, Glob, Grep, WebFetch, Agent and others |
Tools from createSdkMcpServer(), which the reference says “runs in the same process as your application” |
Shell command hooks loaded from settings files |
Your credentials, but only if you set env. By default the subprocess inherits all of process.env |
Session transcripts written to disk |
Two options move that line. env “replaces the subprocess environment instead of merging with process.env,” so you choose which variables Claude Code sees. spawnClaudeCodeProcess is a “custom function to spawn the Claude Code process. Use to run Claude Code in VMs, containers, or remote environments.” With it, your callbacks stay on the host and tool execution moves into an isolated environment.
How tools are integrated
Built-in tools. The tools option sets which ones exist. tools: ["Read", "Grep"] keeps only those. tools: [] removes all of them, so Claude can use only your MCP tools (custom tools guide).
In-process custom tools.
import { tool, createSdkMcpServer, query } from "@anthropic-ai/claude-agent-sdk";
import { z } from "zod";
const lookupInvoice = tool(
"lookup_invoice",
"Look up an invoice by ID",
{ id: z.string().describe("Invoice ID") },
async (args) => {
const row = await billingDb.find(args.id); // credentials live here, in your process
return { content: [{ type: "text", text: JSON.stringify(row) }] };
},
{ annotations: { readOnlyHint: true } }
);
const billing = createSdkMcpServer({ name: "billing", version: "1.0.0", tools: [lookupInvoice] });
for await (const message of query({
prompt: "What is the status of invoice 4411?",
options: {
mcpServers: { billing },
allowedTools: ["mcp__billing__lookup_invoice"],
},
})) {
if (message.type === "result" && message.subtype === "success") console.log(message.result);
}
The signatures, from the reference:
function tool<Schema extends AnyZodRawShape>(
name: string,
description: string,
inputSchema: Schema,
handler: (args: InferShape<Schema>, extra: unknown) => Promise<CallToolResult>,
extras?: { annotations?: ToolAnnotations; searchHint?: string; alwaysLoad?: boolean }
): SdkMcpToolDefinition<Schema>;
function createSdkMcpServer(options: {
name: string;
version?: string;
instructions?: string;
tools?: Array<SdkMcpToolDefinition<any>>;
alwaysLoad?: boolean;
timeout?: number;
}): McpSdkServerConfigWithInstance;
The model sees the tool as mcp__billing__lookup_invoice. A handler that throws does not stop the loop. The server turns the exception into an error result with the raw message. If that message could leak internals, catch it and return isError: true with your own text. Annotations such as readOnlyHint are “metadata, not enforcement.”
External MCP servers go in the same mcpServers map and get the same mcp__<server>__<tool> names.
Allowed tools, disallowed tools and permission modes
These are easy to misread. From the permissions page:
allowedToolspre-approves. It does not restrict. Unlisted tools are still available and fall through to the permission mode andcanUseTool.disallowedTools: ["Bash"]removes the tool from the request.disallowedTools: ["Bash(rm *)"]keeps Bash and denies matching calls “as written” in every mode, includingbypassPermissions./bin/rmis not matched.allowedTools“does not constrainbypassPermissions.” In that mode an unlistedBashcall is approved.- For a locked-down headless agent, the docs pair
allowedToolswithpermissionMode: "dontAsk", which turns every would-be prompt into a deny.
PermissionMode is "default" | "acceptEdits" | "bypassPermissions" | "plan" | "dontAsk" | "auto". bypassPermissions also needs allowDangerouslySkipPermissions: true. Subagents inherit plan, acceptEdits, auto or bypassPermissions from the main session. The docs warn that inheriting bypassPermissions “grants them full, autonomous system access.”
The order of checks
Every tool call goes through six steps:
- Hooks. A hook can deny outright. A hook
allowdoes not skip the deny and ask rules. - Deny rules from
disallowedToolsand loaded settings files. These apply even inbypassPermissions. - Ask rules from settings files, which fall through to
canUseTool. - Permission mode.
- Allow rules from
allowedToolsand settings files. canUseTool, only if nothing above settled the call.
The docs say: “Auto-approved tools never reach canUseTool.” The SDK emits a CLAUDE_SDK_CAN_USE_TOOL_SHADOWED process warning when your setup makes that likely.
Where a policy check can sit
| Location | Sees | Good for |
|---|---|---|
PreToolUse hook |
Every tool call, before it runs, in every mode | The policy check. One place for built-in, MCP and custom tools |
canUseTool |
Only calls nothing earlier settled | Asking a human |
Inside a tool() handler |
Only that tool | Business rules that need the tool’s own data |
| A network proxy | Outbound HTTP from the agent environment | Credential injection, egress allowlists |
This is also the clean split between a “security module” and a “tools module.” Tool handlers do the work and hold the credentials they need. The hook makes decisions and holds none.
TypeScript middleware for an external policy service
The policy decision point (PDP) below is any HTTP service that answers { decision: "allow" | "deny" | "ask", reason }. That shape is ours, not Anthropic’s.
import { query, HookCallback, PreToolUseHookInput, Options } from "@anthropic-ai/claude-agent-sdk";
const PDP_URL = process.env.PDP_URL!; // your policy service
type PdpAnswer = { decision: "allow" | "deny" | "ask"; reason?: string };
function pdpMiddleware(principal: { userId: string; tenant: string }): HookCallback {
return async (input, toolUseID, { signal }) => {
if (input.hook_event_name !== "PreToolUse") return {};
const pre = input as PreToolUseHookInput;
const res = await fetch(PDP_URL, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
subject: principal,
action: pre.tool_name,
resource: pre.tool_input,
context: {
session: pre.session_id,
toolUseID,
agent: pre.agent_id ?? "main",
mode: pre.permission_mode,
mcpServer: pre.mcp_server,
},
}),
signal, // cancelled if the hook times out
});
if (!res.ok) throw new Error(`PDP returned ${res.status}`); // let it fail; onFailure blocks
const answer = (await res.json()) as PdpAnswer;
return {
hookSpecificOutput: {
hookEventName: "PreToolUse",
permissionDecision: answer.decision,
permissionDecisionReason: answer.reason ?? `PDP: ${answer.decision}`,
},
};
};
}
const options: Options = {
permissionMode: "dontAsk",
hooks: {
PreToolUse: [
{ hooks: [pdpMiddleware({ userId: "u_123", tenant: "acme" })], timeout: 5, onFailure: "block" },
],
},
};
for await (const m of query({ prompt: "Clean up old build artifacts", options })) {
console.log(m);
}
What each choice does, per the hooks guide:
- No matcher, so the hook runs for every tool.
onFailure: "block". Without it, a callback that throws lets the call continue into the normal permission check. With it, a network error or a non-2xx response blocks the call. Needs TypeScript SDK v0.3.317 or later.timeout: 5(seconds). APreToolUsetimeout blocks the tool call either way. The default is 600 seconds, far too long for a check that holds up every call.- Don’t catch the error yourself. The guide notes that a callback that catches its error and returns
{}“isn’t failing,” so the call goes ahead. "ask"indontAskmode becomes a deny, since that mode never prompts. Usedefaultmode with acanUseToolcallback if you wantaskto reach a person.- A PDP
allowis not final. Deny rules still apply after it. That helps: hard limits indisallowedToolshold even if the PDP is wrong.
Add a PostToolUse hook if your PDP also needs to see results. That hook can replace the output via updatedToolOutput before Claude reads it.
Sandboxing and working-directory limits
cwddefaults toprocess.cwd().additionalDirectoriesadds more, passed to Claude Code as--add-dir.acceptEditsauto-approves edits and filesystem commands only inside those working directories.- Path rules such as
Edit(//secrets/**)block Claude’s file tools and shell redirection. The permissions page adds: “a script that Claude runs and that opens files itself can still write under/secrets.” For OS-level enforcement it points to the sandbox. - The
sandboxoption takesSandboxSettings. The secure deployment guide says: “Bash commands can run in a sandboxed environment that restricts filesystem and network access.” It covers Bash, not your in-process tools. The standalone sandbox-runtime usesbubblewrapon Linux andsandbox-execon macOS. TheSandboxSettingsfields are in the TypeScript reference. - For stronger isolation the same guide compares containers, gVisor and Firecracker VMs. It gives a hardened
docker runexample with--cap-drop ALL,--read-onlyand--network none.
Secrets
The secure deployment guide recommends a proxy that runs outside the agent’s boundary and injects credentials, so “the agent never sees the actual credentials.” ANTHROPIC_BASE_URL sends model requests to that proxy in plaintext. HTTP_PROXY and HTTPS_PROXY only see an opaque tunnel for HTTPS unless the proxy terminates TLS.
In SDK terms:
- Pass a minimal
env. It replaces the subprocess environment, so nothing leaks into Bash that you didn’t list. - Keep service credentials inside
tool()handlers in your process, or behind a proxy those handlers call. Claude sees the tool interface, not the key. - Don’t leave
.env,~/.aws/credentials,~/.kube/config,.npmrcor*.pemin the working directory. The guide lists these as common leaks, even under read-only mounts.
What the SDK leaves to you
- No central audit log. Session transcripts go to local disk unless
persistSession: false, and hooks can log wherever you send them. The docs describe no audit trail that sits outside the process, records a verdict per call, and can’t be edited by the agent. The deployment guide’s answer is to log at the proxy. - No end-user identity. Hook inputs carry
session_id,agent_idandcwd. Who the human is, and what they may do, is yours to bind. The middleware above passes it by closure. - No policy across machines. Rules come from your options and the settings files on that host. Nothing checks calls from many apps or hosts against one policy.
- No spend cap beyond one query.
maxBudgetUsdstops a query “when the client-side cost estimate reaches this USD value,” and the reference warns the estimate can overshoot. It does not count resumed totals, other sessions or other users. - No guarantee from annotations or regex rules.
readOnlyHintis metadata. Bash rules match “as written.”
ACP is built to fill gaps like these, but you wire it in yourself. Because the SDK loads shell hooks from Claude Code settings files, the same ACP hook that Claude Code uses can apply here. We haven’t tested that under the SDK. If you build on the raw Anthropic SDK instead, the Anthropic Agent SDK integration shows how to wrap your tool handlers.
Frequently asked questions
What is the Anthropic Agent SDK security architecture for tool integration?
Your app calls query(), which runs the bundled Claude Code binary as a subprocess. Built-in tools such as Bash and Edit run in that subprocess. Your hooks, your canUseTool callback and in-process MCP tools from createSdkMcpServer run in your own process. Every tool call passes a six-step permission check: hooks, deny rules, ask rules, permission mode, allow rules, then canUseTool.
Where do the security module and tools module boundaries go with the Anthropic Agent SDK?
Keep tool code (the tool() handlers and their credentials) apart from policy code (a PreToolUse hook or canUseTool callback). The hook sees every call, including built-in and MCP tools, so it is the one place to put policy. Tool handlers should only do the work.
How do I write TypeScript middleware for Anthropic Agent SDK PDP integration?
Register a PreToolUse hook with no matcher that POSTs tool_name, tool_input and session data to your policy decision point (PDP), then maps its answer to permissionDecision allow, deny or ask. Set onFailure: "block" and a timeout on the matcher so an unreachable PDP denies the call.
Does the Claude Agent SDK have an audit log or a cost cap?
It writes session transcripts to local disk by default and has maxBudgetUsd, which stops one query when a client-side cost estimate reaches a value. The docs describe no central audit log that the agent can’t edit and no budget that spans sessions, users or machines. You build those yourself.
Where to read more
- Agent SDK overview
- Configure permissions
- Give Claude custom tools
- Intercept and control agent behavior with hooks
- Securely deploying AI agents
- TypeScript SDK reference
- sandbox-runtime
Running coding agents? ACP records each tool call its hook sees and makes the dangerous shell commands (a force-push, destructive SQL, curl | sh) wait for you. One command, free up to 5 agents:
curl -fsSL https://agenticcontrolplane.com/install.sh | bash
irm https://agenticcontrolplane.com/install.ps1 | iex