MAVLink Governance¶
ADK governs MAVLink vehicle command authority the way runtime/ros2
governs robot-middleware actions: every command an agent wants to issue
(arm, takeoff, goto, set_mode, …) is evaluated by policy before the
runtime issues it on the autopilot link.
The framing: ROS 2 governs robot-middleware actions; MAVLink governs vehicle command authority.
The trust model (read this first)¶
The MAVLink transport — the TCP/UDP/serial link to the autopilot — is owned by
the runtime-side supervisor, never by the agent. The agent sends intent
(POST /v1/tool with a tool.mavlink.* kind and verb-specific fields). The
supervisor:
owns the connection and tracks autopilot state from heartbeats,
snapshots that trusted state at request time, and
after policy allows — and an engineer-edited safety floor passes — issues the command on the same trusted connection.
Because the agent has no path to open a connection or to supply the trusted fields, three properties are structural, not advisory:
SITL-vs-real binding. The
environment(sitl/real) is set by the operator at supervisor construction and is immutable for its lifetime; every snapshot returns it verbatim. An agent can never claimsitlon a real vehicle.Armed / GPS preconditions. Arm requires a fresh heartbeat and a 3D GPS fix; takeoff requires an autopilot-confirmed armed state.
Mission integrity.
upload_missionis bound to amission_hashthe supervisor verifies against the transmitted bytes.
The trusted fields the runtime injects (and forbids callers from supplying)
are: environment, armed_state, gps_fix, heartbeat_age_ms, sysid,
current_mode. A request that carries any of these is rejected with a
governance-protocol error before policy evaluation — the agent must send intent
only.
Two operator entry points (siblings, not nestable)¶
There are exactly two ways to reach a MAVLink-governed runtime. Both create a
single trusted runtime that owns the autopilot link and spawns the agent
directly. Do not nest them (e.g. autonomy run … autonomy demo …): the
inner autonomy run would start its own, unsupervised runtime and the agent
would fail closed with ErrMavlinkNotConfigured.
1. Bundled SITL demo¶
Bring up a SITL autopilot, then run the governed demo:
docker compose -f demo/docker-compose.mavlink.yml up -d
autonomy demo mavlink-sitl
Installed: the demo policy and agent are embedded in the binary —
autonomy demo mavlink-sitl needs no source clone (the SITL stack is the only
prerequisite). The in-repo convenience target make demo-mavlink-sitl brings up
the compose stack and runs the same command.
The agent (examples/mavlink_agent.py
— stdlib only, no pymavlink) issues five governed calls: arm, takeoff,
in-fence goto → ALLOW; out-of-fence goto → DENY (geofence); command_long →
DENY (privileged). Every decision is written to the local WAL.
2. Bring-your-own agent (production)¶
For your own agent, register a supervisor on autonomy run’s own runtime:
autonomy run \
--mavlink-endpoint tcp:127.0.0.1:5760 \
--mavlink-environment sitl \
python3 my_agent.py
--mavlink-endpoint— transport URL (tcp:,udp:,udpin:,serial:). Absent ⇒tool.mavlink.*is fail-closed (ErrMavlinkNotConfigured).--mavlink-environment—sitl|real, required when an endpoint is set; immutable for the run; passed as a flag (auditable in shell history) not an env var.
With no --policy, this defaults to the embedded MAVLink demo policy (which
governs tool.mavlink.*); pass --policy <oci-ref> for production rules. The
flags are not supported with ros2.launch (run MAVLink governance separately).
Tool kinds¶
The typed command surface. Each is a POST /v1/tool body of the form
{"kind": "<kind>", "params": {<intent>}} — intent fields only; the supervisor
injects the trusted fields.
Kind |
Intent |
Notes |
|---|---|---|
|
— |
Arm motors. Floor: fresh heartbeat + 3D fix. |
|
— |
Disarm motors. |
|
|
Floor: autopilot-confirmed armed. |
|
— |
Land. |
|
|
Mode transition. |
|
|
Guided-mode target. |
|
|
Floor: BLAKE3( |
|
— |
Clear the onboard mission. |
|
|
Parameter mutation. |
|
|
Privileged raw |
|
|
Privileged raw RC override. |
Example (the agent sends intent only):
curl -s "$AUTONOMY_RUNTIME_URL/v1/tool" -H 'content-type: application/json' \
-d '{"kind":"tool.mavlink.goto","params":{"lat":0.0005,"lon":0.0005,"alt":50}}'
Unlisted kinds under the tool.mavlink.* prefix (a future or misspelled kind)
are rejected before dispatch — the surface is a closed, typed set, not an open
prefix.
Note
upload_mission is issued via the multi-message MAVLink mission-protocol
handshake: the supervisor announces MISSION_COUNT, answers each
MISSION_REQUEST_INT with the matching MISSION_ITEM_INT, and completes on a
MISSION_ACK. mission is the canonical mission bytes (a JSON array of mission
items — see MAVLink Policy Authoring for the shape) and
mission_hash is their BLAKE3; the safety floor verifies the binding before any
item is sent, so the items that reach the autopilot are exactly the bytes the
policy authorized. A MISSION_ACK of MAV_MISSION_ACCEPTED is reported as
issued (HTTP 200); an autopilot NAK or a handshake timeout is reported as
allowed-but-not-issued (HTTP 400) — the plan was attempted but not stored.
The safety floor (engineer-edited, not policy-tunable)¶
The supervisor enforces a hard floor inside command issuance, after policy allow. Policy can add tighter restrictions but cannot lift these:
arm— heartbeat ≤ 1 s old and GPS fix ≥ 3D.takeoff— autopilot-confirmed armed within the prior 500 ms.upload_mission— transmitted bytes’ BLAKE3 matchesmission_hashbefore any mission item is sent on the handshake (see the note above).
Optional, operator-configured adapter floors (defense-in-depth, independent of
policy): a geofence (radius + ceiling on goto), a mode denylist, and
raw-command authority — command_long and rc_override are denied unless
the supervisor is explicitly granted raw authority. A floor rejection is a
fail-closed governance deny; the command is never issued.
Extending the policy¶
The demo policy is the negative example for command_long / rc_override
(denied) and the SITL-only template for arm. Author your own fail-closed
bundle for production — see MAVLink Policy Authoring.
Installed: point autonomy run --policy <oci-ref> at a bundle in the local
managed cache. The canonical demo source is
demo/policies/mavlink/mavlink.rego
(compiled into the binary as the embedded default; copy it as a starting point).
What this does not do¶
It does not certify the system for flight: the
realpath exists, but the operator owns the safety case. The floor is a floor, not a certification.It does not replace ROS 2 governance for vehicles running ROS 2 stacks — govern both
tool.ros2.*andtool.mavlink.*; they cover different layers.It does not support an agent-owned MAVLink connection for command issuance: any command must go through the supervisor.
Hardening: garble identifier obfuscation is ON by default¶
Per #1136, the release
binary ships with garble identifier obfuscation, using the ADK garble
fork at github.com/AutonomyOps/garble@v0.16.0-adk.1.
The fork adds a GOGARBLE=*,!<prefix> negation syntax so gomavlib is
preserved from renaming while the rest of the binary stays obfuscated.
The release script composes GOGARBLE from a GARBLE_SKIP_PKGS env var
(default github.com/bluenviron/gomavlib/v3). gomavlib’s dialect
message types keep their original names, its reflection-based prefix
check (if !strings.HasPrefix(name, "Message")) passes, and every
tool.mavlink.* entry point works — with the rest of ADK’s code, and
every other third-party dep, still obfuscated.
The full release hardening stack:
garble identifier obfuscation (ON — fork-scoped exclusion for gomavlib)
garble literal encoding (
-literals, ON by default; escape viaGARBLE_LITERALS=0)-trimpath(no source paths)-ldflags="-s -w"(no symbol table / no DWARF)-tags=production(opaque short codes fromruntime/internal/errtextinstead of verbose error strings)cosign signing + boot integrity check
size-budget gate
Trading garble off: GARBLE=0 disables the whole garble path — kept
as an operator opt-out for perf comparisons and quick local rebuilds,
not a supported release configuration.
Scope guarantees. Two CI smokes enforce the contract on every PR:
ci/test_mavlink_release_binary.sh— proves MAVLink init still works on the shipped binary. Catches any fork regression that re-breaks gomavlib’s reflection contract.ci/test_garble_scope.sh— proves the exclusion is scoped exactly toGARBLE_SKIP_PKGS: GOGARBLE composition matches byte-for-byte, gomavlib preserved, ADK internals obfuscated, unrelated third-party deps not spuriously excluded.
Security concession, called out. The fork uses garble’s package-level
ToObfuscate gate, which is all-or-nothing — excluded packages skip
identifier, literal, AND control-flow obfuscation. For gomavlib that
means ~500–1,500 identifier names + all in-package string literals
(protocol constants, format strings, dialect SHA fingerprints) remain
readable in the shipped binary. The disclosure is bounded to whatever’s
listed in GARBLE_SKIP_PKGS; the rest of the binary is unaffected.
Rationale: ADK’s product docs already announce MAVLink support, gomavlib
is public, and the MAVLink dialect spec is a public standard — so the
information leak is bounded to protocol shape descriptions, not to
proprietary logic. See #1136
for the full analysis and the trigger conditions for revisiting.