# Anthropic Agent SDK security architecture: permissions and tool integration

How tools plug into the Claude Agent SDK, how its permission checks run, where a policy check fits, and what the SDK leaves to you.

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](https://code.claude.com/docs/en/agent-sdk/overview), read on 2026-11-07. For the hook types, see the companion [Claude Agent SDK hooks reference](/blog/claude-agent-sdk-hooks-reference-typescript).

## The two processes

The [overview](https://code.claude.com/docs/en/agent-sdk/overview) calls the SDK "a library that runs the Claude Code binary." The [TypeScript reference](https://code.claude.com/docs/en/agent-sdk/typescript) 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](https://code.claude.com/docs/en/agent-sdk/custom-tools#configure-allowed-tools)).

**In-process custom tools.**

```typescript
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:

```typescript
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](https://code.claude.com/docs/en/agent-sdk/permissions#allow-and-deny-rules):

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

```typescript
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](https://code.claude.com/docs/en/agent-sdk/hooks):

- **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](https://code.claude.com/docs/en/agent-sdk/secure-deployment) 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](https://github.com/anthropics/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](/integrations/anthropic-agent-sdk) shows how to wrap your tool handlers.

## Frequently asked questions



## Where to read more

- [Agent SDK overview](https://code.claude.com/docs/en/agent-sdk/overview)
- [Configure permissions](https://code.claude.com/docs/en/agent-sdk/permissions)
- [Give Claude custom tools](https://code.claude.com/docs/en/agent-sdk/custom-tools)
- [Intercept and control agent behavior with hooks](https://code.claude.com/docs/en/agent-sdk/hooks)
- [Securely deploying AI agents](https://code.claude.com/docs/en/agent-sdk/secure-deployment)
- [TypeScript SDK reference](https://code.claude.com/docs/en/agent-sdk/typescript)
- [sandbox-runtime](https://github.com/anthropics/sandbox-runtime)
