Skip to main content

lib/pixir/permissions.ex

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