Skip to content

Security Model

melonfleet edited this page Oct 20, 2026 · 7 revisions

Security model

Flotilla runs commands on your Mac, and since 1.5 it runs commands that other Macs ask for. This page is what stops it running the wrong ones.

Everything crosses the allowlist

SwiftUI app  ──▶  ContainerCLI  ──▶  Allowlist  ──▶  ContainerHost  ──▶  container(1)
                                          │
                       MountPolicy · ExecPolicy · WirePolicy

ContainerHost is the execution spine. UI code never builds a raw command and never cares where the command came from. Every execution — read or mutation, on this Mac or asked for by another — crosses the Allowlist: a default-deny table of 66 command specs, each with typed argument shapes, a trailing-argument policy, and an exposure level.

Default-deny is the direction that matters. A subcommand nobody has written a spec for cannot run.

The allowlist must be stricter than the CLI, not looser

Specs are audited against captured --help output for every container subcommand, stored in reference/cli-help/ (63 files). Not against documentation, which has been wrong repeatedly.

A too-loose shape is the dangerous direction. Real examples that were caught: --publish accepted a bare port the CLI itself refuses, and start accepted 32 operands where the CLI takes one.

There is a related trap that produced nine bugs of one family: checking that you built the right command is not the same as checking the command is accepted. A test that encodes the grammar you guessed only proves you guessed consistently.

The three capability policies

Injected per executor rather than hardcoded, so the same core can be built strictly or permissively depending on who is asking.

MountPolicy — which host paths may be bind-mounted

It depends on who is asking, and there are three answers:

Who Policy What it allows
You, on this Mac unrestricted any absolute host path except / itself
A paired admin, on a host deny host paths named volumes only
A build, for one command the folder you chose that folder, and nothing outside it

On your own Mac, you are the owner, and the run form and the Files tab accept a folder you name. Refusing / is not a sandbox; it stops the one mount that hands a container the whole disk.

A request from another Mac never mounts a folder. A host's executor for its admins is built with host paths denied, so a container an admin runs there can use named volumes and nothing else, and a file copy into or out of a container cannot name a path on the host. A .flotilla file is held to the same rule when it is read: a host folder in someone else's file is never mounted.

Building an image is scoped to the folder you chose. It builds a CLI scoped to .roots([the directory you chose in the file panel]), for that one command. The file panel is the authorisation. It goes the safe way round — narrow, explicit, per-invocation, and named at the call site rather than inherited and forgotten. Saving an image to a folder is scoped the same way.

A subtlety worth recording: build's context argument was optional, because the CLI defaults it to . — and the validator only shape-checks operands that exist. So a build still archived the process working directory. It is now required, because a relative path cannot be checked against absolute policy roots anyway.

Flotilla makes no claim that MountPolicy is race-free. A path checked before a command runs can change before the command reads it. It reduces the surface; it is not a sandbox.

ExecPolicy — whether an interactive shell grammar is reachable

exec <id> sh is refused by default. The permissive grammar is selected by .interactiveShell, which only a CLI built for the machine's own owner carries. See Terminal.

A paired admin gets no shell, and no command of its own choosing: the shell grammar is marked local only, so the capability refuses it rather than a convention. The one exec a host accepts from its admin is a fixed process listing, ps -o pid,comm,args, which is what the Processes tab shows. There is no remote Terminal tab.

WirePolicy — the third dimension, now in use

Some commands are well-formed and still dangerous — no argument shape can refuse them, because there is nothing wrong with their arguments. That class needs a different kind of answer from argument validation, and the core carried one before anything needed it, so the boundary was designed rather than retrofitted.

The fleet is what needs it. Every spec has an exposure, and a CLI built for a remote peer refuses anything marked local only, with the reason, before it runs. Among them:

  • every machine command but list — machines can mount the owner's home directory, and their details disclose the owner's account name;
  • exec with any command but that fixed process listing, and the shell inside the machine VM;
  • registry login, logout and list — they send or reveal the owner's credentials;
  • image save and image load — a peer never names a file on another Mac;
  • system start and stop, DNS changes and kernel set — the owner's decisions;
  • k8s load-image and write-config — they read this Mac's image store and write a file other tools read;
  • logs --follow as a command, because it streams without end. Following goes through its own bounded stream instead.

What a paired admin can do on a host

Exactly what is left: list and inspect containers, images, volumes and networks; run, start, stop and delete containers; pull, tag and delete images, and receive one sent from the admin; create and delete volumes and networks; read logs and process lists; list machines; list, create, recreate and delete Kubernetes clusters; and read the host's status, versions and settings. An image pull over plain HTTP is refused from another Mac.

Within those, nothing is held back. An admin sees a host's containers in full — their settings, environment variables and mount paths — because an admin is the owner of the hosts it manages. Host mode says so where a host is about to pair: pair only with your own admin Mac.

A handful of things are not container commands at all — reading DNS, keeping the Mac awake, starting or stopping container, installing it. Each is a typed host call with its own validation on the host, never a command string, so system start stays local only and a peer still cannot send it as a command.

What the host's owner keeps

The host's own switches decide, in Settings ▸ Hosts and Fleet, and a peer cannot change them:

  • Let its admin start and stop container. Off, the call is refused, and the admin sees that container is stopped but cannot start it.
  • Install Flotilla updates from its admin. Only genuine, newer Flotilla, signed by the same team, is ever installed.
  • Install and upgrade container by itself — the host's automatic installs. An admin's explicit Upgrade… goes through the host's Flotilla Helper instead, and needs it switched on.
  • A keep-awake request from the admin runs for at most a day, and is refused on battery.

The fleet wire

  • TLS 1.3, both ways. Each Mac has its own identity: a P-256 key that never leaves the login Keychain, and a self-signed certificate for it. It identifies an installation of Flotilla, not a person, and not the Mac's name.
  • Pinned, not certified. The identity every other Mac checks is the SHA-256 fingerprint of the key, pinned at pairing. There is no certificate authority and no server between the Macs. Re-issuing the certificate keeps the fingerprint; replacing the key changes it, and every peer must pair again. Nothing ever replaces the key silently.
  • Port 7868, on a host only. An admin listens on nothing on the network. A host advertises itself over Bonjour unless its owner turns that off, and serves only Macs it trusts; a Mac it does not yet trust can do nothing but try to pair.

Pairing

By code, for a single Mac: the host shows an eight-character code — ABCD-EFGH — that lasts ten minutes and allows five wrong tries. The admin's owner types it. Each side then proves it knows the code, and both screens show the same four words, computed from both Macs' fingerprints and a fresh nonce from each. Pairing completes only when both owners have compared the words and confirmed. A machine in the middle presents its own key to each side, so the words differ and the proofs do not verify; the nonces mean it gets one blind guess, spent the moment an owner compares.

By enrolment key, for managed Macs: the admin makes one key, FLT1-…, and a configuration profile hands it to every host. It names the one admin a host may trust, and carries a secret so the admin can tell a host that really holds it from one that merely says so. It is a key for asking to join, nothing more: each host waits in Hosts until the admin's owner approves it, and replacing the key stops new requests without touching hosts already enrolled. An admin a host's owner removed is not let back in by the key — only by a code pairing the owner confirms.

Before either is complete, a Mac says only its name and macOS version about itself — never its model or serial number.

See Hosts for the screens.

Credentials and secrets

  • Registry passwords live in the sign-in form only while it is open, are handed to container registry login --password-stdin, and are cleared. container keeps the credential in the Keychain; Flotilla never stores it, and never sends an admin's credential to a host. A host signs in to a registry only from its own keyboard.
  • Group secrets — a database password in a suggested stack, say — are kept in the Keychain and reach run and nowhere else. A .flotilla file names them but cannot carry them.
  • Fleet identity keys are Keychain items of their own and are never exported. A .flotilla file lists the hosts to pair again, never a key and never trust.

The Flotilla Helper

The one part of Flotilla that runs as root: a launchd daemon the owner approves once in System Settings ▸ General ▸ Login Items & Extensions. It does four jobs and nothing else — DNS domains, the fleet's resolver files, installing Apple's container package, and installing a Flotilla update where the app is owned by root.

It takes typed requests, never an argv: the app says "create test", and the helper builds and validates the command itself, so a caller that got past the signature check still could not choose what runs. Each side requires the other to be signed by the same Developer ID team, so an ad-hoc build can never use it. A package is installed only if it is Apple's container, notarised, exactly the version asked for and not a downgrade; an app only if it is Flotilla from the same team and newer. See Flotilla Helper.

What is not built, and is not pretended to be

  • No App Sandbox for v1. Flotilla runs an external CLI and, on a host, listens for connections; a useful sandbox would need brittle exceptions. Hardened runtime, Developer ID signing, notarisation and least entitlements do apply. This is a recorded decision, not an oversight.
  • One updater, on admins only. Sparkle checks Flotilla's signed feed on an admin Mac, with your consent; a host never checks, and installs only what its admin sends and only if it is genuine and newer. Flotilla's own GitHub release check was removed so that two mechanisms could not disagree. See Updates.
  • No fleet server. Admins and hosts talk directly; nothing in between holds a list of your Macs.

Not yet: a fleet ledger, a redesigned host-trust model, and tunnels or an overlay network between Macs are planned for 2.0.

Error messages are a disclosure surface

Command failures name only the leading non-flag tokens. Everything from the first - onward is dropped, because argv is where --env TOKEN=…, --registry-password and home-directory mount paths live.

The first version of the timeout error interpolated the whole argv into a user-facing alert — the same class of leak it was written alongside a fix for, reproduced on a more exposed surface.

Clone this wiki locally