Skip to content
Agentic Control Plane

Anthropic Agent SDK security architecture: permissions and tool integration

David Crowe David Crowe · · Updated · 16 min read
claude-agent-sdk anthropic security tools reference
Share X HN LinkedIn

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:

  • allowedTools pre-approves. It does not restrict. Unlisted tools are still available and fall through to the permission mode and canUseTool.
  • disallowedTools: ["Bash"] removes the tool from the request. disallowedTools: ["Bash(rm *)"] keeps Bash and denies matching calls “as written” in every mode, including bypassPermissions. /bin/rm is not matched.
  • allowedTools “does not constrain bypassPermissions.” In that mode an unlisted Bash call is approved.
  • For a locked-down headless agent, the docs pair allowedTools with permissionMode: "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:

  1. Hooks. A hook can deny outright. A hook allow does not skip the deny and ask rules.
  2. Deny rules from disallowedTools and loaded settings files. These apply even in bypassPermissions.
  3. Ask rules from settings files, which fall through to canUseTool.
  4. Permission mode.
  5. Allow rules from allowedTools and settings files.
  6. 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). A PreToolUse timeout 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" in dontAsk mode becomes a deny, since that mode never prompts. Use default mode with a canUseTool callback if you want ask to reach a person.
  • A PDP allow is not final. Deny rules still apply after it. That helps: hard limits in disallowedTools hold 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

  • cwd defaults to process.cwd(). additionalDirectories adds more, passed to Claude Code as --add-dir.
  • acceptEdits auto-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 sandbox option takes SandboxSettings. 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 uses bubblewrap on Linux and sandbox-exec on macOS. The SandboxSettings fields are in the TypeScript reference.
  • For stronger isolation the same guide compares containers, gVisor and Firecracker VMs. It gives a hardened docker run example with --cap-drop ALL, --read-only and --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, .npmrc or *.pem in 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_id and cwd. 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. maxBudgetUsd stops 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. readOnlyHint is 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

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:

macOS · Linux · WSL
curl -fsSL https://agenticcontrolplane.com/install.sh | bash
Windows · PowerShell
irm https://agenticcontrolplane.com/install.ps1 | iex

What the installer writes  ·  Getting started

Share X HN LinkedIn
Get the next data drop
What agents actually cost, new tool-surface captures, and the occasional incident post-mortem — sent when we publish something worth your inbox, not on a schedule. Unsubscribe anytime.
Share: Twitter LinkedIn
Related posts

← back to blog