Ship an extension.
The toolchain, the signing chain and the capability enforcement all ship today. The hosted registry that would accept a third-party submission is not open yet, so publishing right now means distributing your own signed archive and enrolling your own key.
START
Scaffold, then develop in the workspace
The SDK is not on the public npm registry yet, so the supported workflow is to clone the extensions monorepo and build your extension inside it as a workspace package. That also gets you the lint rules and the pack and sign scripts.
$git clone https://github.com/altnautica/ADOSExtensions
cd ADOSExtensions && pnpm install
# Scaffold: gcs-only, agent-only or hybrid
node packages/create-ados-plugin/bin/cli.mjs
# Check your manifest against the code it describes
node scripts/lint-manifest.mjs extensions/<your-extension>/manifest.yamlThe manifest linter is the unusual one. It fails on a declared telemetry topic your code never subscribes to, a host-surface permission nothing calls, a contributed slot with no implementation, and a version that disagrees across the manifest and the package files. A decorative permission is a build failure, not a style note.
THE MANIFEST
manifest.yaml, validated strictly
One schema is the source of the JSON Schema the SDK ships and the reference the docs render, so there is one definition of a valid extension rather than three.
- id
- reverse DNS, com.you.thing
- version
- strict semver
- name
- required
- compatibility.ados_version
- required range
- agent or gcs
- at least one
- risk
- low, medium, high, critical
- unknown keys
- rejected
id: com.example-oem.lidar-bridge
version: 0.1.0
name: LiDAR Bridge
license: GPL-3.0-or-later
risk: medium
compatibility:
ados_version: ">=0.99.0"
agent:
entrypoint: lidar_bridge.plugin:LidarBridge
runtime: python # or rust
isolation: subprocess # or inprocess, first-party signers only
permissions:
- hardware.uart
- sensor.lidar.register
- event.publish
gcs:
entrypoint: gcs/plugin.bundle.js
isolation: iframe # or worker, or inline
permissions:
- telemetry.subscribe
- ui.slot.map-overlayUnknown top-level keys are rejected outright, so a typo in a permission block fails at parse instead of silently doing nothing. An id has to be reverse-DNS with at least two labels, which rules out squatting on a bare word. A documentation URL has to be https. If you declare that your agent half carries a pre-compiled vendor binary you must also supply the attribution rows, and if you supply attribution rows without the flag that is an error too, in both directions.
CAPABILITIES
Ask for what you need, and expect it to be enforced
51 capabilities on the agent side and 23 on the Mission Control side. They are not a taxonomy for the install dialog; most of them are wired into a mechanism that stops the code doing the thing.
GENERATED DISPATCH GATE
A table maps every plugin RPC method to the capability it requires, and it is code-generated into both the Rust host and the Python runtime. Calling mavlink.send without mavlink.write is refused at the wire, not inside a handler.
SYSTEMD SANDBOX
Nine capabilities are enforced by the generated unit file: host filesystem, outbound network, UART, I2C, SPI, GPIO, USB, USB UVC and CSI camera. Grant none of the device ones and the unit gets private devices; grant one and it gets a closed device policy plus exactly that allow rule.
HANDLER GATES
Five capabilities are checked inline where the effect happens, because the effect is not a single method call: pose injection, event publish and subscribe, the MAVLink VIO component, and exposing your own tools to the MCP surface.
Eight agent capabilities are still advisory metadata rather than a gate: the camera, gimbal, payload and peripheral MAVLink component claims, and the depth, LiDAR, IMU and payload sensor registrations. The catalog marks each one honestly, and a guard test fails the build in both directions if the marking and the mechanism ever disagree. Do not treat an advisory capability as a security boundary.
Revoking a capability takes effect immediately. A wire-gated capability is re-minted into the running plugin's token and the next request re-gates against the new set with no restart and no dropped session. A hardware, network or filesystem capability re-renders the systemd unit and restarts the plugin, because systemd applies device and address-family policy at exec time. Either way an operator who revokes something sees it stop.
THE GCS HALF
Your panel runs in a box
A Mission Control panel is a bundle loaded into a sandboxed frame with no same-origin privilege, and every host call crosses a validated bridge.
Null origin, empty CSP
The signed bundle is fetched and served to the frame from a blob URL, so the frame gets a null origin and can be sandboxed with scripts allowed and same-origin withheld. Every plugin frame also gets a content security policy whose default source is none, regardless of what the extension declared.
Four checks per message
The bridge validates the origin, then the message schema, then that the method exists in the registry, then that your declared capabilities cover it. Panels are pooled with a cap of eight live frames so a fleet view cannot spawn an unbounded number of them.
PACK AND SIGN
One archive, one detached signature
Pack, then sign. The pack step refuses to write a half-archive: it asserts that both halves the manifest declares are actually staged before it zips anything.
$# Build both halves into dist/<id>-<version>.adosplug
scripts/pack.sh <your-extension> # python agent half
scripts/pack-rust.sh <your-extension> # rust agent half
# Detached Ed25519 signature over the canonical payload hash
ADOS_SIGNING_KEY=~/keys/mycompany-2026-A.ed25519 \
ADOS_SIGNING_KEY_ID=mycompany-2026-A \
scripts/sign.sh dist/<id>-<version>.adosplug
# Static analysis before you hand it to anyone
ados plugin lint dist/<id>-<version>.signed.adosplug- Archive size
- 50 MB
- Single entry
- 25 MB
- Decompressed total
- 100 MB
- Expansion ratio
- 200x
The archive is a zip holding the manifest, an optional two-line SIGNATURE file, the agent half as a wheel or source, the GCS bundle with its stylesheet and locale files, and any assets. The limits are measured on bytes actually read rather than what the zip header claims, and path traversal, absolute paths and symlinks are rejected before a single file is written.
The signature covers a canonical hash: every entry except SIGNATURE, sorted by path, hashed, and the list hashed again. Sorting makes it independent of zip ordering, and signing the digest rather than the bytes makes verification constant in archive size.
TRUST
The signer id is a lookup key, not a claim
Nothing inside the archive can assert that it is trusted. There is no signer field in the manifest at all, and a manifest that declared one would fail to parse.
Enrol a public key on the node
The host loads every PEM public key from its key directory at startup and uses the filename as the signer id. Installing your extension on someone's drone means they put your public key on that drone first. There is no remote key fetch and no implicit trust.
Verification order
Revocation list first, then the key must exist in the enrolled set, then the Ed25519 signature must verify over the canonical payload hash. A missing signer or a missing signature is rejected before anything is unpacked when signed installs are required.
Mint your own signing key
ados plugin keygen writes a key pair, with the private half at 0600, and tells you exactly where the public half goes. The Rust and Python implementations of the canonical hash are pinned against shared fixtures so a signature produced by one always verifies in the other.
In-process isolation is not for rent
Asking for inprocess agent isolation or inline GCS isolation only works for a signer on a hardcoded first-party allowlist held in the source, not in a config file. An attacker who can write to the key directory still cannot get in-process execution.
DISTRIBUTION
How your archive reaches a drone
Three paths exist. Two of them work today.
$# 1. Local install on the node. The CLI takes a file path,
# not a URL, and verifies the signature before staging anything.
ados plugin install ./mything-0.1.0.signed.adosplug
ados plugin install ./mything-0.1.0.adosplug --allow-unsigned # dev only
# 2. Remote install, driven by Mission Control's install dialog or the
# MCP plugins.install tool, with the archive hash pinned by the caller:
# { node: "drone-01", url: "https://example.com/...", sha256: "<digest>" }
# 3. Registry submission (not yet open to third parties)
ADOS_REGISTRY_TOKEN=<token> scripts/submit.sh dist/mything-0.1.0.signed.adosplugThe submission endpoint is real and its handler is in tree, but the hosted registry it belongs to is not live and the first-party extensions monorepo is invitation-only for now. Community submissions open when the registry reaches its first stable release. Until then, distributing your own signed archive and publishing your public key is a complete and supported story, and the install dialog will show an operator your capability requests and any vendor-binary attribution before they approve it.