Letting Agents Debug Production
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,
remoteorrpcgive 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:
- 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.
- Its own credential. One key per agent, revocable on its own, never the cookie.
- Audit everything. Every request, allowed or refused, is logged before it runs.
- Bound every command. Fixed result sizes, timeouts, no unbounded traces or full-state dumps.
- Assume what it reads is hostile and sensitive. Output can contain injected instructions and personal data; return summaries, not raw state, and redact secrets.
- Keep it off the internet. Bind it to an internal address, or reach it
through
kubectl port-forwardor 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.
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
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
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.
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
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.)
# 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.
wherehere 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=nois 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.