ExAthena.Permissions (ExAthena v0.15.1)

Copy Markdown View Source

Decides whether a tool call is allowed.

Every tool call runs through check/4 before execution. The check combines four sources — in this order, first decisive wins:

  1. disallowed_tools — an explicit blocklist. Always denies.
  2. allowed_tools — an explicit allowlist. If non-nil, denies anything not in it.
  3. phase — the current permission mode:
    • :plan — read-only. Writes and shell execution are denied.
    • :default — read + write. can_use_tool callback (if supplied) can ask the user.
    • :accept_edits — auto-allow Read/Edit/Write/Glob/Grep/WebFetch
      • plan_mode / spawn_agent; still consults can_use_tool for everything else (e.g. bash, custom tools).
    • :trusted — skip the can_use_tool callback for every tool. Still respects the disallow / allowlist by default; pass respect_denylist: false to disable that too (equivalent to :bypass_permissions).
    • :bypass_permissions — everything allowed without asking.
  4. can_use_tool — caller-supplied callback (only in :default and unconditionally-allowed-tool slots of :accept_edits).

The can_use_tool callback is a function (tool_name, arguments, ctx -> :allow | :deny | {:deny, reason}) that the loop calls in :default mode for anything the caller marked as sensitive. See Permissions.Opts below.

Reserved name: :auto is reserved for the future ML safety classifier mode the Claude Code paper describes; do not use it.

Deny-first ordering

The check chain is disallowed → allowed → phase → callback, with the first decisive answer winning. A blocked tool stays blocked even when :bypass_permissions would otherwise allow everything:

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> {:deny, denial} = Permissions.check(tc, ctx, %{disallowed_tools: ["bash"]})
iex> denial.code
:user_denied

Likewise, an allowlist denies everything outside it even if a callback would have allowed:

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> {:deny, denial} = Permissions.check(tc, ctx, opts)
iex> denial.code
:user_denied

Summary

Functions

Path prefixes that Glob/Grep skip by default — build outputs and dependency caches that pollute the model's context without adding signal.

Check whether tool_call is allowed under opts. Returns :allow, {:deny, %ExAthena.Permissions.Denial{}}, or {:halt, reason} when the can_use_tool callback requests a hard stop.

Tools to advertise to the model during read-only planning/scope phases. Same as readonly_tools/0 plus "bash", which is conditionally allowed in :plan (read-only commands only — see check_phase/3).

Static list of read-only tool names the :plan phase permits.

Types

opts()

@type opts() :: %{
  optional(:phase) => ExAthena.ToolContext.phase(),
  optional(:allowed_tools) => [String.t()] | nil,
  optional(:disallowed_tools) => [String.t()] | nil,
  optional(:can_use_tool) => (String.t(), map(), ExAthena.ToolContext.t() ->
                                result()),
  optional(:respect_denylist) => boolean()
}

result()

@type result() :: :allow | {:deny, ExAthena.Permissions.Denial.t()} | {:halt, term()}

Functions

artifact_dirs()

@spec artifact_dirs() :: [String.t()]

Path prefixes that Glob/Grep skip by default — build outputs and dependency caches that pollute the model's context without adding signal.

Each entry ends in / so plain String.starts_with?/2 matches a whole directory and never a same-named file at the root. Pass include_artifacts: true to either tool to bypass the filter.

check(tool_call, ctx, opts)

Check whether tool_call is allowed under opts. Returns :allow, {:deny, %ExAthena.Permissions.Denial{}}, or {:halt, reason} when the can_use_tool callback requests a hard stop.

plan_mode_tools()

@spec plan_mode_tools() :: [String.t()]

Tools to advertise to the model during read-only planning/scope phases. Same as readonly_tools/0 plus "bash", which is conditionally allowed in :plan (read-only commands only — see check_phase/3).

Hosts that build their plan-mode tool list from this stay in sync with the permissions layer automatically when ex_athena widens or narrows what's safe in plan phase.

readonly_tools()

@spec readonly_tools() :: [String.t()]

Static list of read-only tool names the :plan phase permits.