scalable/ai/claude agent sdk/lesson 04 cph / /
lesson 04 / 08 · 12 min · updated ·

permissions — the safety model

canUseTool, allow / ask / deny, plan mode. the difference between a helpful agent and a destructive one.

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

modes are a single option — pick once per query()
// 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:

canUseTool — three real branches you'll write
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 },
});
updatedInput is a feature. rewriting the tool input lets you scope a Write to a temp directory, swap a Bash command for a safer equivalent, or strip a destructive flag — without rejecting the call. the model still sees the tool succeed; you just executed something different from what it asked for.

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().

allow / deny / ask patterns — bash matching uses claude code's pattern syntax
// 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

scalable labs·cvr 30091604·github·linkedin·hello@scalable.dk