Elixir by Example

Letting Agents Debug Production

Every command ran when the site was built; the output is real Download project

Coding agents such as Claude Code and Codex are good at the first hour of an incident: reading stack traces, comparing state with code, forming and checking hypotheses. Giving one access to a production node is tempting. The obvious ways to do it are the dangerous ones:

  • The cookie, remote or rpc give full control of the node: they run any code, including code that deletes data or leaks secrets. An agent can make mistakes, and it can be steered by text it reads (a log line or a database row written by an attacker can contain instructions: prompt injection). Whatever it can do, an attacker who controls its input can try to make it do.
  • A shared admin login makes every action unattributable and impossible to revoke for one agent alone.

So the agent gets its own narrow door, designed like any other API:

  1. Observe, don't act. Only read-only commands you wrote and reviewed: queues, memory, what a process is doing, bounded traces. Fixes go through the normal pipeline (pull request, CI, deploy), and emergency changes are made by a human.
  2. Its own credential. One key per agent, revocable on its own, never the cookie.
  3. Audit everything. Every request, allowed or refused, is logged before it runs.
  4. Bound every command. Fixed result sizes, timeouts, no unbounded traces or full-state dumps.
  5. Assume what it reads is hostile and sensitive. Output can contain injected instructions and personal data; return summaries, not raw state, and redact secrets.
  6. Keep it off the internet. Bind it to an internal address, or reach it through kubectl port-forward or a bastion.

This page builds such a door with OTP's own :ssh, inside the release: an SSH daemon with no shell that runs only an allowlist of commands for the keys it trusts. Then it plays out an incident: a worker is stuck, and the agent finds where, without being able to change anything.

The project

An orders app with one worker that charges orders one at a time. Today its payment provider hangs.

mix.exs
defmodule Orders.MixProject do
  use Mix.Project

  def project do
    [app: :orders, version: "0.1.0", elixir: "~> 1.20", deps: []]
  end

  def application do
    # :ssh ships with Erlang/OTP; the release must include it.
    [mod: {Orders.Application, []}, extra_applications: [:logger, :ssh]]
  end
end
lib/orders/application.ex
defmodule Orders.Application do
  use Application

  @impl true
  def start(_type, _args) do
    children = [Orders.Worker, Orders.AgentAccess]
    Supervisor.start_link(children, strategy: :one_for_one, name: Orders.Supervisor)
  end
end
lib/orders/worker.ex
defmodule Orders.Worker do
  @moduledoc """
  Charges orders one at a time. Today the payment provider hangs, so the first
  charge never returns and every later order waits in the mailbox.
  """
  use GenServer

  def start_link(_opts), do: GenServer.start_link(__MODULE__, nil, name: __MODULE__)

  def charge(order_id), do: GenServer.cast(__MODULE__, {:charge, order_id})

  @impl true
  def init(nil), do: {:ok, nil}

  @impl true
  def handle_cast({:charge, order_id}, state) do
    :ok = call_payment_provider(order_id)
    {:noreply, state}
  end

  # Stands in for an HTTP call without a timeout to a provider that never answers.
  defp call_payment_provider(_order_id) do
    receive do
      :never_sent -> :ok
    end
  end
end

The agent's door. exec: {:direct, fun} sends every command an SSH client runs to our function, together with the authenticated user name, and shell: :disabled means there is no interactive shell to fall back on. Commands outside the allowlist get an error.

lib/orders/agent_access.ex
defmodule Orders.AgentAccess do
  @moduledoc """
  A narrow, read-only window into the running node for coding agents.

  An SSH daemon (OTP's own `:ssh`) accepts only the keys listed in
  `authorized_keys`, offers no shell, and runs only the commands below. Every
  request, allowed or not, is written to an audit log first.
  """

  @commands %{
    "queues" => "processes with messages waiting, longest queue first (top 3)",
    "where NAME" => "what a registered process is doing (its stack)",
    "help" => "this list"
  }

  def child_spec(_opts), do: %{id: __MODULE__, start: {__MODULE__, :start_link, []}}

  def start_link do
    config = Application.fetch_env!(:orders, :agent_access)

    :ssh.daemon(config[:port],
      system_dir: String.to_charlist(config[:host_keys]),
      user_dir: String.to_charlist(config[:authorized_keys]),
      auth_methods: ~c"publickey",
      shell: :disabled,
      exec: {:direct, fn command, user -> run(to_string(command), to_string(user)) end}
    )
  end

  defp run(command, user) do
    File.write!(audit_log(), "#{user}: #{command}\n", [:append])

    result =
      case String.split(command) do
        ["queues"] -> {:ok, queues()}
        ["where", name] -> where(name)
        ["help"] -> {:ok, Enum.map_join(@commands, "\n", fn {cmd, what} -> "#{cmd}: #{what}" end)}
        _ -> {:error, "not allowed: #{command} (try: help)"}
      end

    # The SSH client prints the text as is; end it with a newline.
    case result do
      {:ok, text} -> {:ok, text <> "\n"}
      {:error, text} -> {:error, text <> "\n"}
    end
  end

  defp queues do
    Process.list()
    |> Enum.map(&{&1, Process.info(&1, [:registered_name, :message_queue_len])})
    |> Enum.filter(fn {_pid, info} -> info != nil and info[:message_queue_len] > 0 end)
    |> Enum.sort_by(fn {_pid, info} -> -info[:message_queue_len] end)
    |> Enum.take(3)
    |> Enum.map_join("\n", fn {pid, info} -> "#{label(pid, info)}: #{info[:message_queue_len]}" end)
  end

  # Only names that already exist are accepted: String.to_existing_atom/1
  # never creates atoms from untrusted input.
  defp where(name) do
    with {:ok, atom} <- existing_atom(name),
         pid when is_pid(pid) <- Process.whereis(atom),
         {:current_stacktrace, stack} <- Process.info(pid, :current_stacktrace) do
      {:ok, stack |> Enum.take(3) |> Enum.map_join("\n", &Exception.format_stacktrace_entry/1)}
    else
      _ -> {:error, "no process named #{name}"}
    end
  end

  defp existing_atom(name) do
    {:ok, String.to_existing_atom("Elixir." <> name)}
  rescue
    ArgumentError -> :error
  end

  defp label(pid, info) do
    case info[:registered_name] do
      [] -> inspect(pid)
      name -> inspect(name)
    end
  end

  defp audit_log, do: Application.fetch_env!(:orders, :agent_access)[:audit_log]
end
config/runtime.exs
import Config

# Where the agent-access SSH daemon listens and keeps its keys. Bind it to an
# internal address only; never expose it to the internet.
config :orders, :agent_access,
  port: String.to_integer(System.get_env("AGENT_SSH_PORT", "4022")),
  host_keys: System.fetch_env!("AGENT_SSH_HOST_KEYS"),
  authorized_keys: System.fetch_env!("AGENT_SSH_AUTHORIZED_KEYS"),
  audit_log: System.fetch_env!("AGENT_AUDIT_LOG")

Set it up

Build the release, then create keys: a host key for the node, so the agent can verify it's talking to the real thing, and a key pair for the agent, whose public half goes into authorized_keys. The agent pins the node's host key in its own known_hosts.

$ MIX_ENV=prod mix release --quiet
$ mkdir -p ssh/host ssh/agent log
$ ssh-keygen -q -t ed25519 -N "" -f ssh/host/ssh_host_ed25519_key
$ ssh-keygen -q -t ed25519 -N "" -C agent -f agent_key
$ cp agent_key.pub ssh/agent/authorized_keys
$ echo "[127.0.0.1]:47022 $(cat ssh/host/ssh_host_ed25519_key.pub)" > known_hosts
Compiling 3 files (.ex)
Generated orders app

Start the node, and wait until the orders application (and with it the SSH daemon) has started. agent is the command the coding agent is allowed to run: plain ssh with its own key and the pinned host key.

$ export RELEASE_NODE=orders_demo AGENT_SSH_PORT=47022
$ export AGENT_SSH_HOST_KEYS="$PWD/ssh/host" AGENT_SSH_AUTHORIZED_KEYS="$PWD/ssh/agent"
$ export AGENT_AUDIT_LOG="$PWD/log/audit.log"
$ orders() { _build/prod/rel/orders/bin/orders "$@"; }
$ orders daemon
$ until orders rpc 'true = List.keymember?(Application.started_applications(), :orders, 0)' > /dev/null 2>&1; do
  sleep 0.1
done

$ agent() { ssh -q -p 47022 -i agent_key -o UserKnownHostsFile=known_hosts agent@127.0.0.1 "$@"; }

The incident

100 orders arrive while the payment provider hangs. (An operator runs this with full access; the agent never could.)

incident.exs
# Simulates the incident: 100 orders arrive while the payment provider hangs.
for id <- 1..100, do: Orders.Worker.charge(id)

# Wait until the first order is stuck and the other 99 are queued behind it.
worker = Process.whereis(Orders.Worker)

wait = fn again ->
  if Process.info(worker, :message_queue_len) == {:message_queue_len, 99},
    do: :ok,
    else: Process.sleep(10) && again.(again)
end

:ok = wait.(wait)
$ orders rpc 'Code.eval_file("incident.exs")'

The agent investigates

It starts with what it may do, then looks for a backlog, then asks the busy process what it's doing.

$ agent help
$ agent queues
$ agent where Orders.Worker
help: this list
queues: processes with messages waiting, longest queue first (top 3)
where NAME: what a registered process is doing (its stack)
Orders.Worker: 99
(orders 0.1.0) lib/orders/worker.ex:22: Orders.Worker.call_payment_provider/1
(orders 0.1.0) lib/orders/worker.ex:17: Orders.Worker.handle_cast/2
(stdlib 8.1.1) gen_server.erl:2460: :gen_server.try_handle_cast/3

The answer is complete enough to act on: one order is stuck inside call_payment_provider/1, which has no timeout, and 99 more wait behind it. The agent can now propose the fix (a timeout on the provider call) as a pull request.

What it can't do is anything else, such as charging an order itself:

$ agent 'Orders.Worker.charge(101)'
**Error** not allowed: Orders.Worker.charge(101) (try: help)

Every request is in the audit log, the refused one included.

$ cat log/audit.log
agent: help
agent: queues
agent: where Orders.Worker
agent: Orders.Worker.charge(101)

Stop the node, and wait until it's gone (it frees its SSH port on the way out).

$ orders stop
$ while orders pid > /dev/null 2>&1; do sleep 0.1; done

Pitfall: an allowlisted command is still code running in production. where here only accepts names of existing processes (String.to_existing_atom/1), so the agent can't make the node create atoms, and every command returns a bounded amount of text. Review new commands as carefully as a public API.

Pitfall: don't add a generic "eval" command, even read-only-looking ones like "inspect any process state". State holds secrets and personal data, and an eval is the cookie by another name.

Pitfall: ssh -o StrictHostKeyChecking=no is the usual shortcut in scripts, and it lets anyone who can intercept the connection impersonate the node. Pin the host key, as above.

Pitfall: the same rules apply to MCP servers and other agent tools that wrap production access: a tool that can run arbitrary commands is the cookie, whatever its name.

Go deeper: chapter 17 of The BEAM Book by Erik Stenman, "Operation and Maintenance", covers the ways to connect to a running system.