Serial devices
Give an agent access to a USB serial device attached to your machine — an ESP32 or Arduino dev board, a UART adapter, a modem — without giving it the rest of your hardware.
The device broker runs inside the proxy daemon, which outlives the CLI. If you just installed or upgraded moat, restart it once so the running daemon is the new binary:
$ moat proxy restart
A run whose moat.yaml has devices: fails immediately — before the container is
created — if the daemon is too old to serve them.
1. Find the device
$ moat device list
DEVICE USB ID IFACE SERIAL NUMBER PIN DESCRIPTION
/dev/cu.usbserial-14220 10c4:ea60 - 0001 - CP2102 USB to UART Bridge
Add to moat.yaml:
devices:
- name: cp2102-usb-to-uart-bridge
match: {usb: "10c4:ea60"}
The run gets MOAT_SERIAL_CP2102_USB_TO_UART_BRIDGE_URL.
The first run pins this hardware to that name, shown in PIN.
Pick whatever name you want — it is the name: key, and it determines the
environment variable. The suggestion is derived from the USB description, and for a
device that is already pinned it is the name that pin uses.
2. Declare it in moat.yaml
name: board-dev
dependencies:
- python
devices:
- name: board
match: {usb: "10c4:ea60"}
3. Use it
Each device is exposed as an RFC2217 URL in MOAT_SERIAL_<NAME>_URL. Pass the URL to
whatever tool would have taken the device path:
$ moat run -- sh -c 'esptool --port "$MOAT_SERIAL_BOARD_URL" chip-id'
The command can also live in moat.yaml. command: is an argv list, not a shell string —
$MOAT_SERIAL_BOARD_URL does not expand in it — so reference the env var from a script
the command runs:
command: ["sh", "/workspace/verify.sh"]
MOAT_SERIAL_DEVICES lists the names of all devices available to the run.
Why a URL and not /dev/ttyUSB0
Flashing a board is not a byte stream. esptool drives the DTR and RTS control lines to
put an ESP32 into its bootloader, and changes the baud rate mid-session.
A pseudo-terminal cannot carry either signal — TIOCMGET on a pty returns ENOTTY,
because a pty has no modem control lines at all. A device path inside the container would
enumerate correctly and then fail to flash, which looks like broken hardware rather than a
missing feature.
RFC2217 is the standard solution: it carries baud rate, DTR, RTS, and break over TCP, and
reports the modem status lines (CTS, DSR, RI, CD) to a client that asks.
Espressif documents it
as supporting DTR/RTS auto-reset “the same as for a local serial port,” and recommends it
for remote serial. Any pyserial-based tool accepts an rfc2217:// URL in place of a port.
One message to expect rather than fix: esptool prints Failed to get VID/PID of a device on rfc2217://... on each connect. It asks the port for its USB IDs to pick a
reset strategy; an rfc2217:// URL has no USB identity by construction — the device
lives on the host, not in the container — so esptool falls back to the standard UART
reset sequence, which is the one that works over RFC2217. The message repeats because
esptool caches the answer only on success. It is informational, so the
serial example
filters it from its demo output; no esptool flag suppresses it (checked against 5.4.0:
--before does not skip the lookup, and the custom_reset_sequence config option
covers only the two lookups in reset-strategy selection, not the four during chip
detection and chip-info printing).
Approval and pinning
Nothing is exposed unless moat.yaml asks for it.
The match block selects a device by USB vendor and product ID, which identifies the
model, not the unit. The first run to use a device name records that specific device’s
serial number. Every later run must present the same device:
$ moat run -- esptool chip-id
cannot use the serial devices this run requires:
board: serial device does not match its pin: "board" was pinned to serial 0001
but the attached device reports 0002
If you intended to swap devices, run: moat device forget board
Error: creating run: cannot use the serial devices this run requires:
board: serial device does not match its pin: "board" was pinned to serial 0001
but the attached device reports 0002
If you intended to swap devices, run: moat device forget board
The first block is the pre-flight check, which reports every requested device before anything is built. The run then fails for the same reason when it reaches container creation, which is the gate that actually refuses it.
This is a hard failure, not a warning. Two boards of the same model are indistinguishable by USB ID, so without pinning an agent could flash the wrong one.
Before anything is created, the first run also shows what it is about to approve:
⚠ Approving serial device for the first run:
board 303a:1001 (serial 0001)
This is trust-on-first-use: the run gets full control of the hardware —
it can read, reflash, or brick the device. Later runs must present the same
device or fail until you run `moat device forget`.
A device with no serial number says so plainly, because the pin that gets recorded approves whatever is plugged into that port, not that unit:
⚠ Approving serial device for the first run:
board 1a86:7523 (no serial number — pins whatever is plugged into port 1-3, not this unit)
After deliberately swapping hardware:
$ moat device forget board
If you change a device’s name: in moat.yaml instead, move the pin with it — otherwise the
new name is unpinned and the next run re-approves the same board under it:
$ moat device rename board esp32
Devices without a serial number
Cheap CH340 and CP2102 clones often ship without a serial number. Those are pinned to the
physical USB port instead, and moat device list says so:
DEVICE USB ID IFACE SERIAL NUMBER PIN DESCRIPTION
/dev/ttyUSB0 1a86:7523 - - (pins by port 1-3) - USB Serial
A port pin approves whatever is plugged into that port, so moving the device to another
port fails until you either move it back or run moat device forget.
Multi-UART bridges
Some debug bridges expose two or more UARTs over one USB device — an FT2232H (common on
ESP-Prog boards), a CP2105, or an FT4232H. Their ports share everything: USB ID, serial
number, physical port. moat device list shows one row per port, distinguished by the
IFACE column:
DEVICE USB ID IFACE SERIAL NUMBER PIN DESCRIPTION
/dev/ttyUSB0 0403:6010 0 FT7ABCDE jtag Dual RS232-HS
/dev/ttyUSB1 0403:6010 1 FT7ABCDE uart Dual RS232-HS
Declare each port as its own device, selecting it by interface number:
devices:
- name: jtag
match: {usb: "0403:6010", interface: "0"}
- name: uart
match: {usb: "0403:6010", interface: "1"}
Each name is pinned independently — port 0 on jtag, port 1 on uart — so the two ports
can be given to different runs. Without a selector, a bridge’s ports are ambiguous and the
run fails with a message showing this config.
Devices that expose a single UART show - in the IFACE column and need no selector.
What an agent can do with a serial device
An agent with a serial line can reflash the device, and can therefore brick or reprogram it. That is inherent to the request — flashing is the point.
The mitigation is consent at the device level: you choose which device, and moat enforces that it stays the same device — checked when the run starts, before the container is created — and the first run states what it is approving and what the approval grants (see Approval and pinning). Sandboxing does not help here, and moat does not pretend otherwise.
Identity is not re-checked mid-run: the check happens once at start, against the
devices attached at that moment. Unplugging the pinned board mid-run ends the session
(the failed read tears it down and releases the claim), and replugging the same board
lets a new session open it. But if a different board takes over the same device node
while the run is active (Linux reuses /dev/ttyUSB0 freely), the next connection opens
it — the pin holds between runs, not within one.
Two further boundaries are worth stating plainly:
-
Only ttys are exposed. The broker refuses to open anything that is not a tty character device, so storage, HID, and smartcard devices cannot be reached through this mechanism at all.
-
The RFC2217 port is not exposed on every interface. The protocol has no authentication, so access is scoped by bind address: each device’s listener binds the host address the run’s container uses (the default bridge gateway on Docker on Linux, host loopback on Docker Desktop for macOS/Windows — where the container’s URL names
host.docker.internal, which forwards to that loopback — and the default network gateway on Apple containers), not every interface.Bind address is a narrowing, not an off-host boundary. On Linux the bound address is a local address, and the kernel accepts packets for any local address arriving on any interface, so a sender that can get a packet routed to this host — one on the same network segment, for instance — reaches the listener. Moat installs no host firewall rule. On an untrusted network, add one yourself (for example an
INPUTrule dropping traffic to the listener’s port from anything but the container subnet), or do not attach devices there.Processes running on this machine — and any container on it, not just the owning run — can still connect to it, and the broker serves one connection at a time, so a competing local connection can hold the device but cannot interleave bytes with the run’s session.
A connection that holds the port without doing anything is dropped after 30 seconds. A client that negotiates, sends a com-port command, or writes to the device is exempt from then on and may sit quiet for as long as it likes — but a monitor that only reads (
nc host portwatching boot logs) never proves itself and is dropped on that budget. Use an RFC2217 client, which negotiates on connect.
One run at a time
A device is claimed exclusively while a run holds it. A second run that wants the same device fails rather than interleaving bytes on the same line:
Error: creating run: registering run with proxy daemon: daemon returned 409: serial device "board" (/dev/ttyUSB0) is already in use by run run_015e8e26...
The claim is released when the run stops — including when the run dies without unregistering (a killed CLI, a crashed machine): the daemon’s liveness checker reaps the dead container and releases its devices.
Daemon restarts
The MOAT_SERIAL_*_URL a container gets is fixed when the container is created, so a
proxy daemon restart while a run is active re-opens each device’s listener on the same
port. The run’s device keeps working across moat proxy restart and across daemon
crashes — the run manager re-registers the run and its pinned ports.
If a restart lands and one of those ports is now occupied by something else, the daemon
refuses the re-registration and the run is skipped on restore rather than given a device
that silently does not work. Stop whatever holds the port, or moat stop and re-run.
Interaction with network.policy: strict
A device connection is raw TCP to the RFC2217 listener, not HTTP through the proxy, so
under strict policy the device’s port is added to the firewall allowlist automatically
alongside the proxy port. A strict run with devices: works without further
configuration — the ports are OS-assigned per device, so there is nothing to list in
network.host. Entries you list there yourself (network.host: [8080], say) stay
proxy-mediated exactly as before — they are not added to the firewall’s direct-egress
allowlist, which carries only the device listeners and is scoped to the address those
listeners bind.
Observability
Attach, detach, line-setting changes, DTR/RTS transitions, and byte counters are recorded
for every session in the run’s devices.jsonl, with attach/detach/error/conflict also in
the tamper-evident audit chain (moat audit <run-id>). Set record: full to capture the
payload bytes too, in serial-<name>.capture beside the other run artifacts:
devices:
- name: board
match: {usb: "10c4:ea60"}
record: full
Full capture is opt-in because serial traffic carries firmware images and device credentials. A run configured for full capture whose capture file cannot be opened — disk full, permissions — degrades to events and records an error saying so; the session itself keeps running.
Devices that are not serial at all
USB hardware with no serial interface never appears in the devices: list. This is not
moat failing to detect it — an SDR dongle (RTL2832U), a keyboard, or a USB drive has no
tty and no CDC class, so there is nothing for a serial broker to serve. moat device list shows such devices in a separate “Other USB devices” section so a plugged-in
device is visible.
Reaching that hardware from a container is a networking question, not a devices: one,
and the answer depends on the device. Some streaming hardware has host-side server
software a container can consume over the network; most USB peripherals have no such
equivalent. Whether a container can open that connection depends on the run’s
network policy — a permissive run reaches host ports directly,
and a strict run’s firewall allows only the proxy and the run’s own device listeners.
Platform support
Serial devices work on Linux and macOS, with Docker and with Apple containers. Nothing here requires privileged mode, and it does not disable the gVisor sandbox, because no device is passed into the container — the broker holds the device on the host and speaks TCP to the container.
General USB passthrough is not supported and is not planned: gVisor does not forward host devices, Apple’s container runtime has no USB passthrough, and the Linux devices cgroup cannot filter on USB identity, so an allowlist could not be enforced.