MAVLink Policy Authoring¶
How to write a fail-closed Rego policy for the tool.mavlink.* command surface.
The runtime evaluates data.autonomy.allow for every command; author rules that
allow only what you intend and let the default allow := false deny the rest.
For the runtime trust model and operator entry points, see MAVLink Governance.
The input shape¶
The policy input is {"kind": "<tool.mavlink.*>", "params": {...}}. The params
are split by source, and that split is the foundation of the trust model:
Supervisor-injected (trusted). The runtime injects these from the live autopilot snapshot before evaluation; the agent cannot supply them (a request that does is rejected before policy runs). Query them freely — they are observed truth, not agent claims:
environment("sitl"/"real"),armed_state,gps_fix,heartbeat_age_ms,sysid,current_mode.Agent intent (caller-supplied). Validate these defensively:
altitude,lat,lon,alt,custom_mode,mission_hash,operator_approval,command,param1…param7,compid,channels.
Kind |
Trusted (query) |
Intent (validate) |
|---|---|---|
|
|
|
|
|
|
|
|
|
|
|
|
|
— |
|
|
— |
|
|
— |
|
Fail-closed scaffolding¶
Start from default allow := false and validate the command’s own schema,
not just its safety condition — otherwise a missing or malformed field can slip
through a negative check. Mirror the typed transport contract:
package autonomy
import rego.v1
default allow := false
# Gate on the supervisor-injected environment — trustworthy, not an agent claim.
allow if {
input.kind == "tool.mavlink.arm"
input.params.environment == "sitl"
}
allow if {
input.kind == "tool.mavlink.arm"
input.params.environment == "real"
input.params.operator_approval != ""
}
# Validate the schema, THEN the ceiling. is_number rejects missing/string input.
allow if {
input.kind == "tool.mavlink.takeoff"
is_number(input.params.altitude)
input.params.altitude >= 0
input.params.altitude <= 50
}
The same discipline for goto (require lat/lon/alt are numbers in range
before the ceiling check) and set_mode (require custom_mode is a number in
[0, uint32max] before the mode check). Validating the schema in policy keeps
malformed input fail-closed at the policy layer, not only at the transport.
The mission-hash workflow¶
upload_mission is allowed only for a mission whose canonical-bytes BLAKE3 is in
your allowlist. The supervisor independently verifies the transmitted bytes hash
to the same value and then issues the plan over the MAVLink mission-protocol
handshake (MISSION_COUNT → MISSION_ITEM_INT … → MISSION_ACK), so an
allowlisted hash cannot be paired with different bytes.
Build the mission and compute the BLAKE3 of its canonical serialization.
Register the hex digest in the policy allowlist.
The agent sends
upload_missionwithmission(bytes) +mission_hash.
allowed_mission_hashes := {"<blake3-hex-of-canonical-mission-bytes>"}
allow if {
input.kind == "tool.mavlink.upload_mission"
allowed_mission_hashes[input.params.mission_hash]
}
Canonical mission bytes¶
mission is a JSON array of mission items — the MISSION_ITEM_INT shape, in
sequence order. Compute the mission_hash as the BLAKE3 of these exact bytes;
the supervisor parses the same verified bytes into the items it uploads.
[
{"seq":0,"frame":3,"command":22,"current":1,"autocontinue":1,"x":473977420,"y":85455940,"z":10},
{"seq":1,"frame":3,"command":16,"autocontinue":1,"x":473978000,"y":85456000,"z":20},
{"seq":2,"frame":3,"command":21,"autocontinue":1,"x":473979000,"y":85457000,"z":0}
]
Per item: command is the MAV_CMD (required, non-zero), frame the
MAV_FRAME; x/y are the integer-scaled position (latitude/longitude in
degrees × 1e7 for global frames) and z the altitude in meters; param1–
param4 carry the command parameters. seq is optional but, when present, must
equal the item’s array index. Unknown fields, an empty list, or a malformed item
are rejected fail-closed before any frame is sent.
Operator-approval tokens¶
For commands that demand dual control (real-vehicle arm, manual mode
transitions), require a non-empty operator_approval:
allow if {
input.kind == "tool.mavlink.set_mode"
requires_approval[input.params.custom_mode]
input.params.operator_approval != ""
}
The demo treats operator_approval as an opaque non-empty token. For
production, verify a signed token here (the value the agent supplies is intent —
validate it as you would any agent input).
Mode-transition matrix template¶
Express which modes need approval as a set keyed on the autopilot’s numeric
custom_mode. The demo uses ArduCopter values (STABILIZE=0, ACRO=1 —
manual control); adapt to your autopilot:
# Modes requiring operator approval (autopilot-specific custom_mode values).
requires_approval := {0, 1}
# Non-approval modes pass once the schema is valid.
allow if {
input.kind == "tool.mavlink.set_mode"
is_number(input.params.custom_mode)
not requires_approval[input.params.custom_mode]
}
The privilege boundary¶
command_long and rc_override are raw command authority. The demo policy has
no allow rule for them — they are denied by the fail-closed default, the
negative example operators learn from. If a production policy must allow them,
gate them behind operator_approval and grant the supervisor raw-command
authority explicitly (it denies them by default regardless of policy). Treat
this as the most dangerous escape hatch on the surface.
Installed: the embedded MAVLink demo policy is the default for
autonomy demo mavlink-sitl and autonomy run --mavlink-endpoint …; point
autonomy run --policy <oci-ref> at your own bundle in the managed cache. The
canonical demo source is
demo/policies/mavlink/mavlink.rego
and its allow/deny matrix is pinned by the OPA tests in policy/.