v0.8.7-beta.5 is out · 5 Novsee the release
Browse guides / Security
DOCS

Security posture

docs/security.md on GitHub

Operational guidance for people running an Aether server. This file records the stances that are deliberate, so they are not repeatedly re-raised as findings.

The agent container

The container is the isolation boundary. Aether does not try to build a second sandbox inside it.

  • Agents run as root by default. An agent profile may map a run to a non-root UID/GID, but the default image runs as root and nothing in Aether forces otherwise.
  • Docker's default capability set is retained deliberately. Agents install packages, run build tooling, and use sudo in images that ship a non-root user. Dropping capabilities, setting no-new-privileges, or making the root filesystem read-only each break one of those in practice, including Aether's own setup-script sentinel, which needs a writable /tmp.
  • The consequence: treat container root as capable of anything the container can reach. Security comes from what the container is given, not from restrictions applied inside it: the mount policy, the network it can see, and the credentials mounted into it.
  • Docker inside a run uses Sysbox, not the host daemon. On a host with the Sysbox runtime (environments.md), run and Environment containers start with --runtime sysbox-runc and run their own dockerd. Sysbox gives the container a user namespace: root in the container maps to an unprivileged host uid, so the inner daemon's privileges stop at the container. The container is not --privileged and never receives the host Docker socket, so an agent cannot start containers on the host daemon or mount host paths through it. The browser companion stays on the daemon default. Without Sysbox nothing changes: a run has no daemon and no socket.
  • Git trusts the checkout regardless of owner. Every container with a run checkout gets safe.directory=/workspace through GIT_CONFIG_COUNT, GIT_CONFIG_KEY_n and GIT_CONFIG_VALUE_n, appended after any of the workspace's own entries. An unprivileged server owns the checkout on the host while the agent runs as root; git's ownership check guards against another user's repository on a shared machine, which the container is not. Git reads GIT_CONFIG_COUNT from 2.31 on; an image with an older git that carries the ownership check (Debian 11's 2.30.2, Ubuntu 20.04's 2.25.1) still refuses the checkout. The image's own ENV GIT_CONFIG_* entries are replaced, not appended to: set them in the workspace environment instead.
  • An unprivileged server deletes root-owned run files as root. When it cannot remove a run checkout or a member home, it starts a container of the standard image as 0:0 with only that directory mounted, running find /reclaim -mindepth 1 -delete and nothing else. find -delete never follows symlinks, so an agent cannot point it at another host path.

Each member's persistent home is mounted as $HOME only into that member's own containers: their environment and the runs they launch. An account share is the one exception, and it reaches only the owner's agent login, except that an omp share reaches the owner's whole ~/.omp/agent; see Account sharing.

Real names and email addresses cross into the container with the run. The git identity of the member who launched it is baked into the container's GIT_AUTHOR_* and GIT_COMMITTER_* at creation, and /run/aether/co-authors holds one Co-authored-by: Name <email> line per member who messaged the run, readable by the agent like any other file under the coordination mount. A merged branch credits a real upstream account only if it carries that account's address.

Each member controls their own address, not the operator. aether member git sets your own identity with no admin check - only setting someone else's needs the admin role (internal/sshd/gitidentity.go) - and the local dashboard's onboarding wizard asks every new member for one. Setting none withholds the address alone: the synthetic <member-id>@aether.local fallback credits nobody upstream, but domain.Member.GitIdentity still falls back to the display name, or the member id when that cannot be a git author name, so a name reaches GIT_AUTHOR_NAME and the trailers either way.

Account sharing

Sharing your agent account with a member (Share account under Agent account sharing in Profile, or aether account share <member-id>) lets them launch runs on it from the launch dialog's Account picker (or aether run --account <your-member-id>), so they use your agent CLI subscription. The launcher remains the run owner and actor; usage and cost are attributed to the selected account.

A run always starts from its launcher's saved image, or the standard image, with the launcher's home as $HOME. Git identity, .gitconfig, the gh login, the signing key, SSH files, shell history, agent configuration (settings, hooks, MCP servers, history), installed executables (except a borrowed agent installation, below), imported configuration, terminal image uploads, and the profile snapshot pin are all the launcher's. Candidate verification started by a run's agent runs in the home that run's container mounts, which Aether records on the run when it is launched; a handoff (aether handoff) changes the run's owner but not that home, and verification does not need the run's container to still be alive. On a shared account, Aether additionally mounts that agent's login path from the owner's home over the same path in the launcher's home, read-write. The Login state column of harnesses.md lists each path; for claude it is ~/.claude/.credentials.json.

When the launcher's home has no ~/.local/bin/<executable> for the agent, the run borrows the owner's installation. The owner's ~/.local/bin and, when it exists, ~/.local/lib are mounted read-only at ~/.aether/account/bin and ~/.aether/account/lib, and ~/.aether/account/bin is appended to the end of PATH. Each install directory the agent declares (harness.Profile.InstallPaths; ~/.local/share/claude for claude) is mounted read-only at its own path. A borrowed login also gets the launcher's own copy of the agent's state file the CLI consults before using a login (harness.Profile.BorrowedState; for claude, hasCompletedOnboarding: true in ~/.claude.json, created with mode 0600 or rewritten in place, never replaced), so the CLI starts signed in rather than running first-time setup; Claude Code then writes the owner's account email into that file. Nothing else of the owner's home is mounted, and ~/.local/share as a whole never is: opencode keeps its login there. The owner's ~/.local/bin/<executable> counts as an installation only when every link it follows stays inside those mounted directories; a link into anything else would dangle in the run, so the agent is reported as not installed instead. When neither home has the executable, nothing is borrowed and the run starts whatever the image provides.

Two limits follow from mounting the owner's files unchanged. Claude Code's installer links by absolute path (/root/... or /home/aether/...), so an owner who installed as root and a recipient whose image runs as a non-root user, or the reverse, get a link that does not resolve and the container's own not found error. A recipient whose image runs as a different non-root uid can only execute what the owner's file modes allow others to; the read-only mounts are never re-owned.

A borrowed installation means the owner's executables run in the recipient's container, with the recipient's home, GitHub login, and signing key. Every executable in the owner's ~/.local/bin is on PATH, after the recipient's own and the image's, so a recipient who launches without their own installation trusts the owner's. The mounts are read-only, so the recipient's run cannot alter the owner's installation. A recipient who installs the agent in their own ~/.local/bin runs their own copy and borrows nothing.

omp is the exception. It keeps its login in a SQLite WAL database, agent.db, beside config.yml, mcp.json, its extensions, usage tables, and sessions/, and a WAL database cannot be shared file by file. An omp share therefore mounts the owner's whole ~/.omp/agent directory read-write, and omp loads extensions, MCP server commands, and settings from it. That cuts both ways:

  • The recipient's run can plant an extension or MCP server command there that runs in the owner's next omp session, inside the owner's environment with the owner's whole home, gh token, and signing key. Against a recipient who does that, an omp share is as broad as sharing your home.
  • The owner's extensions and MCP servers run in the recipient's container, with the recipient's GitHub login. The recipient's run can also read the owner's omp sessions.

Share an omp account only with someone you would give your home to.

Only the shipped agents and server-wide definitions (harnesses.md) declare a login path to share. A member's own definition (aether agent add) runs only on that member's own account; a launch of it on a shared account is refused:

CODE
scheduler: harness "<name>" is your own agent definition, which runs only on your own account; on a shared account, only a server-wide definition (aether-server --agent-definitions) can declare the login it shares

A launch is also refused when a definition's login path is the home itself, when the owner has no login at any declared path (a missing or empty file is no login), when any component of that path in the owner's home is a symlink, and when the login file has another hard link.

What a share still hands over: the recipient's run holds the owner's login itself, including the refresh token. Root in that run can copy it, and revoking the share does not recall a copied token; the owner rotates it by logging out and in again. The login is writable because a token refresh rewrites it, so a recipient's run can also overwrite it, log out (/logout in Claude Code and codex logout revoke the owner's session at the vendor), or replace it: /login in Claude Code or codex login in a shared run writes the recipient's own login into the owner's file. For the file logins (claude, codex, pi, opencode, copilot, gemini, cursor, antigravity) that is loss of the login, not access to anything else in the owner's home. For omp, see above.

How the path is mounted. The owner's own containers control every path inside the owner's home, so a plain bind of a path in it could be swapped for a symlink between Aether's check and container start, exposing another member's home or a host path. Aether instead mounts the login path as a Docker volume subpath of the owner's home, which the engine resolves beneath the home at every container start. On Docker Engine 28.0.4, a symlink in the path that leaves the owner's home is refused at container start (path concatenation escapes the base directory or cannot access path), including one swapped in after the container was created or stopped; a symlink that stays inside the owner's home is followed. Aether refuses any symlink in the path at launch, but the owner's own containers can plant one afterwards, before a start or restart, and so redirect the mount to another file in the owner's own home, never outside it.

This needs Docker Engine 26.0 or newer (API 1.45) for every container that mounts a path beneath a member home: runs on a shared account, and every container that mounts a sharing owner's home - their runs, their environment terminal, and candidate verification started by them or by their runs. An older engine ignores the subpath and would mount the owner's whole home, so Aether reads the engine's API version first and refuses:

CODE
runtime: docker engine API "1.44" cannot mount a path beneath a member home; that needs API 1.45 (Docker Engine 26.0) or newer

A DOCKER_API_VERSION below 1.45 in the server's environment is refused the same way, because requests sent at it ignore the subpath too:

CODE
runtime: docker client sends API "1.44" (DOCKER_API_VERSION), which cannot mount a path beneath a member home; that needs API 1.45 or newer

Aether creates one volume per sharing owner, named aether-home-<hash> and labelled aether.managed=true, as a bind of that owner's home; removing the volume does not delete the home.

Claude's login file. Claude Code replaces ~/.claude/.credentials.json by rename on every token refresh and writes in place only when the rename fails, so a refresh in the owner's own container would leave recipients' runs holding the old file. Once a member has shared their account, Aether therefore also mounts that file in place in the member's own runs and environment, creating an empty file when none exists, so every writer updates the one file recipients' runs hold. The empty file is not a login: until the owner logs in, a recipient's claude launch is refused. A login file with another hard link is neither mounted in place nor shared. A container the owner started before sharing, typically the long-lived environment, lacks that mount, so stop it and open it again. When the environment is running at a first share, or its state cannot be read, Profile says so and offers Stop environment; Open on the Environment page starts it again. From the CLI: aether terminal stop, then aether terminal. In a container with the mount, Claude's /logout revokes the login at Anthropic and reports success but cannot delete the file; the dead tokens are cleared on the next refresh. Members who share nothing are unaffected.

Gemini's login file. Gemini CLI replaces ~/.gemini/oauth_creds.json by rename too, and rewrites it in place when the rename fails, so Aether mounts it in place on the same terms as Claude's file above, and the same stop-and-reopen applies. ~/.gemini/.env is shared but not pinned: Gemini only reads it. A share of gemini hands over a GEMINI_API_KEY kept there.

Antigravity's login file. ~/.gemini/antigravity-cli/antigravity-oauth-token is shared like Claude's but not pinned: whether agy replaces it by rename is unverified, and so is whether agy can refresh it as a shared mount.

Non-root images. With a non-root image, a run hands the login path to its own uid before it starts. For omp that is the whole ~/.omp/agent, so a large or deeply nested one slows non-root launches on that account, the owner's own included, but not the rest of the server. The owner's containers are never refused because of a recipient's run. A recipient's launch is refused while its uid differs from that of the owner's live containers, or of another recipient's live run, on that login; the error names the login <member-id> shares is held by. The handover is checked again against those containers when it happens, so a recipient launch that the owner's container overtakes is refused rather than taking the login back. If the owner starts a container with a different non-root uid while a recipient's run is live, the owner's container takes the login back and that run loses access to it. A profile push or rollback by the owner (aether profile push, aether profile rollback) re-owns every entry under that agent's profile root in the owner's home, the login included, to the uid:gid of the owner's home directory unless that is the server's own uid, and a recipient's live run with another uid loses access the same way.

Revoking a grant blocks later launches and reopens. It does not stop an already-running container or remove the login mounted into it. Stop those runs before revoking access when immediate removal matters: Kill run in the run header's More menu, or aether kill <run-id>.

Usage fallback. A launch whose account is out of usage can start on another account (teams.md), so a run can mount a sharing owner's login without the launcher picking that account for it. The share is the owner's consent; there is no separate grant. Fallback never widens who may launch on an account. Its candidates are only the launcher's own account and the accounts shared with the launcher, each checked again with the share check a hand-picked launch uses, at the moment it is chosen, so a revoked share leaves the pool at once. A fallback account is never one whose agent installation the launcher would borrow, so fallback never runs a teammate's executables for a launcher who did not pick that account, and never one with no login to share, nor one whose login a live container holds under another uid:gid than the launcher's own live containers use. Choosing reads only member homes and the scheduler's state, never the container runtime or a vendor, so nothing slow runs under the launch authorization lock. Without an installation of the launcher's own, an account qualifies only when the requested launch would also have run the image's agent, so a launch that relied on the requested account's installation never lands where the agent is missing. An admin gains nothing, and a member-defined agent still runs only on its member's own account. A swarm worker is checked against the swarm's accountable human, and an integrator launch or replacement against the member who creates or replaces it.

A run whose agent reports its usage limit mid-run moves to another account the same way: the move is a launch on the new account. Under the launch authorization lock, with the run read again there so a handoff during the move's quota read counts, the run's owner and the member whose home its container mounts (the launcher, who differ after a handoff) must both still be allowed to launch, as AuthorizeLaunch requires, and each candidate must be shared with both and pass the same installation and login rules in that home. The lock is released while the old container is removed, then taken again: the owner, home member and share are admitted once more and the run records the new account, as a launch holds the lock until its admission is durable. It is not held while the replacement starts. A move never returns to an account the run already used. A swarm worker's later checks use the account its run is on now, not the one it launched on. The signal comes from inside the run's container (agent output, the Claude StopFailure hook payload, the ACP error), so the agent can forge it and force a move, but only to an account the launcher may already use that has usage left: the same reach as the launcher picking it. Only the current agent process's output counts: output an earlier process printed is never read again after a restart or a move. Revoking a share still does not stop a run that has already moved onto that account.

Containers created before shares were narrowed to the login path still mount the account owner's whole home until they end. They stay supervised, and reopening one is refused:

CODE
scheduler: invalid run state transition: run <run-id> predates the narrowed account share, and its container still mounts the account owner's whole home; it stays closed, so launch a new run

Stop those runs, before or after upgrading, to end that exposure immediately: Kill run in the run header's More menu, or aether kill <run-id>.

This narrows what an account share exposes. It does not change the role model: a collaborator can still message and control another member's live run (teams.md) and reach the home its container mounts, and a handoff transfers a run whose container keeps the home it was created with. The development terminal and run repository gates still also check access to the run's account, as before; that check is not what protects a run owner's home. The Steer capability is. The share boundary is not a per-member sandbox.

GitHub credentials and signing keys

Connecting GitHub (the administrator's browser authorization flow or aether github connect, see environment-home.md) puts two secrets, and the settings that use them, in the member home on the server:

  • The gh token, at homes/<member>/.config/gh/hosts.yml. gh auth login runs inside the member's environment container, which has no keyring, so gh falls back to writing the token to that file in plain text.
  • The signing key pair, at homes/<member>/.ssh/aether_signing (mode 0600) and .pub. Aether generates the ed25519 key itself and registers only the public half on GitHub with gh ssh-key add --type signing.
  • The settings that use them, in homes/<member>/.gitconfig: gh's credential helper for https://github.com, plus gpg.format=ssh, user.signingkey=~/.ssh/aether_signing, and commit.gpgsign=true.

None of it is in aether.db, and none of it is in a saved environment image. The member home is a bind mount, and Docker's commit records only the container's own layer, never a bind mount, so aether env save cannot capture the token, the key, or the .gitconfig. An integration test starts a container from a saved image with no home mounted and asserts all three are absent (TestIntegrationMemberEnvironmentImage).

The token is in the home because the run itself has to hold the credential: the agent pushes its own branch and opens its own pull request. Keeping it out of the container behind a host-side proxy was considered and rejected - the agent asks that proxy for the same pushes and pull requests, so it has the same reach by a longer path.

What that grants an agent is the point of the feature, so it is worth stating plainly. Container root in any of that member's runs can read and replace the token, the private key, and .gitconfig. With the token it can act as the member on GitHub within the token's scopes - gh auth login as documented in environment-home.md requests gh's defaults (repo, read:org, gist) plus admin:ssh_signing_key - so it can push to the workspace's recorded origin, open pull requests, and register or remove signing keys on the account. That push is HTTPS: gh's credential helper authenticates https://github.com and nothing in the home authenticates SSH, which is why a github.com origin is recorded in its https form (teams.md).

Replacing reaches further than reading. The home is mounted read-write and its files are owned by the container user, so an agent can swap the signing key for one of its own, and Aether then signs its own end-of-run commits with the replacement; rewrite .gitconfig, which decides the credential helper and the identity commits are made under; and plant ~/.local/bin/gh, first on the container's PATH, so the next aether github connect - or the gh --version the dashboard's GitHub step runs on its own to check that environment - runs the agent's program instead of gh. An account share does not extend this reach: a recipient's run has the recipient's own home, token, and key, never the owner's. An omp share is the exception: code the recipient's run plants in the owner's ~/.omp/agent runs in the owner's own omp sessions (Account sharing).

Aether signs its own end-of-run commits with that key while still treating the home as hostile. It opens the key through a root-confined open on the home directory, so a symlink planted inside the container cannot lead the server to a file outside it, and copies the bytes into a private temp file of the server's own for the length of the commit. gpg.format, gpg.ssh.program and commit.gpgsign are all passed with git -c on the command line, because the run checkout's .git/config is agent-writable: without pinning them, a planted gpg.ssh.program would name a program the server then runs.

GitHub marks a signature Verified only when the commit's committer address is a verified address on the account that registered the key. So the agent's own commits, which the container makes as the member, show as verified only when that member's git email is on their GitHub account: the synthetic <member-id>@aether.local fallback, and any address they have not added there, read Unverified. Aether's end-of-run commits, which are committed as Aether, never verify on GitHub. Those still verify locally against the member's public key with git verify-commit, given a gpg.ssh.allowedSignersFile that lists the address and the key. Signing does not change who the commit is authored as; see teams.md.

To revoke the token at GitHub, revoke the GitHub CLI authorization in GitHub's application settings. gh auth logout --hostname github.com in the environment terminal only removes the native local login; it does not revoke a copied token. Remove the signing key from github.com/settings/keys, and delete .config/gh/hosts.yml, .ssh/aether_signing and .ssh/aether_signing.pub from the member home. Then clear the signing settings in the environment terminal, or every git commit inside a run starts failing with Load key ...: No such file or directory:

CODE
git config --global --unset commit.gpgsign
git config --global --unset user.signingkey
git config --global --unset gpg.format

aether env reset does none of this - it forgets the saved image and never touches the home.

Workspace secrets

A workspace variable marked secret (environments.md) is write-only in the settings and an ordinary environment variable in a run. Hiding it protects the value from someone reading the settings, not from someone who can run code in the workspace.

At rest. The values of a workspace's secrets are one JSON file, <data-dir>/workspace-secrets/<workspace-id>.json, in plain text with mode 0600 in a mode-0700 directory. They are not in aether.db, the event log, a member home or a saved image. The server administrator and any process that can read the data directory can copy the file, and a backup of workspace-secrets/ is a credential backup. Deleting the workspace deletes the file. Plain variables and the setup script are in aether.db and are readable by every member, so neither is a place for a credential. Marking a plain variable secret hides it from then on: every member could read it before, and earlier copies of aether.db still hold it, so change the value when you mark it.

In a run. Each new run container, and each candidate verification container, is created with the workspace's variables and secrets in its environment. From there:

  • Every process in the container reads them: the agent, the setup script, and any shell opened on the run. Anyone who can launch a run in the workspace, or message a run there, can make it print a secret, and what it prints lands in the run's transcript like any other output.
  • Anyone who can inspect containers on the server host reads them and the setup script with docker inspect; Docker access there is root access already.
  • A container keeps the values it was created with. Removing or replacing a secret does not reach a container that already exists, so rotate a leaked credential at its issuer, not only here.

What never carries a value. workspace.environment.get and the result of workspace.environment.set name a secret and omit its value, for admins too. The timeline note for a change lists names. Refusals do not echo a value. A candidate verification's environment_sha256 covers a secret by name, not by value.

Setup script output. When the script fails, the launcher gets the end of what it printed and the server log gets the same text; the run's status reason, which every member reads, gets only the exit code. A candidate verification whose setup script fails records the first 8 KiB of that text as its error, which every member reads. In that text every exact occurrence of a secret's value is replaced with ***. A value the script transforms first - encodes or splits - is not recognized and is not masked, and nothing masks what the agent or a verification command prints later.

The assistant

The assistant is an agent running in a member's environment container, driven from the dashboard. The boundaries:

  • Member authority, through the server. Its Aether tools reach the server over a Unix socket at <data>/assistant/<member>/sock/assistant.sock. The server binds each socket to one member and runs every call as that member, with the same permission checks as their own CLI. No tool takes a member id, and the socket rejects any parameter containing member_id, including account_member_id. The one exception is run.list, which accepts member_id as a read filter: every member can see every run anyway. Only a fixed allowlist of methods is served; anything else returns method not found.
  • Socket placement. The socket directory is mounted read-only at /run/aether-assistant in that member's environment terminal only. No run container and no other member's terminal receives it. A terminal created before the assistant existed lacks the mount and reports needs_restart.
  • Tiers, enforced by the server. Read methods and run.pause/run.resume run at once. run.launch, run.inject, run.kill and env.save block inside the server until the member approves a card whose text the server builds from the call and the stored run or workspace. A decline, a 10-minute timeout or a closed session fails the call, and the card is withdrawn if the agent's call goes away. The socket does not serve assistant.confirm; the approval is a control call from the member's own dashboard session.
  • Auto-approval is the member's, and off by default. With assistant.settings auto_approve on, the server passes those four methods without a card, from a fixed list, so a confirmed method added later asks. A message to or stop of a run another member owns always shows a card; the server looks the owner up from the stored run. The server reads the stored setting on each call; nothing the assistant sends is consulted. The dashboard suggests the setting after three cards approved by hand; the tool socket cannot answer that suggestion either. The tool socket serves neither assistant.settings nor assistant.confirm, so the assistant cannot turn it on or approve for itself; only a control call authenticated as the member, from their dashboard or CLI, can. Approved and auto-approved calls alike are written to the member's transcript before they run, and a call whose note cannot be written does not run.
  • Shell work does not ask. The session starts in the mode Enhanced runs use (Claude Code bypassPermissions, Codex agent-full-access), so the agent's shell and file tools run without a prompt. The tiers above are the only approvals, and they are enforced by the server, not the harness.
  • Run text is untrusted. Transcripts and output the assistant reads can contain injected instructions. The assistant's instructions say to treat them as data, but the control is the server: with auto-approval off, a steered assistant still cannot launch, message or stop a run, or save the environment, without a click. With it on, that click is gone, and for the agent the instruction is the only guard: a steered assistant can launch runs, message and stop the member's own runs and save the environment; messaging or stopping another member's run still needs a click. It can run commands in the member's environment, which is the member's own container, either way.
  • Notices name the run. Aether follows the runs the assistant launches or messages and writes a notice to the member's own transcript and to their devices through Web Push. A notice holds the run's title and a reason, question or action taken from the run, so it is run text too. The assistant.notice event is a workspace event: any client subscribed to the workspace receives it, the same as the run's run.status events, and the dashboard shows it only to the member named in member_id.
  • No account sharing. The assistant session always uses the sign-in in the member's own home and counts against their own subscription. Runs it launches may use quota fallback onto accounts shared with the member, the same as the member's own launches.

Stated plainly:

  • Anything that can write the member's home can influence the assistant while it runs. The member's runs mount that home read-write, so a run can plant ~/.claude settings or hooks, or .bashrc, that the assistant then loads.
  • Code running in the member's environment container can reach the tool socket while a session is live. It gets the read tools and pause/resume at once; anything else needs the member's approval on a card, or nothing while auto-approval is on.
  • With auto-approval off, every action beyond reading and pause/resume needs that approval, and the card text comes from the server, not the agent. Turning auto-approval on trades that check for speed; the transcript then shows what passed, after the fact.
  • With auto-approval on, any code running in the member's environment container gets the confirmed tools too, not only the agent: a shell script, a package install hook, or a ~/.claude hook or .bashrc line a run planted in the shared home. Such code can launch runs, message and stop the member's own runs, and env.save can persist it into the image new runs and terminals start from.
  • The assistant cannot change the setting as long as no Aether control credential is stored in the member's environment home. A member who signs the aether CLI in there gives the assistant, and every run that mounts that home, the means to call assistant.settings and assistant.confirm as them.

The agent is a process in the member's environment container, the same as aether terminal. It can read the member's home, including agent logins and other credentials, and run any command there without asking. Tiers gate Aether operations, not what the shell can do to the container. The assistant is told not to reveal secrets, which is an instruction, not a control.

Remote development and browser isolation

Development terminals, the shared app browser and transient captures require Steer, including reads and captures: an app session can already be authenticated. Ordinary permission to view a run is not permission to inspect that app session. The server also rechecks access to the run's backing account. Local SSH gateways and the server-hosted dashboard use the same checks.

Explicitly retaining selected captures crosses a sharing boundary: retained bytes and verification notes become ordinary workspace evidence, readable with View under the packet's existing scope and expiry, without live Steer or backing-account access. Retain only reviewed content before headless completion or other cleanup. Capture bytes and observation metadata are immutable; the packet's later retained Git revision does not establish capture-time Git state. Notes are at most 4096 UTF-8 bytes; retained plus staged captures are bounded to 64 files and 128 MiB per run, with at most 8 MiB per capture.

Retention re-resolves current authority after taking the per-run evidence lock and before opening each selected source. Immediately before publishing the packet it rechecks under the shared authorizationMu admission gate, which also serializes account, membership, role and workspace-policy changes. A busy gate refuses publication with a visible retry-retention conflict and rolls back staged work; it never silently retries. After an uncertain result, inspect evidence and retry explicitly with the same key and exact request if needed.

A human member and a run agent are distinct principals. The agent's identity comes from its run socket, not a member ID in a request. Each app terminal and browser session has its own writer lease and resource incarnation; commands carry that lease's generation. A human can explicitly take over, which fences stale writes. An agent cannot force a takeover. Taking an app surface does not take the primary agent terminal or release its swarm hold; primary-terminal control and swarm dispatch retain their own rules. Held browser keys, buttons and touches are cleared server-side when the controller releases, loses authority or disconnects, and before replacement control is admitted. A cleanup failure fences new control rather than handing the next controller potentially held input. A disconnected observer is not a controller release.

Inspecting the element under a point (dev.browser.inspect, the Browser's Annotate) is a read like a frame or a snapshot: it needs Steer, takes no writer lease and dispatches nothing to the page. What it returns excludes every form control's value, whether typed or set in markup, and replaces an attribute value that looks like a credential with [redacted]; visible page text, URLs and the picture of the element are not redacted. A member chooses what to send: nothing reaches the agent until Send to agent, and what is sent is ordinary run history afterwards.

This is shared-input ownership, not a restricted execution sandbox. An app terminal executes in the live run container, under the run owner's ordinary home and credentials, the selected account's agent login, and the container's filesystem and network authority. An agent or human can still execute native tools outside the managed surface. Taking a writer lease does not suspend every process already running there.

The Chromium companion has a different policy from the agent container above:

  • It runs as UID/GID 1000:1000, with a read-only root filesystem, all capabilities dropped, no-new-privileges, private IPC and 256 MiB shared memory, bounded memory/CPU, and a private temporary filesystem.
  • Chromium runs genuinely headless with its namespace and seccomp sandboxes enabled. A scoped seccomp profile permits the required user-namespace operations; Docker's stock AppArmor policy remains in place. There is no privileged, unconfined, --no-sandbox, Xvfb, or host-display fallback.
  • Its only bind mount is a private control directory. It cannot mount the checkout, member home, signing keys, agent credentials, or Docker socket. It joins the run's network namespace, never host networking; this lets it reach the app's loopback ports without exposing a browser port on the host.
  • Playwright controls Chromium through a debugging pipe, not a CDP TCP listener. The server brokers typed commands and bounded frames over a Unix socket; clients do not receive unrestricted CDP access.

This separation protects member files from browser code; it does not make pages safe to publish. An app test login may contain cookies, tokens, customer data, or other secrets. The browser shares the run's network reach. Use dedicated test accounts, never paste production credentials for recording, and inspect every selected capture before sharing it. Console warnings/errors, failed requests, page URLs, and visible page or terminal content can also contain secrets. There is no automatic public pull-request image upload. See privacy.md for retention and install.md for deployment failures.

Managed selected-path Git commits

These commits execute Git inside the live run environment, using its native identity and signing configuration and the run's coauthor trailers. Publication uses one native git update-ref --stdin prepared transaction, with a single dereferencing update HEAD NEW OLD. Git compares the branch's object ID with the expected old value as a native compare-and-swap. After Git acknowledges prepare, Aether verifies symbolic HEAD still names the expected branch while Git holds both the HEAD and branch locks, then commits or aborts within that transaction. A changed branch or HEAD fails closed; Aether does not emulate the guarantee with its own lock or update whichever branch is current later. Stock Ubuntu 24.04 Git 2.43 supports this primitive; it does not require a special Git build or new host upgrade prerequisite.

The selected-path index is isolated while constructing the commit: only the explicitly selected paths enter the commit, and unrelated staging is preserved. This is not a snapshot lock over files being edited concurrently: selected content can change while Git reads it. Updating the selected entries in the live index after publication is a separate step. A result with committed=true and index_updated=false means the commit exists but index reconciliation failed; inspect the reported error and repository status rather than blindly retrying the commit.

The native commit-tree path deliberately does not run commit hooks (hooks_run=false). Use git commit in the run terminal when commit hooks are required. Native reference-transaction hooks are not disabled or replaced; their preparation veto remains effective. Custom environments without the required native transaction support fail closed, preserving native stderr and reporting missing protocol acknowledgements rather than falling back to an unchecked update. Other repository reads remain available. See install.md for exact diagnostics.

Workspace source mirrors

A workspace without a mirror is local-only. A configured mirror is an administrator-only, read-only fetch from its source branch into the workspace's protected base. It is not the checkout Origin: Origin remains the independent push destination for run branches and pull requests. A mirror source URL must not contain credentials, query strings, or fragments.

GitHub mode (auth: github) uses the initiating administrator's native GitHub CLI login on their own Aether server. The server reads homes/<member>/.config/gh/hosts.yml through a bounded, root-confined read, verifies the active account with https://api.github.com/user, and uses the credential only for fixed GitHub API reads and HTTPS fetch of the selected https://github.com/<owner>/<repo>.git source. API requests and fetches do not follow redirects. Repository listing includes accessible private collaborator and organization repositories; repository administration or push access is not required to import a readable source.

The token remains a native member-home secret. Mirror configuration persists only the authorizing member ID and numeric GitHub account ID as its credential binding, alongside ordinary source metadata, never a token. It is not copied to other members, the browser, the edge, the workspace database, on-disk Git configuration, URLs, or command arguments. The server uses it in memory and passes URL-scoped HTTP authorization to the fetch child through its temporary process environment. Server administrators and processes able to inspect that server can therefore access it.

Fetch-only describes the mirror operation, not the token's permissions. Native gh authorization retains its scopes, including the broad repo scope and the signing-key scope described above. The owner's own agents already can read or replace these native credentials. Importing private source deliberately shares its code with the workspace's authorized members and runs; it does not lend them the administrator's GitHub login or grant them upstream publishing permission. Publishing still requires an explicit, independent checkout Origin and the publishing member's own credentials.

Every refresh re-reads the native credential and verifies the numeric account binding. Expiry, revocation, logout, removal of the authorizing member, or switching to another account fails refresh without moving the accepted base. Reconnect the same account as the authorizing member, or have an administrator explicitly reconfigure the source to bind their current account. Disabling or deleting a mirror does not log out or revoke native gh authorization. Browser authorization and repository-selection RPCs are admin-only; the server derives the credential owner from the authenticated caller. Existing member-scoped native GitHub setup remains separate, as do edge identity sign-in and agent-provider accounts.

Public mode fetches credential-free HTTPS. Deploy-key mode generates a dedicated Ed25519 key for each mirror configuration generation. For GitHub, the operator installs only the printed public half as a repository deploy key and should leave Allow write access off. Generic SSH sources use the operator-supplied known_hosts contents; GitHub uses Aether's pinned github.com host key rather than the server's global known_hosts.

The private key is stored on the server below <data-dir>/mirrors/<workspace>/private_key.<generation> with mode 0600 and a mode-0700 parent. It is not in aether.db, a member home, a saved image, an RPC result, or the dashboard. The server uses it only for the mirror fetch. The server administrator and any process that can read the server data directory can nevertheless copy it, and backups of mirrors/ are therefore credential backups. A compromised server can read the upstream with that key; for generic SSH, the account's server-side permissions define the scope. Use a repository-scoped, read-only key wherever the provider supports it.

Reconfiguring rotates to a new generation and removes the old local key files, but it cannot remove a key already installed at GitHub or another provider. Revoke old keys there, using the GitHub deploy-key settings URL or the provider's equivalent. Disabling a mirror removes its local key and restores client-writable base behavior; it also cannot revoke a remote key. Treat a deploy-key public key and its fingerprint as operational metadata, but never publish the private key or the known_hosts file.

Configuration starts pending; it does not fetch until Verify/refresh (remote import performs that first fetch). The initial observed revision requires explicit administrator adoption; fetching it is not acceptance. Adoption requires both the configuration generation and the full reviewed commit SHA (expected_commit in the API, --commit in the CLI). Generation pins configuration, not code: a refresh can replace the candidate without changing generation. Under the Git ref-mutation lock, the server checks the reviewed SHA against the current candidate on every compare-and-swap attempt, before moving any base, accepted, or candidate ref. A stale review is rejected without changing those refs; the new candidate remains inspectable. The administrator must refresh the review and explicitly accept the new revision, not retry with an automatically selected latest SHA. Each launch refreshes exactly the configured branch before a run row is created. ready accepts an unchanged or forward-only source, while auth-failed, offline, source-missing, rewritten, diverged, and error explain a failed or intentionally held observation. Rewrites and divergence retain an observed candidate without moving the accepted base. Every failed refresh blocks that launch and never silently starts from stale data. When an accepted commit is available, an operator may make one explicit --cached-base <sha> retry; Aether verifies that the protected base is still exactly that commit and never reuses the cache automatically.

Direct writes to a mirrored base - including git push aether <base>, dashboard Push now, repo.push, or a daemon base push - are rejected by the server. Only a forward refresh or an administrator's explicit candidate adoption can move it. Run branches remain publishable to the workspace, and aether pull remains the safe review path before a human merges locally and pushes the reviewed branch to checkout Origin.

Candidate verification and delivery

Candidate operations use the existing workspace capabilities, not a new integration role. Reading a candidate requires the caller's normal view authority; preparing, resolving, verifying, requesting delivery, and executing delivery use the existing Push capability. The service resolves the current member, workspace, run ownership, and candidate state itself. It rechecks the caller and the approved human approver at the actual delivery, so an old page, role change, or stale request cannot turn into authority. integration.decide is human-only, and an optional swarm identifier is context rather than a permission grant. The one delivery without a human decision is a swarm integrator's: its request is recorded as approved by the swarm's accountable human, who must hold Push when it is requested and again when it is delivered, and only while the swarm is active. Cancelling the swarm stops it.

Verification runs against a server-owned isolated candidate revision and a disposable verification tree; candidate inputs and retained evidence are not re-read from a mutable live checkout. The isolation protects the source tree and post-execution integrity check, not the credentials of the environment it runs in. A human's verification runs in their own environment; a run actor's runs in the home that run's container mounts, recorded on the run at launch, which a handoff does not change; it runs whether or not that container is still alive. That home's gh login, signing key, and other files are available to the verification, so do not treat it as a per-candidate credential boundary.

Delivery to a local workspace target is an expected-old atomic ref update. Delivery to a mirrored target must use the proposal action: it creates a public refs/heads/aether/proposal-<request-id> ref and a private receipt, without pushing the upstream or moving its protected mirror base. The proposal is labelled proposed, not landed; a human fetches and pushes it through the normal upstream review route. The mirror's read-only deploy key is only for server fetches and is never reused as a delivery credential.

Nothing here blocks native credential use outside Aether. An agent with a member home can still run its own git push, gh operation, or pull-request flow under that member's credentials, subject to the upstream's permissions. Candidate delivery's Push checks govern only the Aether-managed operation.

See teams.md for the operator flow, integration.md for the exact wire contract, and failure-handling.md for restart and cleanup behavior.

Hostile agents

If you run agents you do not trust, put the --data-dir on a filesystem mounted nosuid,nodev. Docker exposes no per-bind nosuid/nodev controls, so without that a root agent can plant a setuid binary through a writable bind mount and have it survive on the host. See the security note on ValidateMounts in internal/runtime/mounts.go.

Subscription quota reads

The read-only account.usage control method makes the server, not the browser, read native Claude Code and Codex OAuth files from the selected member home and call fixed vendor HTTPS usage endpoints. This is server-side token use for status reporting, not credential extraction: Aether never copies the credential bytes, refreshes or rewrites native OAuth files, or sends tokens or provider response bodies to clients. API-key logins and unsupported agents do not become quota collectors. Account selection uses the same explicit directional grant as launches; administrators do not gain implicit access, and membership/share authorization is checked before and after the provider read.

Launches read the same cached quota server-side to decide on usage fallback: for claude and codex runs, the requested account's, and every candidate's only when that one is exhausted. A run that hits its usage limit mid-run reads every candidate's. The read happens before the server takes its launch authorization lock, so a slow vendor endpoint does not hold up other launches; every account is authorized again under the lock, and a launch never sends the figures it read to the client.

The dashboard gateways

The dashboard runs over one of two gateways, which share their handlers and differ only in who they trust (internal/webgate is the shared core; local-gateway.md). An edge serves no dashboard.

aether gui, on the user's own machine

aether gui serves the dashboard from the user's machine and proxies the API shape over that machine's SSH connection to the linked server (internal/localgw).

  • It binds 127.0.0.1 and nothing else. There is no exposure flag; the listener is loopback or it does not exist. Nothing about the dashboard widens what the server listens on. A contributor testing on a phone can put the development proxy in front of it on a LAN address, which gives up this boundary for as long as that proxy runs; what that costs is spelled out in dashboard-frontend.md.
  • Every request needs a token, loopback included. HTTP cannot identify a member on its own and any local process can reach a loopback port, so the gateway mints a bearer token per process (32 random bytes) that every API and WebSocket request must carry - as Authorization: Bearer, or ?token= on WebSocket handshakes and the initial browser tab. The token dies with the process: there is nothing to revoke, and nothing survives a restart.
  • The full method map is reachable, not an allowlist. The identity is the member's own SSH key, held by the same process that serves the page, and the bearer token never crosses a network or lands anywhere shareable - it lives in one process and one local browser tab. A method call carries exactly the authority that SSH key already has from a terminal on the same machine, and every call still passes the same capability checks the CLI's calls do. There is no path by which the browser surface can exceed the person sitting at it.
  • /local/v1 executes with the user's own filesystem and git authority - link config, git fetch/push on the linked clone, systemd user units, scaffold files. That is the point of the surface: it does what the CLI does, for the person already at the keyboard.

The server's own listener, for tailnet devices

With web-port set, aether-server serves the same dashboard itself (internal/servergw, networking.md). It is the only HTTP listener the server has, and it exists only where a tailnet can identify its callers.

  • Tailnet addresses only, HTTPS only. It binds the host's tailnet addresses and nothing else, with the certificate tailscaled issues for the node's MagicDNS name. There is no cleartext port and no redirect, and a server that cannot fetch that certificate refuses to start.
  • Identity is WhoIs, per request, with no token at all. Every call and every WebSocket handshake is resolved through the same tailnet WhoIs lookup and member mapping the SSH none auth uses. Nothing is issued to the browser, so there is no credential to leak, copy, or forget to revoke: losing the tailnet loses the dashboard on the next request. A tagged node is refused 403, a failed lookup 503.
  • A cross-site page cannot act as the member. With no token, the browser's tailnet position is the whole credential, so the gateway refuses any request whose Origin is not its own host and any POST /api/v1 body not declared application/json; a foreign page can neither send the simple request that skips the CORS preflight nor pass the preflight, which the gateway never answers. WebSocket handshakes apply the same origin rule. Both gateways enforce it.
  • Who the tailnet address vouches for. WhoIs names the owner of the node the request came from, so any process on the server host that connects to the host's own tailnet address is served as the node's owner, usually the admin, with no credential; a device behind a Tailscale subnet router arrives as the router node and is served as the router's owner. SSH on :2222 has had exactly the same boundary since tailnet identity shipped; the dashboard adds no new one. Loopback, LAN and container addresses resolve to nobody and are refused.
  • The same capability checks, run by the same code. Each identified member is served in-process through internal/sshd's Local client, which runs the handlers an SSH channel runs - pending gating, per-method capability checks, the steer check on a shell tab, and the same live revalidation. A request carries what that member's SSH session would carry, no more.
  • No machine-local verbs. /local/v1 does not exist here: nothing on the server is the caller's own machine, so there is no surface that would act as them on it.
  • The Android app adds nothing to this boundary. The APK on every release (install.md) is a WebView on one origin. Of its own it stores, in app-private preferences, the connection mode, the server's address, the Edge gateway's loopback port and the update check's state (when it last ran and last got an answer, that answer's JSON line, since when the app has been behind, and the dismissed version), plus files/update-resolv.conf, the DNS servers the update check uses. Beside that are the WebView's ordinary cache of the dashboard's files and the dashboard's local storage of view preferences: no token, no cookie jar it shares with anything, no key, and no JavaScript bridge into the app. What leaves the phone, and where, is privacy.md. HTTPS is pinned in three places, so there is no way to point it at a cleartext listener: the address screen refuses a http:// URL, the app's network security config forbids cleartext for the whole process, and the WebView refuses mixed content. Certificate errors are never offered to the user to click through. A link or script navigation off the dashboard's origin, target=_blank included, is handed to the phone's browser instead of being loaded with the member's tailnet position behind it, and a scheme that is neither - an intent:// URL that would start another app with page-chosen extras - is dropped. WebView does not run that check for a POST, so a form on the page could otherwise submit to any origin. Two more gates catch that. shouldInterceptRequest runs before the request is sent and answers an off-origin main-frame request with an empty response, so neither the form's fields nor the member's tailnet position reaches the other origin. The refused navigation still commits, on that empty document, so onPageStarted puts the dashboard back; it hands nothing to the browser, because anything arriving there was not a link the member tapped. Subresources are untouched: those are the dashboard loading its own files. Nothing the app stores leaves it through Google's cloud backup or through device-to-device transfer: the app opts out of both, naming every domain it can store in, because the backup agent walks each one separately. In a release build the page's console output is not written to logcat, where any app holding READ_LOGS, or a connected adb, would read it. WebView Safe Browsing is turned off in the manifest. It matches each URL against a hash-prefix list held on the device and, on a match, asks Google about that 4-byte prefix - never the URL or the host. On a WebView that only ever loads the member's own server it protects nothing, because every other link goes to the phone's browser, which runs its own check; off is the setting under which nothing about a navigation reaches Play services at all.

Both

  • The SPA files are served without identity. The bundle is not secret and has to load before it can present anything; everything behind /api/, /ws/ and /local/ is gated.
  • Live sockets are re-checked by the server, not by the transport. The server re-runs its capability checks on every call and on each subsystem channel, and re-checks live attach, terminal, event and sync channels every few seconds. A write attach that loses the steer capability is dropped exactly as a CLI attach would be - the terminal view falls back to a mirror - and a member removed or set back to pending loses every open channel within that interval. On the local gateway the token cannot be revoked out from under a socket, because it lives and dies with the process serving it. On the server gateway a socket keeps the member it was opened as; the tailnet is asked again on the next request or reconnect, and the server's revalidation is what ends an open socket when the membership itself is removed or set back to pending.
  • Every socket is pinged every 30 seconds and closed when the pong does not arrive within 10. A phone that changed networks or went to sleep leaves a half-open connection that reads as live on both ends; without the ping it would keep holding a PTY client whose geometry clamps every other viewer.

Push notifications

A member's push subscription names a URL the server will post to (networking.md), and it arrives from that member's browser, so the server treats it as untrusted:

  • Only an https endpoint with a valid P-256 key and a 16-byte secret is stored, and a member holds at most 16.
  • The server connects only to public addresses. Loopback, private, link-local and carrier-grade NAT addresses, which cover the server's own host, its LAN and the tailnet, are refused when the connection is dialed, after DNS has answered. Proxy settings are ignored so that this check sees the real peer.
  • A member can test, replace and remove only their own subscriptions. push.test returns the push service's status and the first 512 bytes of its answer, to that member alone.

The payload is encrypted to the browser (RFC 8291) and the request is signed with the server's VAPID key, <data-dir>/push/vapid_key.pem, mode 0600. That key identifies the server to the push services and grants nothing on the server itself. No dependency was added for this: it is the Go standard library's ECDH, HKDF, AES-GCM and ECDSA, checked against the example message in RFC 8291. The worker the dashboard registers, /sw.js, has no fetch handler, so it never sees a request or a response.

Terminal image uploads

terminal.image accepts image bytes, not a client path. The dashboard sends only the bytes in a user-selected browser File (including an actual image File from native paste), or the picture of a page element a member annotated in a run's Browser, cut from the frame on their screen; a text clipboard value that happens to be a local path remains text. There is no RPC that asks the server to read an arbitrary client path. In a terminal, the upload action inserts the returned shell-quoted path without pressing Enter or running a command.

The web gateway permits a 12 MiB request for terminal.image to leave room for base64 and JSON framing. The server validates the decoded bytes as a non-empty PNG, JPEG, GIF, or WebP image no larger than 8 MiB, then writes a generated .aether/terminal-images/image-<random>.<ext> file with mode 0600 in the persistent member home the target container mounts as $HOME, not in a workspace checkout or source tree. The returned absolute path is the path visible inside the target container at its $HOME; the client cannot choose the destination or filename. An upload with no run_id targets the authenticated member's running environment. A run target requires Steer and writes into the home that run's container mounts: its launcher's, also on a shared account.

These files follow member-home retention: stopping or resetting an environment does not remove the home, so images remain until they are removed from that home or the member is deleted. A member-home bind mount is not part of env.save's Docker image, so terminal images are not copied into the saved environment image. Like every file in that home, the images are readable in each container that mounts it: the member's environment and the runs they launch.

terminal.file takes any other file a member attaches to a run message on the same terms: bytes only, the same targets and Steer check, a 96 MiB request for at most 64 MiB decoded. The content is not inspected. The server writes it with mode 0600 as .aether/terminal-files/<random>/<name>; <name> is the original name with /, \ and unprintable characters replaced and leading dots dropped, so it stays one component inside its generated directory. An Enhanced agent receives the file's container path as an ACP resource_link, not its bytes. Retention is the same as for images.

Chat image delivery and previews

Enhanced chat keeps validated attachment references separate from prompt text. The server reads the saved images into ACP image blocks only when delivering the prompt; agents without image support are refused rather than given a filename fallback. The conversation log keeps short room-message references, not image bytes.

Preview requests name a run, persisted message and attachment index. The server checks their association and current View permission, and repeats authorization checks while streaming. It resolves the stored reference only within the run's persisted home member, including after handoff or container shutdown. Reads reject arbitrary names, symlinks, hardlinks, non-regular files, invalid images and files over 8 MiB. MIME types come from validated bytes.

The dashboard fetches previews through the authenticated gateway and displays local object URLs; it never loads an arbitrary URI supplied by the transcript. Deleting the message or run, removing the home file, or losing View access prevents another download. Existing member-home image retention still applies.

Agent configuration and Files

aether config push uploads agent configuration from the computer the CLI runs on (Upload agent configuration). The dashboard shows only the prompt that asks a local agent to run it; it reads no local files. The CLI reads the agent's root from config.roots and refuses an unknown agent and any path outside the agent's directory. The root's credential_names combines shared denials and the agent's DenyNames; names match any path component case-insensitively. Those names, *.pem, .env, and .env.* files, .aether-profile-ignore, and the root's runtime_ignores (case-sensitive patterns anchored at the agent's directory, each covering a path and everything below it, with * matching within one name) are left out before the CLI reads a file. Symlinks are not followed, special files are skipped, and files over 64 MiB are never read. The CLI then runs the server's secret scanner on each remaining file and does not send a flagged one; there is no override. None of these client checks replaces server validation: the server enforces the same credential, runtime, and .aether-profile-ignore policy and scans every uploaded file again. A push does not replace a server file with different content unless it is run with --overwrite, and the server refuses such a file from any request that does not set overwrite; aether config cat prints the server's copy first, subject to the same credential and runtime policy. There is no watcher, automatic configuration synchronization, or automatic retry.

A config.import request carries at most 2,000 files and 64 MiB decoded, and each file at most 64 MiB; the CLI sends batches of about 20 MiB. A request that fails after writing files returns the exact committed paths and the error, and the server reports them even when a preflight failure confirms zero writes. A call that returns no result has an unknown outcome; the CLI reports that batch as unconfirmed and stops. The shared HTTP gateway permits a 96 MiB request for config.import, 385 MiB for config.write and files.write, and 1 MiB for ordinary methods. Decoded file and import-request limits remain authoritative. The authenticated SSH control channel caps a JSON line at 96 MiB; the larger framing budget does not remove decoded import bounds or filesystem validation. Before reading an import body, each HTTP gateway admits at most two imports; admission lasts through backend processing and the response. Excess requests receive HTTP 503 without their bodies being read. Body reads expire after 30 seconds without progress or 15 minutes total and return HTTP 408. HTTP admission, body, and JSON-validation refusals carry error.data.config_import_not_started: true before backend dispatch. A status code or message alone is not no-write evidence: backend or transport errors, including HTTP 503 without that marker, retain an unknown outcome. SSH control frames that grow beyond 64 KiB require admission: at most two globally and one per member, held through dispatch and the response. Small control requests do not consume those slots. Partial-frame reads have a 30-second idle timeout and a 15-minute total timeout. Expanded frames retain the total timeout through dispatch and response; complete small requests do not. Expiry closes the offending SSH connection, including its other channels, so a peer cannot retain the large buffer by ignoring channel closure. Idle channels between frames do not start a frame timer.

New files get the permission bits the request carries, 0644 when it carries none; existing modes are preserved even when the server uses a restrictive umask. The server rejects unsafe paths, symlink components, hardlinks, and nonregular destinations, and retains directory and staged-file ownership. Every config.* method requires the Launch capability and targets only the authenticated member's own home; an admin cannot select another member or account.

Files reaches the whole home, not only agent directories. Its Home tree lists every file and folder under ~ and creates, renames, moves, deletes, uploads, and downloads there through the home.* methods (local-gateway.md). They require Launch, target only the caller's own home, never follow a symlink, and keep the hardlink and ownership rules above. The credential, runtime, and secret-scanner exclusions apply to aether config push only: in Files a member acts on their own home deliberately, as they can in its terminal. home.upload has the 96 MiB request limit of config.import and home.write the 385 MiB of config.write.

Credential files are listed in Files and locked: home.read, home.download, and home.write refuse a path with a credential name of any shipped agent, the name .env or .env.*, or a .pem suffix in any component, anything under ~/.ssh except *.pub, and ~/.config/gh/hosts.yml, ~/.git-credentials, and ~/.netrc. The lock keeps credential bytes out of the browser and out of downloads. home.rename refuses a locked file, and a folder that holds one of those fixed paths, because a renamed copy would be served; home.remove and home.upload work on a locked file, since neither returns its bytes. It is not a boundary against the member: the Environment terminal reads the same home, and a credential stored under any other name is not recognized.

Uploaded and edited files are in the member's shared read-write home, mounted into that member's environment and the active and future runs they launch, including runs on a shared account. A member's account share does not expose them, except the ~/.omp/agent directory an omp share mounts; otherwise only the agent login path is shared (Account sharing). The home is not a per-run isolated configuration copy. A snapshot pin records optional launch provenance, not an isolation boundary or a promise that home edits wait for later runs. Uploads and Files edits do not create or update CLI snapshot history; the HOME persists independently. Manual profile push and rollback overlay snapshot files into that same HOME and leave paths absent from the snapshot untouched. Rollback is not an exact-tree restore. Files edits do not rebuild the installed-agent image.

The Files editor accepts complete UTF-8 text without NUL bytes up to 64 MiB. Binary and oversized files are read-only. Run and configuration saves recheck SHA-256 revisions immediately before atomic rename under Aether's root lock; base-branch commits compare-and-swap the branch head. These locks do not exclude arbitrary live-agent filesystem writers. A stale or failed save leaves the browser draft available. Later uploads do not replace open editor buffers; Reload from server explicitly discards a draft and reads remote content.

SSH into a run

ssh <run-id>.aether (terminal.md) gives a member a shell, file transfer and port forwarding in a run's container. It is a second door into the place a run shell already reaches, not a wider one.

  • Admission is the run shell's. The aether-run-ssh subsystem asks exactly what a writable shell tab asks (developmentAuthority in internal/sshd, then the scheduler's development authorization): current membership, Steer on the run, access to the run's backing account, and a running or needs-attention run that is not paused. The check runs again for every session and every forwarded connection, and every three seconds for the connection as a whole, which is closed once the member fails it or the run has finished or is being closed. It fails closed: a check that cannot complete, such as a store error, closes the connection when it happens three times in a row. A pause closes nothing: the connection's processes are frozen with the container, and it starts nothing new until the run resumes. A refusal carries the server's reason. Each connection a member opens to a run they have no other SSH connection to is stamped on the workspace timeline under that member, and refused when the timeline cannot record it.
  • A session in a swarm worker takes its hold. A person working in a worker's checkout is the intervention the swarm takeover hold exists for (coordination.md), whichever door they came in by. Starting a shell, a command or a file transfer over SSH commits the hold a writable shell tab commits, after the admission above and before the process starts: the integrator's input to that worker is refused until a person releases the hold, the way a shell tab's is released (terminal.md). A session whose hold cannot be recorded does not start. Port forwarding alone takes no hold, and disconnecting releases none. The server says so when the connection opens, and aether ssh --stdio prints it on stderr; the Open in editor… dialog says it before that.
  • The run asks for no second credential. The member is authenticated by the connection the aether CLI makes: tailnet identity, SSH key or edge device (networking.md). The SSH connection the member's ssh client speaks is nested inside one channel of it and is reachable no other way, so it accepts any user name and no key. It presents the server's own host key, and the server names that key when it admits the connection. The CLI accepts the name only if it is the key it verified on its own connection, then writes it to ~/.ssh/aether_known_hosts, which is why ssh can check it strictly without ever prompting.
  • No sshd, no port and no key in the container. The server speaks the SSH protocol itself and starts each session as an owned execution in the container, the way run shells start. Nothing listens for it in the container or on the host.
  • The server picks the container. It resolves the run id to its supervised container after authorization; a client names no container, path, user or host. A session runs as that container's user with its filesystem, network, home and credentials, so what Remote development says of a run shell holds here: it is not a restricted sandbox, and it is not a way out of one. There is no path to a shell on the server host, to another run, or to a container the member could not open a shell in.
  • Forwarding stays in the run. ssh -L, -D and -W are accepted for localhost and loopback addresses only, and the connection is dialed by a process inside the run's network namespace (aether-internal dev-exec forward), never by the server. It cannot reach the host's loopback or another run. The run's Chromium companion (Remote development) shares that network namespace, so a forward reaches a loopback port the companion listens on exactly as a process in the run does; Chromium itself is driven through a pipe and listens on none. Remote forwarding, agent forwarding and X11 are refused.
  • A session owns its processes, for as long as the server runs. When a session ends, its connection drops, or its authorization is withdrawn, everything it started is stopped. A stop that does not land is tried again, five times, and only a container Docker reports gone counts as nothing left to stop; after the last failure the server logs sshd: ssh execution left running and the process stays. A frozen container refuses that stop, so for a paused run, and for a closed run whose container is kept, the server holds the stop and delivers it when the run resumes or is relaunched. The pending stop lives only in the server's memory. A restart while it is still owed, whether a shutdown, a self-update or a kill, forgets it: those processes thaw with the container, owned by nobody, and run until they exit or the container stops, even if the member who started them has since lost access. A shutdown waits for the stop of every open session in a running container to finish before it exits, and one that failed then is among the stops the restart forgets; a killed server stops nothing in any container. Only destroying the container, Free container… on a finished run, is certain to end them.
  • Bounded. A run takes 16 SSH connections, at most 8 of them from one member, so no member can keep the others out; a connection takes 10 sessions and 64 forwarded connections. Every process runs inside the run's container, under its CPU, memory and process limits. Output is never dropped: the server holds at most 256 KiB of each stream a client has not read, and past that the command blocks. An open SSH connection holds a scheduled server update back as an attached terminal does (install.md), one into a paused run included.
  • Not recorded. An SSH session has no transcript and no control lease. Teammates see that a member connected, not what they did.
  • The member's ssh needs OpenSSH 9.6 or a build patched for CVE-2023-51385. ssh runs the ProxyCommand through the member's shell with the host name substituted in, and before that fix it substituted any name. On such a client a crafted *.aether host name, from a link or a git submodule URL, can run a command on the member's computer, as with any ProxyCommand that names %h. The block quotes %h, which on those clients leaves a quote character in the name as the only way through. aether ssh --stdio itself refuses a host that is not a run id plus .aether before it reads the link or dials anything.

An editor trusts the machine it attaches to. An editor attached over SSH runs its own server on the remote and acts on what that server sends back. Here the remote is a run's container, and the agent in that run controls everything in it. The Remote - SSH extension's own Security Note says to connect only to remotes you trust, because a compromised remote could use the connection to execute code on your local machine; Cursor and Zed attach the same way. So opening a run in your editor extends your computer's trust to that run's agent and to whatever it has installed or fetched. Attach to runs whose task and repository you would run on your own machine, and use ssh or a run shell for the rest: a plain ssh session gives the container a terminal and nothing more.

The desktop app lets one kind of link leave its window besides http and https: the Open in editor… links, matched in full as vscode://vscode-remote/ssh-remote+<run-id>.aether/workspace, the same under cursor://, and zed://ssh/<run-id>.aether/workspace. Links in agent output pass through the same filter, so the worst such a link does is open an editor on a run the member can already connect to. Every other use of those schemes is dropped.

SSH port forwarding

aether forward and the dashboard's OAuth forwards open channels on the CLI's own connection, not through ssh. There, port forwarding is limited to direct-tcpip channels whose destination is run:<run-id> or exactly terminal. Run targets require the Steer capability for the authenticated member; the terminal target resolves that member's own live environment container and requires current membership. The server revalidates that authorization while the channel is open and closes the tunnel when membership or Steer is withdrawn. It resolves addresses itself and dials only the requested container port. Arbitrary hosts and ports are not targets, and reverse forwarding is disabled: global forwarding requests are denied.

The local dashboard recognizes OAuth authorization links whose redirect URI is an HTTP loopback address. It binds the matching local callback port before opening the authorization page, then forwards that port only to the terminal where the link appeared. Other links keep the normal browser behavior.

Edge remote access

The edge (edge.md) is an identity broker, not an authority. The server believes it about who signed in; whether that admits a new device is the server's access policy, and membership, role and device status are always the server's own. edge.md tabulates what a taken-over GitHub account and a compromised edge can do under each policy, with the tests that show it; the reasons follow.

  • A server uses an edge only when its operator chose one. edge-url is empty unless the config file or a flag names an edge. aether-server setup is the only command that sets it without being told: on a host without tailscaled it turns on the project's edge and prints what that edge sees and how to turn it off; with tailscaled it asks, defaulting to no. An upgraded server whose config never named an edge stays off it and discloses nothing (TestIntegrationUpgradeFromMainWithoutTheEdge).
  • The server trusts an edge by its signing key, not its host name. It keeps one pinned edge key, whatever edge-url names, and its owner per edge key. A new host name of the same edge keeps both. A different key, at any host name, is refused until sudo aether-server edge trust pins it after a person compared fingerprints; the server then has no owner for that key and reports itself ownerless to an edge that records one. Changing edge-url alone never makes the server trust another key.
  • SSH is end to end. The relay splices bytes between two outbound WebSockets and never holds a key that could decrypt them. Clients check the host key against the server id on every path, so an edge cannot pose as a server.
  • Grants are checked by the server. Each relayed connection carries a grant signed by the edge key the server pinned at enrollment. The server checks the signature, its own id, the connection id and a 60-second lifetime, and refuses a connection id it has seen. The key the client offers must be the grant's device key, so a stolen device token alone authenticates nothing.
  • Membership stays on the server. The server maps the account to a member itself and checks the device. A compromised edge can forge a grant for any account, but that account gets in only as a member or through an open invitation the server holds. The SSH user name of a relayed connection names the account the device signed in as, under the device's own signature, and the server refuses a grant naming another before it records anything; so an edge cannot file the key of a device it relays under another account unless it also lied to that device at sign-in. Under approved-devices a grant for an invited account changes nothing on the server but a device waiting on the invitation: no member, role or bound account exists, and the invitation stays open, until a person approves that device. Approving it creates the member and uses the invitation in one transaction; the other devices waiting on it are deleted.
  • The access policy decides what a grant is worth. Under edge-access account, a grant for a member's account admits a new device of that member: an attacker gets in when either the member's GitHub account or the edge is taken over. Under approved-devices, every new device key, a member's first included, waits until a person approves it: the member from an approved device, SSH key or tailnet connection, an admin, or sudo aether-server device approve <code> on the server. An attacker then also needs an approver to type the code their device shows. The policy is set only on the server's host, and a missing setting means approved-devices. Under either policy a device that only signed in cannot mint an invite code, a bearer credential that would outlive a switch to approved-devices. Neither policy assumes OAuth is weaker or SSH keys stronger; they differ in how many parties must fail.
  • An approved device key is a credential on its own on the direct path. The direct SSH port admits approved device keys, under both policies, without asking the edge, as it admits a member SSH key. Through the edge the key also needs the device's token or the edge.
  • Approval codes are typed, not listed. The code is derived from the device key and shown to that device inside SSH; aether device list never carries it, so approving proves the approver was handed the code of the device in front of the person. Approve only a code read from a device you are holding. Two keys can derive one code; such a code approves neither, and sudo aether-server device review approves by choosing the device instead. An approval from a connection that signed in with an unapproved device is refused under either policy.
  • An approver sees what a code admits before approving. The member a waiting device belongs to follows from the account it signed in as, which the edge vouches for, so a compromised edge, or someone holding a member's GitHub account, can make a code admit their own key as that member. aether device approve, sudo aether-server device approve and the dashboard first look the code up (member.device.lookup), show the device, the account, and the member and role approving admits it as, and approve only after a yes; member.device.approve takes the device id the lookup returned and refuses a code that names another. Someone handed a code that admits their own member, or an admin, should refuse it.
  • Waiting devices are bounded. A relayed connection with a new key records a device. The server keeps at most 10 pending devices per account and 10 per invitation, and refuses more with a banner naming aether device list and sudo aether-server device review.
  • Enrollment signatures cannot become host signatures. The host key signs aether-edge-enroll-v1\x00, the edge origin, the server id and a nonce. That message is longer than any SSH exchange hash, so an edge that collects it cannot use it in a handshake.
  • Claims travel inside SSH. aether link --claim opens a claim connection through the edge and offers the code, with the account this device signed in as, in the SSH user name. An SSH client sends the user name only after the key exchange has checked the host key, and the client accepts only a host key that derives the server id in the code, so the code reaches that server encrypted and nothing else. The client's SSH signature covers the user name, and the server refuses a claim whose grant names another account before it tries the code. The edge relays the bytes without reading them; to take a claim it would need the code, which only the server's console printed. An edge that also lied to the client about who signed in could make the claim for its own account: the person then has to notice the account shown by aether login, aether link --claim and the dashboard's claim form, which is the one the edge reported, before the code is sent.
  • Console recovery restores an existing admin and nothing else. sudo aether-server edge claim-code --admin <member id> issues a code whose claim binds the claiming account to that admin and approves the claiming device. The code is refused for a member who is not an admin, when issued and again when used; it creates no member, raises no role, and cannot move an account that is another member's. The server logs each use.
  • One account of a member can be removed. An account linked to a member while someone else held it is unlinked with aether member unlink by the member or an admin, from a credential a person approved: the devices that signed in with it are revoked, not deleted, so their keys stay refused if the account is linked again.
  • Sign-ins are joined only by proof of both. Nothing joins two sign-ins because an email, login or name matches. aether member link binds a GitHub account to the caller's own member only once a device signed in with that account opens the join link, is told This links your github account <login> to the existing member <name> (<id>), role <role>; you will act as that member., and the member approves that device, under either policy; a member has one open link at a time. A member merge takes both sides: a code minted on one sign-in, shown only there, kept as a SHA-256 hash, 80 bits, used once within 10 minutes; a proposal from the other sign-in; and acceptance back on the first, which recomputes the plan under the lock that member and role changes hold and refuses one that differs from the proposal. No step is taken from a device no person approved, under either policy. The merged member never gains a permission: it takes the lower role, and stays admin only when the admin side asks for it, which an admin can already grant. When it is no longer an admin, the invitations, links and invite codes either member created are revoked (an invite code from an older server, which did not record who minted it, lasts until it expires), and a merge that would leave the edge owner's account on a member who is not an admin is refused. The absorbed member stays as a tombstone that authenticates nothing, so history keeps naming it. A merge keeps an audit record with every setting it discarded and moves the absorbed home, live agent logins included, to <data-dir>/merged/<merge id>/, owner-only and outside every member's home and Files view, for the machine's administrator to remove. A merge code you did not ask for should not be entered: it proposes joining your member with the code's.
  • The first link decides which server a client pins. Every later connection must present a host key that derives the linked id. An id from the server's admin, or from sudo aether-server edge status, rules out being sent elsewhere. An id from aether servers comes from the edge, and an edge that lists a false id can send that first link to another server; aether link --from-edge and the wizard show the id and host key fingerprint and ask before they pin it.
  • Device tokens go to two origins only. A token is sent to the relay origin it is stored under and to the sign-in origin that issued it, never to an origin the edge's metadata names later, and the client follows no redirect.
  • The edge stores bearer secrets hashed (device tokens, session cookies, device codes) and rate-limits sign-in, device codes and claim connections per address; claim connections also per account and per server. An IPv6 address counts against its /64, its /56 (4 times the budget) and its /48 (16 times), so one allocation cannot spend more than its /48's share; a full limiter evicts the block with the most budget left instead of refusing new addresses. Device-code entry is also limited per account. The address a relayed connection came from is for logs and rate limits only, never for authentication. Behind a reverse proxy the edge reads the address from the right-most X-Forwarded-For entry, and only when the connection comes from a loopback address. With --trusted-proxies it takes the right-most entry outside the named networks, only from a peer inside them, and refuses every other peer, loopback included; a list that trusts every address is refused. A request whose header is missing or not an IP address is refused. The packaged nginx configuration sets the header to the address nginx saw, discarding the client's.
  • Sign-in and relay are two host names. The sign-in cookies are __Host- cookies with no Domain attribute, bound to the sign-in host alone, and each host refuses the other's paths. A server's ownership is recorded only from that server's own report on its control connection, for the account whose claim connection presented the code; no client request or operator command records an owner or approves a device.
  • Deleting an account needs a fresh sign-in in the same browser. The edge deletes an account only on its Account page, from a browser that itself signed in with GitHub in the last 5 minutes, with the login typed back. A device token cannot delete it, and neither can a session cookie from a browser that has not signed in since, even after the person signs in elsewhere. Deleting takes the person's GitHub sign-in, or a cookie stolen within 5 minutes of its sign-in.
  • Invitations match only a recently confirmed login or email. The edge learns an account's GitHub login and verified email only when that person signs in with GitHub in a browser. Signing in takes the login from any other account that held it, but someone who renamed on GitHub, or whose email moved to another account, keeps the old value until they sign in again. So an invitation matches an account only within 24 hours of its last sign-in: the edge refuses such an account with your login and email were last confirmed over 24 hours ago; open this edge in a browser to confirm them, then retry, any edge page opened later asks GitHub again, and the server checks the same age in the grant, which carries when GitHub last confirmed the account. Within those 24 hours an old login or email can still match. Members match by GitHub's immutable user id and are unaffected.
  • Recovering a GitHub account removes nothing at the edge or on a server. Device tokens do not expire, so devices signed in while the account was taken over keep working until revoked (edge.md).
  • Only self-hosted servers exist. The protocol reserves a hosted kind for a workspace Aether would operate. Such a workspace would not be protected end to end against Aether's operators, who would hold its host key and data (edge.md).

What the edge operator does see: server ids and host names, account identities, device labels, client addresses, timing and byte counts (privacy.md).

Conflict coordination

Each container may receive a run-scoped unix socket that runs in one workspace use to coordinate, for example when the radar finds two of them editing the same file. Detail is in docs/coordination.md (host side and wire) and docs/mcp-bridge.md (the optional in-container bridge); the operator-facing stances are these.

  • Binary availability is not run identity. The canonical /usr/local/bin/aether-internal CLI is an Aether-provided, version-matched executable available in managed containers, and the staged server binary may also be present for the optional bridge and lifecycle plumbing. Neither path authenticates a caller. The socket at /run/aether/coord3.sock is the run identity: a connection accepted there is treated as that run. An identity-less environment or container can use general help or non-run skill guidance, but status, messaging, reporting, and swarm operations are unavailable.
  • The mount is the authentication, so no token enters a container. Each run gets its own socket; there is nothing inside the container to steal, and nothing to rotate. The host-side modes (0700 on the coordination root, 0755 on the per-run directory, 0666 on the socket, 0444 on the config and the co-author list, 0555 on the staged binary) are a contract with a semi-trusted container that may not run as root - they are not the access control. Both container paths are reserved: runtime.ValidateMounts refuses any caller-supplied mount that targets or nests under them, so a credential home cannot shadow either.
  • Disabling coordination still disables coordination. With --conflict-coordination=false, the read-only canonical CLI mount remains available, but no usable run socket, borrowed run identity, or MCP bridge is available. Run-bound CLI calls and bridge calls return unavailable; the identity-free CLI can still provide general help and non-run skill guidance. The overlap radar remains active.
  • The run socket exposes no general control verbs. Its six advertised coordination methods are coord.status, coord.send, coord.inbox, coord.ask, coord.reply, and coord.report. Native hooks also use the internal read-only coord.hook.status endpoint, with the same run identity and status authorization but an independent bounded request budget. Malformed envelopes and unknown methods still consume the ordinary budget; the hook endpoint cannot dispatch mutations. A swarm-assigned run additionally receives only the current assignment's task.* and worker.* methods over that same run-authenticated socket; those methods are not a general control API. There is no run.kill, no Git access, and no other run's transcript. Messages are capped at 4 KiB, rate-limited per run, bounded at 100 unread per inbox, and every one is recorded on the workspace timeline. The optional bridge is manual and still exposes only its six existing tools; it is not automatic registration or a Release B mission/worker interface.
  • Three routes authorize a new conversation, none across workspaces. coord.send and coord.ask are accepted when the target is a current swarm assignment peer, a radar active/grace overlap peer, or a live run in the sender's workspace that the sender run's owner may message. Every route first requires the sender run's owner to be a current member who is not a viewer. The sender and target must share a workspace on every route, and a finished target is refused. coord.reply is authorized by the question it answers.
  • Messaging is its own permission. The workspace route checks the message capability for the sender run's owner against the target run. Admins may message any run; a viewer's runs may message no run on any route; collaborators may message any run unless it is protected, and a protected run (aether protect) accepts workspace-route messages only from its owner's runs and admins' runs. steer_others=admins_only (the message-others policy) limits workspace-route messages to another member's run to admins' runs the same way. The swarm and radar routes check neither protection nor that policy. The owner's role and the workspace policy are re-read on every send and status, so a demotion applies to the next message. coord.status omits runs the sender may not message.
  • A message grants nothing else. It is an attributed inbox item (from_run_id), never an instruction. It confers no steer, kill, input injection, terminal, transcript, Git, secret, or task and worker authority over the recipient, and the socket's method set stays closed.
  • A run can widen its own peer set, and the cap is what bounds it. A run that touches every tracked file is reported as overlapping with every other run in the workspace, and the workspace route already reaches every live run its owner may message. The server limits each run to 8 distinct correspondents opened over the radar and workspace routes instead of trying to infer intent. Swarm assignment peers are server-derived and do not count, so an integrator reaches every worker it dispatched. A run already sent to stays reachable at the cap. Read this as defence in depth, not a boundary: runs in one workspace already share a repository, so influencing each other through file contents needs no authorization at all. Turn the feature off if that is not acceptable.
  • The staged bridge binary is the server's own binary. It is mounted read-only at /opt/aether/aether-server so a container can run the optional MCP bridge without shipping an extra artifact. A container therefore holds a copy of the server's code and can run its available subcommands. This grants nothing new: the binary carries no credentials, reaches no host state that the container was not already given, and the isolation is still the container, exactly as in "The agent container" above.

Sysbox install

When an admin presses Install under Settings > Server (server.sysbox.install), the server runs its embedded install-sysbox.sh as root, the user its systemd unit runs as. That is the same installer sudo aether-server sysbox install runs, and it is part of the signed release binary: the client sends no script, URL or version. It downloads the pinned Sysbox package, verifies its SHA-256 and installs it with apt-get, in a transient systemd unit named aether-sysbox-install when the host runs systemd; it may also add bip and default-address-pools to /etc/docker/daemon.json. A server that does not run as root answers off and names sudo aether-server sysbox install. While it runs, a server update is refused with a conflict error. Only admins can call either Sysbox method. An admin can already run arbitrary code on the Docker daemon, so this adds root only for this one script.

Release signing

GitHub release assets are mutable: anyone who can write to the repository's releases can replace a binary and the checksums.txt beside it. Updaters therefore trust a signature, not the release page. Each release carries aether-release.json (version, commit, store schema, and every asset's SHA-256 and size) and aether-release.json.sig, an ed25519 signature over the file's exact bytes. Every updater (aether update, the dashboard's Update now, aether server update) verifies the signature against the public keys compiled into internal/selfupdate/keys.go, requires the manifest's version to equal the tag it installs, so an old signed manifest cannot be replayed under a new tag, and checks every download's size and SHA-256 against it. install.md shows the same check done by hand.

The private key is a 32-byte seed, base64-encoded, held only in the AETHER_RELEASE_SIGNING_KEY Actions secret. Only the final release job reads it, after the publisher and exact-main-CI gates. The generator refuses to write a manifest the tag's own trusted keys would reject, so a wrong secret fails the release, not the clients.

Key rotation. Clients trust any key in the list, and a client only learns a new key by installing a release that carries it. The generator also refuses a seed whose public key is not in that release's own list. To rotate:

  1. Generate the next key pair offline and add its public key to releaseKeys in internal/selfupdate/keys.go, beside the current one.
  2. Publish that release signed with the current key. It is the bridge: every client accepts it, and it trusts both keys.
  3. Replace the AETHER_RELEASE_SIGNING_KEY secret with the new seed and remove the old public key from releaseKeys. Every later release is signed with the new key.

A client older than the bridge rejects every later release. Install the bridge on it first with aether update --version <bridge tag> (or aether server update --version <bridge tag>), then update normally, or reinstall it with the install script.

If the current key leaks, run the same steps at once: the bridge has to be signed with the leaked key, because no client trusts anything else, and the next release drops it. A key cannot be revoked remotely, so a client that has not installed that next release still accepts a manifest signed with the leaked key from anyone who can also write to the releases.

Server self-update

aether server update (see install.md) lets an admin replace the running server's own binaries and restart onto them, from their laptop, with no shell on the server box. That is inside the existing trust model, not outside it: an admin can already run arbitrary code on the server's Docker daemon through environment builds, so choosing which release binary runs grants nothing new.

What bounds it: the version a client supplies is validated as a release tag

  • v plus semver - and only ever names a release in the pinned 3xDevOps/Aether GitHub repository; the client can never supply a URL.

Both binaries are downloaded and verified against that release's signed manifest (Release signing) before either is replaced, and each is then renamed into place from a staging file in its own directory. So a bad tag, a network error, or a checksum mismatch leaves both binaries exactly as they were. Only the renames at the end could leave aether-server updated and the aether beside it not, and a rename within one directory fails only when the filesystem does; the recorded failure then names which binaries were already replaced. aether server update --status shows it.

Client self-update on macOS

The dashboard's Update now button runs from aether gui, an unprivileged process. On macOS it can still replace a CLI in a directory the user cannot write, such as /usr/local/bin (install.md, local-gateway.md). No Aether code runs as root; root is left exactly one thing to do.

When the dialog is offered. Only when the directory is not writable by this user, only root can write the binary's directory or any directory above it, this is macOS, and the gateway is in a GUI session. Otherwise the banner shows sudo aether update. The full rule, and why the privileged command depends on it, is in local-gateway.md.

What runs as root. One sh command of system tools, addressed by absolute path so nothing is looked up on root's PATH, with the staged file, the destination and the release digest baked into its text (internal/macinstall). For /usr/local/bin/aether it is, verbatim:

CODE
set -e; t=$(/usr/bin/mktemp '/usr/local/bin/.aether.update.XXXXXX'); trap '/bin/rm -f "$t"' EXIT; /usr/bin/install -m 0600 '/Users/you/Library/Caches/aether/update/.aether.update-123456789' "$t"; h=$(/usr/bin/openssl dgst -sha256 "$t"); [ "${h##* }" = '<sha256 of the release asset>' ] || { /bin/echo 'copied binary does not match the release checksum' >&2; exit 65; }; /bin/chmod 0755 "$t"; /bin/mv -f "$t" '/usr/local/bin/aether'

Only the staged file's name and the digest vary. /usr/bin/osascript -e 'do shell script "<command>" with prompt "<text>" with administrator privileges' runs it, with the environment PATH=/usr/bin:/bin:/usr/sbin:/sbin, LANG=C, and HOME, working directory /, and nothing else from the user's shell - no TMPDIR, no DYLD_*, no Homebrew PATH. The command is decided before the user is asked and cannot change after: what the dialog authorizes is that text.

Three checksum checks, in three places. The gateway downloads the release as the user and compares it with checksums.txt before anything is staged. Root hashes its own copy and exits 65 on a mismatch, so a staged file swapped while the dialog is up installs nothing. The gateway then re-reads the installed file as the user - a regular file, mode 0755, root-owned, the release digest - before it rebuilds the desktop app or exits; a mismatch there is reported as installed /usr/local/bin/aether does not match the release checksum; do not run it.

0600 until verified. Root copies into a temp file in the destination directory, which the user cannot write, and keeps it 0600 until the hash matches. A staged file replaced with a symlink to a root-only file would be copied, fail the hash, and be removed without ever having been readable. The final step is mv -f within one directory: atomic, and a running aether keeps its old inode.

The staging directory is private. The download goes to <user cache>/aether/update (~/Library/Caches/aether/update), created 0700, and refused when the path is a symlink, not a directory, or owned by another user, because root reads from it. install copies the staged file; root never executes or renames it.

No password passes through Aether. The dialog is macOS's own; the password goes to the system's authorization service and is not read, stored, piped, or logged by any Aether process. macOS asks on every click: each click runs a new osascript process with a new command text (the staged file's name differs), and Apple's TN2065 says the authentication applies to that specific script text. Nothing is cached across clicks by Aether or by the system for this right - system.privilege.admin is not shared across processes.

Why the dialog says osascript. The dialog is titled osascript because the app is built locally and unsigned. A dialog in Aether's own name needs a privileged helper installed through SMJobBless or SMAppService, and both require a Developer ID signed helper. Aether's prompt text sits beneath the title and says so, naming the file and the version being installed.

What a same-user process can still do. A process running as the same user can write the staging directory and can kill or block the gateway. That lets it make the root step fail - a swapped staged file fails the hash - or stall it, by putting a FIFO where the staged file was so root's install blocks on the open. Both are denial of service against this user's own update, not escalation: nothing that process does can make root install bytes other than the ones whose digest is in the command text. That rests on the root-only path rule above: the directory root writes into, and every directory above it, is root's alone (local-gateway.md).

Dependency and toolchain vulnerability scanning

make vulncheck runs govulncheck over the whole module. CI runs it in the lint job. A docs-only pull request does not run it, because it does not change module dependencies.

The step is advisory (continue-on-error: true), not a gate. Two reachable Moby CVEs in the Docker SDK (GO-2026-4887, GO-2026-4883) have no fixed release, and govulncheck has no way to suppress an individual finding, so a hard gate would leave CI permanently red and train everyone to ignore it. Read the step output on each PR instead: anything beyond those two Docker findings is new and should be fixed or explicitly accepted here.

The SSH dependency golang.org/x/crypto must be at least v0.56.0 to fix GO-2026-6303, GO-2026-6354, and GO-2026-6355. These fixes require Go 1.26; the module and toolchain requirements in go.mod must not be downgraded independently of the dependency.

The Go toolchain is part of the attack surface. go.mod carries a toolchain directive alongside the go directive so that CI, which selects its Go version from go.mod, builds release binaries with a patched toolchain rather than the oldest version the module happens to be compatible with. Bump the toolchain line whenever a Go patch release fixes a standard-library CVE.