Repository navigation
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.
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.
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.
Injected per executor rather than hardcoded, so the same core can be built strictly or permissively depending on who is asking.
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
MountPolicyis 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.
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.
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
machinecommand butlist— machines can mount the owner's home directory, and their details disclose the owner's account name; -
execwith any command but that fixed process listing, and the shell inside the machine VM; -
registrylogin, logout and list — they send or reveal the owner's credentials; -
image saveandimage load— a peer never names a file on another Mac; -
system startandstop, DNS changes andkernel set— the owner's decisions; -
k8s load-imageandwrite-config— they read this Mac's image store and write a file other tools read; -
logs --followas a command, because it streams without end. Following goes through its own bounded stream instead.
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.
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 thatcontaineris 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
containerby 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.
- 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.
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.
-
Registry passwords live in the sign-in form only while it is open, are handed to
container registry login --password-stdin, and are cleared.containerkeeps 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
runand nowhere else. A.flotillafile names them but cannot carry them. -
Fleet identity keys are Keychain items of their own and are never exported. A
.flotillafile lists the hosts to pair again, never a key and never trust.
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.
- 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.
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.
Flotilla — a native macOS app for Apple's container CLI. A personal project, free to use, Apache-2.0. No account, no telemetry.
Getting started
Fleet
Containers and resources
- Containers
- Groups and suggestions
- Images
- Registries
- Volumes and networks
- Kubernetes
- DNS
- Machines and containers
- Machines
- Terminal
- Files
Using Flotilla
- Menu bar and window
- Views and filtering
- Logs and diagnostics
- Sharing and export
- Flotillafile
- Settings
- Greta
- Flotilla Helper
More