Every tool call goes through ExAthena.Permissions.check/4 before execution. The check has four layers, evaluated in this order — first decisive answer wins:

1. :disallowed_tools   denylist (always denies, deny-first)
2. :allowed_tools      allowlist (denies anything not in it)
3. ctx.phase           permission mode (see below)
4. :can_use_tool       caller-supplied callback (interactive approval)

Five modes

The permission mode lives at ctx.phase (configured via :phase opt). Five modes ship; one name (:auto) is reserved.

:plan

Read-only. The mutating builtins (Write, Edit, Bash, TodoWrite) are denied with reason {:mutation_in_plan_mode, name}. Read-only builtins (Read, Glob, Grep, WebFetch, PlanMode, SpawnAgent) are allowed. Custom tools fall through to the callback.

ExAthena.run("explore the repo", tools: :all, phase: :plan)

The agent uses the PlanMode tool to request a transition out of :plan (typically into :default).

:default

The standard mode. Read + write + shell, but the :can_use_tool callback (if supplied) can prompt for approval.

can_use_tool = fn name, args, _ctx ->
  case name do
    "bash" -> ask_user("Run `#{args["command"]}`?")
    _ -> :allow
  end
end

ExAthena.run("ship it", tools: :all, phase: :default, can_use_tool: can_use_tool)

The callback returns :allow, :deny, or {:deny, reason}.

:accept_edits

Auto-allow file edits + read-only tools without prompting. Still consults can_use_tool for bash and custom tools.

Auto-allow set: read, glob, grep, web_fetch, plan_mode, spawn_agent, write, edit, todo_write.

ExAthena.run("refactor the file", tools: :all, phase: :accept_edits)

The right mode for "I trust you to edit code, ask me before running shell commands". Common in CI / automated workflows.

:trusted

Skip the can_use_tool callback for every tool. Still respects the denylist by default — respect_denylist: false opts out of even the denylist (the only way to fully bypass it).

# Trust everything except `bash`
ExAthena.run("CI agent",
  tools: :all,
  phase: :trusted,
  disallowed_tools: ["bash"])

# Full YOLO — explicit opt-in
ExAthena.run("sandbox automation",
  tools: :all,
  phase: :trusted,
  respect_denylist: false)

:trusted replaces what other tools call "auto" or "yolo" mode. The name :auto is reserved for a future ML-based safety classifier that the Claude Code paper documents — don't use it.

:bypass_permissions

Skip the callback AND auto-allow every tool — but the denylist still wins. Locked in a doctest:

iex> alias ExAthena.{Permissions, ToolContext}
iex> alias ExAthena.Messages.ToolCall
iex> tc = %ToolCall{id: "1", name: "bash", arguments: %{}}
iex> ctx = ToolContext.new(cwd: "/tmp", phase: :bypass_permissions)
iex> Permissions.check(tc, ctx, %{disallowed_tools: ["bash"]})
{:deny, {:disallowed, "bash"}}

Use this for fully unattended runs where you've explicitly enumerated the deny list.

Deny-first ordering

The denylist is the user's "absolutely never" list. It always runs first. The doctest also locks :allowed_tools precedence over a permissive callback:

iex> alias ExAthena.{Permissions, ToolContext}
iex> alias ExAthena.Messages.ToolCall
iex> tc = %ToolCall{id: "1", name: "bash", arguments: %{}}
iex> ctx = ToolContext.new(cwd: "/tmp", phase: :default)
iex> opts = %{allowed_tools: ["read"], can_use_tool: fn _, _, _ -> :allow end}
iex> Permissions.check(tc, ctx, opts)
{:deny, {:not_in_allowlist, "bash"}}

can_use_tool callback contract

Function arity 3:

@type can_use_tool ::
  (tool_name :: String.t(), arguments :: map(), ctx :: ToolContext.t() ->
     :allow
     | {:allow, term()}
     | :deny
     | {:deny, reason :: term()})

Return values are normalised:

ReturnEffect
:allow / {:allow, _}Tool executes
:denyBecomes {:deny, :denied_by_callback}
{:deny, reason}Reason flows to the model as a tool-result error
anything elseBecomes {:deny, {:unexpected_callback_result, value}}

When the callback denies, the loop:

  1. Fires the PermissionDenied hook with tool_name, tool_use_id, arguments, reason.
  2. Replays a tool-result message with is_error: true and content "permission denied: #{inspect(reason)}".
  3. Continues the loop — the model sees the deny reason and adjusts.

This is Claude Code's "deny as routing signal" pattern.

Skill-scoped permissions

A skill's frontmatter allowed-tools field restricts which tools can run while that skill is loaded into context:

---
name: deploy
description: Ship to production
allowed-tools: [bash, read]
---

When the skill is active, Permissions.check/4 consults the state.active_skills map; only bash and read are permitted for the duration of the skill being in context. Falls back to the normal phase-based check when no skill is active or the active skill has no allowed-tools set. See memory + skills.

See also