tl;dr. the sdk has a four-mode dial (default,
acceptEdits, bypassPermissions, plan), a per-call
canUseTool callback that can allow / deny / rewrite
each tool call, and a declarative rule list of allow /
deny / ask patterns. layer them. defaults
are conservative; production agents tighten further.
why permissions are the lesson everyone skips
most "build an agent" tutorials skip permissions because the demo agent only reads files. real agents edit, delete, run shell, push to git, hit external apis, charge cards. the difference between a useful autonomous agent and a destructive one is almost always permission plumbing — not a smarter model. this is the lesson you'll come back to most. when permissions aren't enough, reach for hooks for hard policy gates.
the four modes
- defaultthe sdk consults
canUseTooland the rule list before each tool call. nothing dangerous runs unless your code says so. - acceptEditsauto-allow file edits (Edit, Write, MultiEdit), still ask for Bash and other potentially-destructive tools. the sweet spot for ide-style agents.
- bypassPermissionsallow everything. only safe in disposable environments — fresh containers, ephemeral vms, ci jobs that can't reach prod.
- planread-only. the agent investigates and writes a plan; it cannot execute side-effecting tools. great for "audit this codebase" style runs.
// the four built-in modes:
// - "default" — sdk asks via canUseTool / settings
// - "acceptEdits" — auto-allow Edit/Write, still ask for Bash
// - "bypassPermissions" — allow everything (use only in sandboxes)
// - "plan" — read-only; agent plans, never executes
const run = query({
prompt: "audit this codebase for unused dependencies",
options: { permissionMode: "plan" },
}); canUseTool — the surgical layer
the mode is a coarse setting; canUseTool is a per-call
callback. it gets the tool name and the input the model wants to
pass, and returns one of three responses:
- allowrun it. you can return an
updatedInputto rewrite the call before execution — a useful place to sandbox file paths or cap arguments. - denydon't run it.
messageis sent back to the model so it understands what just happened and can choose another path. - askdefer to the user. only meaningful in interactive surfaces (cli, ide); on a server you'd treat this as deny.
import { query, type CanUseTool } from "@anthropic-ai/claude-agent-sdk";
const canUseTool: CanUseTool = async (toolName, input) => {
// hard-deny any rm command
if (toolName === "Bash" && /^\s*rm\b/.test(String(input.command ?? ""))) {
return { behavior: "deny", message: "rm is not allowed in this run." };
}
// allow reads silently
if (toolName === "Read" || toolName === "Glob") {
return { behavior: "allow", updatedInput: input };
}
// for everything else, allow but rewrite the path to a temp dir
if (toolName === "Write") {
return {
behavior: "allow",
updatedInput: { ...input, file_path: "/tmp/agent-out/" + input.file_path },
};
}
return { behavior: "allow", updatedInput: input };
};
const run = query({
prompt: "fix the type error in src/api.ts",
options: { permissionMode: "default", canUseTool },
}); declarative rules
for static policies — "never let any agent run rm",
"always allow npm test" — a callback is overkill. the sdk reads
permission rules from project (.claude/settings.json)
and user (~/.claude/settings.json) settings, and from
an inline settings bag passed to query().
// settled as a list of permission rules in the project's .claude/settings.json
// or passed inline. the sdk merges them in the order: cli flags > project > user.
const run = query({
prompt: "...",
options: {
permissionMode: "default",
settings: {
permissions: {
allow: ["Read", "Glob", "Grep", "Bash(npm test:*)"],
deny: ["Bash(rm *)", "Bash(git push:*)"],
ask: ["Edit", "Write"],
},
},
},
});
rules and canUseTool compose: the rules are checked
first; canUseTool only sees calls that aren't
pre-decided. for most agents the right shape is rules for the
common cases, callback for the surgical ones.
plan mode in two sentences
plan mode flips the agent into research-only behaviour: every tool that has side effects is silently blocked, and the agent is instructed to produce a plan as its final answer. you read the plan, decide whether to run it, and re-issue the same prompt without plan mode if you want execution.
what to notice
- layer, don't choose.mode + rules + callback work together. the right setup uses all three: mode for the broad shape, rules for static policy, callback for context-dependent decisions.
- deny messages reach the model.a clear deny message ("Bash rm not allowed — use the rm wrapper at scripts/rm.sh") helps the model recover. a vague one wastes a turn.
- log every decision.put telemetry in
canUseTool. the audit trail of what was allowed / denied / rewritten is gold for debugging weird agent behaviour.