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] }] },
},
});toolskeys 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, adataresolver that turns the tool input into the resource shape the permission's schema expects.subjectandactorare resolved once perqueryunless they are functions of the call context;delegationstates what the user handed the agent (the scopes of scoped credentials, or the permissions the host chooses) and has no default: anactorwith nodelegationis denied every tool with reasonno-delegation.canUseToolandpermissionRequestHookare ready to pass to the SDK and take its real shapes:canUseTool(toolName, input, { signal, mcpServer, agentID, toolUseID })and thePermissionRequesthook 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. Anmcp__tool is denied when the SDK reports no server provenance, when the server'ssourceis not listed, or when the tool name does not start withmcp__<server name>__; trust keys on the source, never on the name alone.store(anApprovalStore),sink,limits,memberships,customRolesandtenantbehave as on every adapter.
Request lifecycle
- The model proposes a tool call. The SDK invokes
canUseTool(toolName, input, context). - The adapter looks up
toolNameintools. Unmapped tools are denied (fail closed) with a message naming the tool. - If
datais declared, the input is projected into the resource shape and validated against the resource schema (boundary mode). permdock.decide(permission, data)runs.- The outcome maps to the SDK's return value:
| Decision outcome | canUseTool result |
|---|---|
granted | allow, with the input passed back unchanged as updatedInput |
denied | deny, with a message built from denials and alternatives |
approval-required | deny, 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 |
- A reviewer resolves the request through
approvalsHandler,resolveApprovalor 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 withapproval-consumed.permissionRequestHookanswers 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. on('decision')fires for every step, including the human's answer, for audit andpermdock/otel.
What it validates
- Tool input against the permission's resource schema after
dataprojection (validate: 'boundary'). ForBash, ashell.runresource can carrycommandsowhereconditions 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 trustedmcpSourceswhose 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
deniedreturns the SDK deny behaviour with a message such asDenied: shell.run (command not allow-listed). You may: file.read, file.write.The message is built fromDecision.denialsreasons andalternativesso the model can self-correct.approval-requiredis 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
dataresolver or by the policy are caught, logged throughon('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
cwdforBashor similar is host code, and ashell.runresource schema is defined by the application; the entry ships no built-in tool schemas. - A pending approval lives in the
store, so with a durableApprovalStoreit 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.
Related standards
- Approvals:
approval: 'human', replay-safetoken. - 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
AI SDK
permdock/ai-sdk turns PermDock decisions into Vercel AI SDK 7 tool approvals, capability middleware and WorkflowAgent suspensions, fail-closed by construction.
Eve
permdock/eve turns PermDock decisions into Eve tool approval policies and approval response policies, takes principal and actor from the durable session's auth, and stores pending approvals in a pluggable ApprovalStore.