defmodule Pixir.Permissions do
@moduledoc """
Permission policy (ADR 0006). Pure decision function — the Executor consults it and,
for `:ask`, calls a front-end-supplied *asker*.
Modes:
* `:auto` (default) — everything is allowed; no prompts. The blessed common path.
* `:ask` — only genuinely risky operations prompt. `read` never asks; `bash`
commands on a conservative safe-list auto-run; `write` and non-safe `bash` ask.
* `:read_only` — mutating tools are denied; reads and safe commands run.
Workspace confinement is enforced elsewhere (the tools) and is the real floor in every
mode — this layer is a convenience gate on top.
"""
@type mode :: :auto | :ask | :read_only
@type decision :: :allow | :deny | {:ask, String.t()}
# Read-only shell commands that are safe to auto-run even under :ask. A command is
# only safe if its first token is here AND it has no chaining/redirection/substitution.
@safe_commands ~w(ls cat pwd echo grep rg find head tail wc which whoami date env true
git ripgrep tree stat file dirname basename realpath sort uniq diff)
@shell_metachars ["&&", "||", ";", "|", ">", "<", "`", "$(", ">>"]
# Subcommands that make otherwise-safe binaries (e.g. git) mutating.
@unsafe_git_subcommands ~w(push commit merge rebase reset checkout clean rm mv add tag fetch pull)
@doc "All valid permission modes."
@spec modes() :: [mode()]
def modes, do: [:auto, :ask, :read_only]
@doc "Decide whether a tool call may run under `mode`."
@spec decide(mode(), String.t(), map()) :: decision()
def decide(:auto, _tool, _args), do: :allow
def decide(:read_only, tool, args) do
if mutating?(tool, args), do: :deny, else: :allow
end
def decide(:ask, tool, args) do
if mutating?(tool, args), do: {:ask, reason(tool)}, else: :allow
end
@doc "Whether a tool call mutates state (and so is gated outside `:auto`)."
@spec mutating?(String.t(), map()) :: boolean()
def mutating?("read", _args), do: false
def mutating?("skills_list", _args), do: false
def mutating?("skill_view", _args), do: false
def mutating?("wait_agent", _args), do: false
def mutating?("list_agents", _args), do: false
def mutating?("run_workflow", _args), do: true
def mutating?("spawn_agent", _args), do: true
def mutating?("send_input", _args), do: true
def mutating?("close_agent", _args), do: true
# `update_plan` only publishes an ephemeral plan Event (no files, no commands),
# so it is allowed even in `:read_only`/plan mode — it IS plan mode's tool.
def mutating?("update_plan", _args), do: false
def mutating?("write", _args), do: true
def mutating?("bash", %{"command" => command}), do: not safe_command?(command)
def mutating?(_tool, _args), do: true
@doc """
Whether a shell command is read-only and safe to auto-run: first token on the
safe-list, no shell metacharacters, and no mutating git subcommand.
"""
@spec safe_command?(String.t()) :: boolean()
def safe_command?(command) when is_binary(command) do
trimmed = String.trim(command)
tokens = String.split(trimmed, ~r/\s+/, trim: true)
with [first | rest] <- tokens,
true <- first in @safe_commands,
false <- Enum.any?(@shell_metachars, &String.contains?(trimmed, &1)),
true <- git_safe?(first, rest) do
true
else
_ -> false
end
end
def safe_command?(_command), do: false
# ── internals ─────────────────────────────────────────────────────────────
defp git_safe?("git", [sub | _]), do: sub not in @unsafe_git_subcommands
defp git_safe?("git", []), do: true
defp git_safe?(_first, _rest), do: true
defp reason("write"), do: "write a file"
defp reason("bash"), do: "run a shell command"
defp reason("spawn_agent"), do: "spawn a subagent"
defp reason("send_input"), do: "send input to a subagent"
defp reason("close_agent"), do: "close a subagent"
defp reason("run_workflow"), do: "run a workflow"
defp reason(tool), do: "run #{tool}"
end