PermDock
Adapters

Claude Agent SDK

permdock/claude-agent answers Claude Agent SDK canUseTool callbacks and PermissionRequest hooks from PermDock decisions, mapping built-in tools such as Bash to typed permissions.

permdock/claude-agent plugs PermDock into the two permission surfaces of the Claude Agent SDK: the canUseTool callback that runs before every tool invocation, and the PermissionRequest hook that fires when the SDK is about to ask a human. Both are answered from one Decision, so the same policy that guards an HTTP route decides whether an agent may run a shell command.

Purpose

The Claude Agent SDK exposes tool use to the host application as a callback: the SDK proposes a tool call, the host allows or denies it, optionally rewriting the input. Without a policy layer, hosts hard-code tool names and argument patterns in that callback. permdock/claude-agent replaces the hard-coding with a tools map from SDK tool names to permission references, and applies PermDock's subject model so the agent acts as an actor under the user's authority and never exceeds it. The result is the same three-outcome mapping used by permdock/ai-sdk and permdock/mcp, with a human approval path where a grant says approval: 'human'.

API

import { createPermDock } from "permdock/claude-agent";
import { z } from "zod";

const PostArgs = z.object({ id: z.string() }); // tool input arrives as `unknown`

const { canUseTool, permissionRequestHook } = createPermDock(policy, {
  subject: () => currentUser,
  actor: () => ({ id: "claude-agent", kind: "claude-agent" }),
  delegation: () => ({
    scopes: [permissions.post.read.scope, permissions.post.update.scope],
  }),
  tools: {
    Bash: {
      permission: permissions.shell.run,
      data: (input) => ({ command: input.command }),
    },
    Write: {
      permission: permissions.file.write,
      data: (input) => ({ path: input.file_path }),
    },
    Read: {
      permission: permissions.file.read,
      data: (input) => ({ path: input.file_path }),
    },
    mcp__posts__delete_post: {
      permission: permissions.post.delete,
      data: (input) => loadPost(PostArgs.parse(input).id),
    },
  },
});

query({
  prompt,
  options: {
    canUseTool,
    hooks: { PermissionRequest: [{ hooks: [permissionRequestHook] }] },
  },
});
  • tools keys are SDK tool names, including built-ins (Bash, Read, Write, Edit, WebFetch) and MCP tools by their prefixed name. Each entry names a permission and, for instance-level actions, a data resolver that turns the tool input into the resource shape the permission's schema expects.
  • subject and actor are resolved once per query unless they are functions of the call context; delegation states what the user handed the agent (the scopes of scoped credentials, or the permissions the host chooses) and has no default: an actor with no delegation is denied every tool with reason no-delegation.
  • canUseTool and permissionRequestHook are ready to pass to the SDK and take its real shapes: canUseTool(toolName, input, { signal, mcpServer, agentID, toolUseID }) and the PermissionRequest hook input (hook_event_name, session_id, agent_id, tool_name, tool_input, mcp_server). The hook returns {} for any other event.
  • mcpSources (default ['sdk']) lists the MCP server sources whose tools are trusted. An mcp__ tool is denied when the SDK reports no server provenance, when the server's source is not listed, or when the tool name does not start with mcp__<server name>__; trust keys on the source, never on the name alone.
  • store (an ApprovalStore), sink, limits, memberships, customRoles and tenant behave as on every adapter.

Request lifecycle

  1. The model proposes a tool call. The SDK invokes canUseTool(toolName, input, context).
  2. The adapter looks up toolName in tools. Unmapped tools are denied (fail closed) with a message naming the tool.
  3. If data is declared, the input is projected into the resource shape and validated against the resource schema (boundary mode).
  4. permdock.decide(permission, data) runs.
  5. The outcome maps to the SDK's return value:
Decision outcomecanUseTool result
grantedallow, with the input passed back unchanged as updatedInput
denieddeny, with a message built from denials and alternatives
approval-requireddeny, with a message naming the permission, the resource and Approval <token> is pending; retry the call once it is approved.; the pending request is written to the store
  1. A reviewer resolves the request through approvalsHandler, resolveApproval or the PermDock Cloud inbox. When the agent retries the same call, the adapter recomputes the token from permission, resource, principal, actor and input, finds the approved record in the store, consumes it and allows the call once; a replay is denied with approval-consumed. permissionRequestHook answers the SDK's own prompt with the same decision, so the SDK never asks a human to allow what PermDock denied or has not yet approved.
  2. on('decision') fires for every step, including the human's answer, for audit and permdock/otel.

What it validates

  • Tool input against the permission's resource schema after data projection (validate: 'boundary'). For Bash, a shell.run resource can carry command so where conditions such as an allow-listed prefix are expressed as portable conditions, not regexes in the callback.
  • Subject provenance: the subject comes from the host process, never from the conversation or tool input.
  • Approval replays: the token is recomputed on every retry and must match an approved record in the store; an approval resumes one call, and a replay is denied with approval-consumed.
  • MCP provenance: mcp__ tools only from trusted mcpSources whose server name matches the tool prefix.
  • Coverage in development: a warning lists SDK tools enabled for the session that are missing from tools, because those calls will be denied at runtime.

How denials surface

  • denied returns the SDK deny behaviour with a message such as Denied: shell.run (command not allow-listed). You may: file.read, file.write. The message is built from Decision.denials reasons and alternatives so the model can self-correct.
  • approval-required is a deny whose message tells the model the call is waiting for approval and carries the token, so the model can tell the user and retry instead of treating it as a final refusal.
  • Errors thrown by a data resolver or by the policy are caught, logged through on('decision') and reported as a deny; the agent loop never receives an unhandled exception from the permission layer.
  • There is no pass-through or default-allow mode; the SDK's own permission modes remain in force on top of PermDock's answer.
  • PermDock never rewrites tool input. Forcing a cwd for Bash or similar is host code, and a shell.run resource schema is defined by the application; the entry ships no built-in tool schemas.
  • A pending approval lives in the store, so with a durable ApprovalStore it survives a restarted session: the retried call recomputes the same token and finds the record.

Example app

apps/examples/claude-agent: HTTP harness on 127.0.0.1:3473 with GET /health. GET /list_posts calls canUseTool and returns behavior: 'allow'. GET /delete_post returns a deny carrying the pending approval token; after POST /approvals?token=… the next GET /delete_post is allowed once and the one after that is denied. No Anthropic API key; a full query() loop needs the Claude Code runtime and is not part of the test suite.

  • Approvals: approval: 'human', replay-safe token.
  • Delegation: the agent as actor, principal grants intersected with delegated authority.
  • OWASP Agentic Top 10: ASI02 Tool Misuse, ASI03 Identity and Privilege Abuse, least agency.
  • MCP authorization: for MCP tools reached through the Claude Agent SDK.

Last updated on

On this page