# Changelog All notable changes to the bastion. The published version is whatever names at the moment, and it is the only version a bastion will update to. This public repository receives each release after it is published, so its `deploy/VERSION` can trail that manifest. Two things can keep a version listed here from reaching most machines: - **Superseded before publishing.** When a second release is merged before the first is published, only the later one is published, and it carries the changes of both. - **Held.** Automatic updates move our own machines to a published version first, and everyone else only after it has run on them for a day without a machine going silent. A version that fails that check is held: automatic updates move no other machine to it, and it is never released again, only superseded by a newer version. From 0.39.1 on, a heading marks a version *not published* or *held* once that is settled. The newest entries may not be published yet; the manifest says which version is. Older entries are not marked. ## 0.63.1 The same program as 0.63.0; its source is now publishable. ## 0.63.0 One Host is in charge of each machine, every pairing comes along when the machine moves to the service, a Host that waits says who admits it, and a Host at home governs a server on the internet that can't reach it back. - **Your Host connects to a server it can reach and that can't reach it back.** A Host on your own computer, behind your home router, now governs a server on the internet with no VPS and nothing opened at home. On the server, `privatae-bastion pair --open` shows a code, good once and for 15 minutes; give your Host the server's address and the code. Your Host then opens the connections, to TCP 8812 on the server, and every lane runs over them as before: the bastion is still the TLS client, the Host's CA still the only one it trusts, its own certificate still presented. The code is proved by each side to the other over the Host's certificate on that connection and never sent; a wrong one costs a wait that doubles, and the fifth closes the window. The port is open only while such a Host is served or a window is open, keeps nothing but its own Hosts' connections, and forwards nothing; the audit records the window, each Host connecting and going away, and refused connections. The wire contract a Host implements is [docs/host-connects.md](docs/host-connects.md). - **Nobody touches a firewall for it.** While that port is in use, the bastion keeps its own rule allowing it inbound, and removes the rule when the last such Host and window are gone, and on `uninstall`: in Windows Firewall (inbound TCP 8812, every profile, for the bastion's own program only), and on Linux in ufw or firewalld when one of them is on. It never turns a firewall on or off or changes anything else in it, and leaves alone a rule of the machine's own that already allows the port. A firewall it does not manage (nftables or iptables rules, ufw for an account that can't change it, macOS set to block all incoming) is said once, with what to allow, in `status` and `doctor`. Each rule added or removed is in the audit. A cloud provider's firewall still needs the port allowed. - **Your own Host gets back in while strangers flood the port.** Of the 256 connections the port takes in their handshake at once, 64 are kept for addresses a Host proved itself from in the last 7 days, so your Host gets in at its first try however many addresses flood it. Any other address is refused at random once 64 are running, more often as it fills, and always at 192 (as OpenSSH's `MaxStartups` does), so a new Host still pairs on a quiet port. The addresses are kept in memory only, never written anywhere; a bastion that has just restarted knows none until its Hosts connect again. - **A server you add has your Host in charge.** `pair --open`, run with machine authority on a server no Host is in charge of, puts the Host that pairs through it in charge (the anchor), so your Host counts the server as yours. A local server becomes networked for it, unless it already serves another Host you said yes to. A Host on the server itself still wins, and an organisation's policy that names another Host still decides. With a Host in charge, each terminal and write on the server waits for your approval, as on any server a Host is in charge of: enrol a second factor there (`stepup-enroll`), as `pair --open` says. - **Adding the same server again just works.** The same Host pairing the same server again through a fresh `pair --open` code replaces its pairing in place, with no `unpair` first: new credentials, the same state, its grants and its place in charge kept (also under a new name). It is the same Host when its connection presents the CA the pairing already holds; another Host with the same name is still refused. - **`pair --open` right after `unpair`.** The running bastion lets go of port 8812 only once it sees the pairing gone, within 15 seconds; `pair --open` now waits up to 25 seconds for that, and says so, instead of failing with "address already in use". - **The tests can't touch a live bastion.** `scripts/e2e-host-connects.sh` hid `/run/privatae` but not `/run/privatae-bastion`, and on a machine running a real bastion it replaced that bastion's local sockets. It now puts an empty mount over all of `/run` and `/tmp` and the bastion's state, and refuses to start while it can still see the machine's bastion; a test holds every namespace script in `scripts/` to that. A lane test that raced its own workers' start no longer fails now and then. - **The Host on your own computer is the one in charge of it.** A Host that runs on the same machine as the bastion (over a socket, or on a loopback lane port, as `npx cseries` sets up) takes charge when it is paired from an Administrator terminal, also from a remote Host that held it before, and never "whichever paired first". The Host it replaced is told so on its last heartbeat, keeps its pairing, and waits for the Host on your computer to admit it, as any other Host does. The change is recorded in the machine's pairing audit. `claim-anchor` with no host picks the Host on this machine, and naming another while one is paired is refused with the reason. - **An organisation's policy can name the Host in charge, and only that overrides it.** On a machine an organisation manages, a policy it deploys names the Host: on Windows the `AnchorHost` value under `HKLM\SOFTWARE\Policies\Privatae\Bastion` (Group Policy, Intune), on macOS the `AnchorHost` key of the managed preference `ai.privatae.bastion` (a configuration profile), on Linux `anchor_host` in the root-owned `/etc/privatae-bastion/policy.json`. That Host takes charge when it is paired, every other Host waits for it, and `claim-anchor` for any other is refused in words that say an organisation manages the machine. Joining a domain is not a policy. - **A Host that waits says who admits it.** `pair` on a machine with a Host in charge says the code was used, that the new Host waits for that Host to admit it, and that it asks you to approve it. `status`, `status --json` (`waiting_for`), `doctor`, the console and the tray say "waiting for to admit it", never "not answering". The heartbeat to the Host in charge lists each waiting pairing with `waiting_for`, `transport`, `same_machine` and `origin` (paired here, carried from your account, or the Host that was in charge before), what that Host needs to ask you. - **Moving to the service keeps every pairing, with no question.** The move from your account's bastion to the bastion for the whole machine carries every pairing your account had, also when the service already served a Host of its own: each comes across with its keys and folders and a control port of its own. Into a machine with a Host in charge each waits for that Host to admit it; into one with none it is served as it was. The installer's last words say what the service serves and what waits, instead of "no Host is needed". - **Each pairing runs on its own.** One Host's lanes failing (a port taken, a certificate that does not read) stopped the whole bastion, and every other Host with it. Now that pairing alone stops: it is said in the log, the machine's events and `status`, and it is tried again by itself after a pause that grows from 15 seconds to 5 minutes. - **The model server on your machine is found by itself.** With `PRIVATAE_LLM_ADDR` unset, the bastion looks on this machine's loopback, and nowhere else, for an OpenAI-compatible server on its usual port: 8080 (llama.cpp), 8000 (vLLM), 1234 (LM Studio), in that order, and serves the first whose `/v1/models` answers with a list of models; on Linux only one run by root, the bastion's own account or the machine's owner, never another account's. It asks again every minute and as soon as the one it found stops answering, so what the heartbeat says is served is what the lane reaches. The variable still overrides it. The log says once what was found, or that nothing was, and `status` and `doctor` show it. - **Smaller ones, from a replay of a Windows desktop.** The service's log names the Windows service manager when it stops the bastion, not the tray. The owner's `status` and the other reads work over a network sign-in (ssh, remote PowerShell), which the service's permissions refused with "Access is denied". `pair` and `claim-anchor` no longer tell you to run or restart the bastion when one is running: it serves a new pairing within 15 seconds. ## 0.62.1 (not published) Superseded by 0.63.0, which carries these changes. Windows, the installer for the whole machine: - **Installing over a running bastion works.** The tray runs from the service's own program in every signed-in session, and Windows will not replace a running program, so a reinstall or upgrade stopped with "Cannot create a file when that file already exists." The installer now moves the program in place aside (the bastion removes it at its next start) and puts the new one in its name, as the bastion's own self-update does. - **A failed install never leaves the bastion stopped.** The service the installer stopped is started again before the failure is reported, and the message says what failed and whether the bastion is running. - **Your tray is the new version too.** After the install, the tray in your session is started again from the new program. Run from an Administrator terminal by hand, the installer says an open tray changes at your next sign-in. The trays of other people signed in are left running the earlier program until their next sign-in, never stopped as an earlier install. - **A reinstall for your account alone runs the new version.** With no pairing code, it said the bastion was running while the copy already running was still the earlier program; it now stops that copy before starting the new one. ## 0.62.0 Windows, with the bastion installed for the whole machine (the `PrivataeBastion` service), from the account that owns the machine: - **`privatae-bastion ui` opens the console from your own account,** with no Administrator terminal, as it does on Linux and macOS. It asks the service over a new pipe, which knows your account from Windows and serves only the account recorded as the machine's owner. Before this it signed in with a key of your account's own, which the service's console refused with a bare 401. - **Your agents and you reach the machine's bastion as on Linux and macOS:** an agent asks to stop another (`stop`, `request`), reads the machine (`agents`, `activity`, `status`, `doctor`, the `--json` reads), and you answer from your own account (`approvals`, `approve`, `deny`, `switch`, the drops). These were refused beside the service. - **Moving to the service keeps the machine's pairings.** When an install moves the bastion from your account to the service, the service takes over your account's bastion state (pairings, keys, grants, audit) before it first starts, and the copy in your profile is removed. Every Host it served goes on being served, and nothing is paired again. The service started empty before, and the machine went silent to its Hosts. - **No dead ends.** A console page opened with no session names the command that opens it on this machine. A second start says which bastion is already running (its process, its program, how it was started) and how to open, check or restart it. It no longer suggests ending the running one in Task Manager. - **An update never leaves a sign-in task's bastion stopped.** After a self-update, the new copy waits for the old one to exit before it takes the machine's run lock. It could find the lock still held, refuse as a second copy, and leave nothing running. ## 0.61.2 - **A machine a Host holds stops dialling it.** When a Host's owner quarantines this machine there, the Host refuses each of the bastion's lane connections, and three of the five lanes dialled again at once, the other two a second later: tens of connections a second, each a TLS handshake. Now a refusal holds all of that pairing's lanes for 5 seconds, twice as long after each further refusal up to 5 minutes; and while the Host's heartbeat reply says the machine is quarantined, the lanes do not dial at all. The first reply that no longer says so brings them back. Other Hosts the machine is paired with are not slowed. ## 0.61.1 - **An op that creates a folder runs on every Linux kernel.** Where the kernel's overlay keeps an index (or has `redirect_dir` or `metacopy` on, as Azure's kernel does), it marks each new folder opaque, and the bastion took that for a replaced folder: every op that created one was refused as "a path outside the effects in disguise". A new folder is now read as created, with what it holds; a folder that replaces one the machine holds is still refused. ## 0.61.0 - **An agent on the machine reads the rest of it.** `agents show ` is one agent in full (its command line in `--json` only, since arguments can carry a key). `switch --list`, `ops`, `device-approvals`, `backup key show`, `backup requests` and `backup shares` print as one value with `--json`. Beside a whole-machine install, the owner's account reads them from the machine's bastion over its ask socket, as `agents` and `activity` already were; no other account is answered. - **`doctor [--json]` says what is wrong with the install**, each problem with its fix: whether the bastion runs, whether a newer version is published, the owner account, the machine's second factor, accounts whose asks are held, each Host pairing, the switch list and the ops sandbox. Exit status 1 on a problem. - **The machine's owner answers from their own account.** `approve`, `deny` and `switch` act on the machine's bastion over its ask socket instead of on an empty bastion of the account's own. `approve` shows the request and takes the owner's code at the terminal; `switch … on` takes the code; `deny` and `switch … off` take nothing, since they only take reach away. Each act is a line in the machine's audit log naming the account. - **And takes reach back the same way:** `approvals drop`, `grants drop`, `ops drop` and `device-deny`. Another account is refused each of them with the command the administrator would type. - **The installer checks the release's signature.** `install.sh` verifies the manifest's signature against the release keys it carries before it downloads the program, and refuses a signature that does not verify. Where no openssl can check an Ed25519 signature it says so and keeps the digest check alone, as before. `install.ps1` still checks the digest only. - **One Host's folder is never answered for another's store.** On a machine paired with two Hosts that each hold a store with the same id, one local agent served both, so the second Host's searches and reads reached the first Host's folder. Each pairing's stores now have their own agents, and each Host's heartbeat reports only its own. - **A stopped pairing's last heartbeat acts on nothing.** A beat out when its pairing stopped (an unpair, a revoke) came back afterwards and could still act on the reply, writing into the pairing's folder after an unpair had removed it. A stopped pairing now waits for its heartbeat, whose request ends with it. - On Windows the owner's reads and acts are refused beside the bastion's service with the administrator's command: there is no ask pipe there yet. ## 0.60.0 - **An agent can follow its own stop request to the end.** `request [--wait 5m] [--json]`, and `--wait` and `--json` on `stop`, say where an ask stands: `waiting`, `approved` (being carried out), `stopped`, `not_stopped` with the reason, `denied`, `refused` or `unknown`. Exit status is 0 for stopped, 1 for not, and 3 when still waiting after `--wait`. Each account sees only its own requests. - **An account whose asks keep being refused is held.** Five refused asks from one account within ten minutes (the owner denied them, or they were refused: not an agent, refused in the last day, too many waiting) hold its asks for ten minutes, doubling on repeats and forgiven after a clean day, as a Host pairing is held. The owner lifts it with `quarantine clear --asker `. - **A deny blocks the same stop for 24 hours from the deny**, not from the ask. - **Every row about a request names who asked to stop what.** The asking account, the asking process and the agent it descends from, and the target, each with how it was started, on the ask, the approval or denial and the stop itself. Activity shows asks and denials. - **An agent in the owner's account reads the whole machine.** Installed for the whole machine, the bastion keeps its records in root's state. From the account recorded as the machine's owner, `status`, `agents`, `schedules`, `tool-servers`, `posture`, `baseline show`, `baseline diff`, `activity`, `report` and `approvals` now ask the running bastion over its local socket and show the whole machine, with no sudo and no browser. Any other account is refused; `agents`, `schedules`, `tool-servers` and `posture` then show what that account can read itself, and say so. - **New commands.** `schedules` (scheduled jobs that start an agent), `tool-servers` (MCP tool servers and what is wrong with each), `posture` (Linux: the firewall, SSH, fail2ban, security updates, disk and load), and `baseline show|diff|record`; the `observe:baseline*` names still work. - **`--json` on `status` and `approvals`**, and `report --out -` prints the report instead of writing a file. `status` says NOT ANSWERING when the bastion is installed for the whole machine and its service is not running. - **What changes for existing installs.** - No read prints the delegation tag a Host gives an agent; the administrator's `agents --json` and the report file used to carry it. - `schedules --json` and `tool-servers --json`, and the same parts of `report --json`, use snake_case keys like every other read. - On Linux and macOS, `status`, `activity` and `report` from the owner's account are no longer refused beside the system service; they read it. ## 0.59.0 - **The bastion works on its own, with no Host.** It keeps watching the machine with no Host connected, and `status` says it is running on its own. Connecting a Host is optional and comes last. - **Every AI agent on the machine, in the console and at a terminal.** The console's home page lists each agent and what it is doing: for Claude Code, the session's title, folder, git branch, permission mode and what it is running now; for the others, the folder and tool servers. `agents [--json] [--prompts]` prints the same list. None of it goes into the heartbeat or to a Host. - **The machine's owner can stop an agent without a Host.** `stop ` at a terminal, or Stop on the agent's page, takes the owner's code: a second factor for the machine itself, set up with `stepup-enroll --machine` or the QR code in Settings. An agent can only file the request; it waits for the owner under Waiting for you, and the bastion carries it out within two minutes of the approval. Wrong codes are slowed down after five and locked after a hundred. - **New console pages.** Waiting for you lists requests from agents, including agents running under other accounts, and which account asked. Activity is one timeline of what happened on the machine (`activity`). This machine shows scheduled jobs that start an agent, problems with tool servers, and changes since a recorded baseline. Write report saves one Markdown file of what the bastion knows, prompts left out (`report`). Settings is in plain words, with the owner's acts as buttons. - **Installed for the whole machine by default.** On Linux and macOS, `curl -fsSL https://privatae.ai/bastion/install.sh | sh` from your own account asks for your password once through sudo and installs the bastion as the machine's service, so no program running as you, an AI agent included, can stop it. `--just-me` installs it for your account alone, with a warning; so does a machine where sudo is missing or refuses. On Windows, `install.ps1` does the same through one administrator prompt (`-JustMe` for your account alone). `--personal` and `--shared` are the new names for `--local` and `--networked`, which still work; on Windows they are `-Personal` and `-Shared`, and `-Networked` still works. - **The console opens from the owner's own account.** On a whole-machine install, `privatae-bastion ui` from the account that installed it opens a limited console without sudo. It shows everything, and it does whatever takes the owner's code or only narrows what reaches the machine. Connecting a Host, setting up the owner's code, choosing the Host in charge, proposing a folder, approving devices and the disk map need `sudo -H privatae-bastion ui`, and the pages say so. Any other account is refused. - **`privatae-bastion uninstall`.** It removes the service and the program and keeps the bastion's state and audit log, unless `--delete-data` is given and confirmed at a terminal. From an ordinary account it removes only that account's own bastion. - **What changes for existing installs.** - A local switch list that cannot be read now refuses everything; it used to allow everything. `switch --reset` sets the unreadable list aside. - Marking an agent as yours, and turning a switched-off capability back on at the console, take the owner's code. - The Claude Code background service is shown as a service, not counted as a session. - On Linux, `pair`, `run`, `status`, `switch`, `activity` and `report` from an ordinary account beside the system service are refused with the command to use instead, as on macOS and Windows. They used to act on that account's own empty state. - Reinstalling restarts the system service on the new program, and the installer no longer puts a second bastion in an account beside the machine's. - **Documentation.** The README starts with the bastion on its own. `docs/STATUS.md` says what has run on a real machine, what is tested and what is only built, for each system; `docs/skills/bastion.md` tells an AI agent on the machine what it may do with the bastion. The whole-machine install has run on a fresh Linux machine (`docs/verification/m0-gate-linux-2026-10-31.md`); the macOS and Windows installers have not yet run on a real machine. ## 0.58.1 - **An excluded path that names more than one folder now matches.** A shared folder's grant lists paths no Host may read, and each was compared with one folder name at a time, so a path written with a slash never matched. The Host's own default list includes `.config/gcloud`, so a shared home folder served the Google Cloud command line's sign-in files through list, stat and read_range. A path such as `.config/gcloud` now matches that run of folders anywhere under the shared folder, with names compared the way this machine's file system compares them, as one-folder paths already were. Every check that reads the excluded paths uses it: each folder op, the listing walk and both checks on a move plan. - **The local search agent's own folder is never served.** The agent keeps its extracted text, its sanitized text, its vectors, its catalog and its sealed bundles in `.agent` inside the shared folder, and a Host allowed to read the folder could list it and read the extracted text as it is. List, stat, read_range and move plans now refuse `.agent` at any depth and in any case, however the path is written or reached through a link, and say why. The bastion's own plan reports still go to `.agent/plans`. - **What flag mode replaces is said exactly.** The note a Host passes on for a folder searched in flag mode said card, bank and ID numbers were replaced. It now names what is replaced (e-mail addresses, phone numbers, card and US social security numbers, IBANs, US routing numbers after a word such as "routing", Bitcoin addresses that start with 1 or 3, IP and MAC addresses, private keys and known token formats) and what is never looked for (names, street addresses, dates of birth, passport, licence and other ID numbers, other bank account numbers and other crypto addresses). - **The documentation says what listens and what each claim's test checks.** The tunnel holds one UDP port, chosen by the system, which answers nothing that is not from the paired Host's key; every other port is on loopback. A coder run reaches nothing on this machine or your network that you did not name with `--allow-lan`. Nine rows of `docs/claims.md` now say only what their tests assert. The commands page lists every command, and the install page the one-line pairing for a Cloud Host. `git-setup` no longer says the forge can be reached only through the bastion. ## 0.58.0 - **A Host answers four common questions about a machine itself, instead of handing the person a command to run.** Each is a read-only observe op, granted under `observe` like the rest of the family and listed in the ops the machine advertises, so a grant can name it. Each value is checked against a written rule before anything is read or run, and a refusal says which rule it broke without repeating the value. Off Linux each answers `unsupported`. - `observe:folders [depth]`: the 25 largest folders under an absolute path, 1 to 3 levels down (2 when not given), and the total, on that path's own filesystem only, counted the way `du -x` counts (disk blocks, a file with several links once). Only folders are named, never a file. What could not be read, and filesystems mounted under the path, are listed against the folder that holds them, with a reason and a count. `/proc`, `/sys`, `/dev`, a relative path, a path containing `..` and a link that leads into those are refused. A walk that runs out of the 20-second budget answers with what it measured and says every size is at least that. The walk follows no link: a folder swapped for one while it reads, or reached through one put above it, is listed as changed and not counted. A read that never returns (a network mount that stopped answering) is answered without at the budget, and while four such reads are still waiting no new walk starts. - `observe:journal [minutes]`: one systemd unit's journal for the last 1 to 60 minutes (15 when not given), up to 5000 lines and 200 KB, newest kept. A name with no type is a service, as with `systemctl`. The name must be in systemd's unit-name alphabet and one this machine has, loaded now or installed; otherwise it is refused, with the units whose names come close. Until now a Host could read logs only from a Loki, which most machines do not run, so it asked the person to run `journalctl` instead. - `observe:logins [hours]`: sshd's failed and accepted logins over the last 1 to 168 hours (24 when not given). Failed attempts are counted and ranked by source address and by user name, 20 of each; the last 20 accepted logins come with user, source, method and time. A user name the other side sent is cut to 32 characters, and anything outside the characters account names use reads `?`. No raw line, key fingerprint or password is returned. Only lines written by sshd running as root are read, so another account on the machine cannot add login lines or bury the real ones under its own. - `observe:cert [server name]`: the certificate this machine's own listener on a TCP port serves: subject, names, issuer, dates, days left, and whether the chain verifies against this machine's roots for that name. The port must be one `ss` shows listening here. The handshake goes to loopback first and otherwise only to an address of this machine the listener is bound to, never to one another machine could hold. - **A journal the bastion cannot read is said, not shown as empty.** `journalctl` run by an account outside the journal's groups shows only that account's own lines, which would look like a quiet machine. `observe:journal` and `observe:logins` first check that the bastion can read the system journal (as root, which is how the networked install runs it, or as a member of `systemd-journal`) and otherwise answer with the reason; `observe:logins` then reports its counts as -1, never 0. - **A grant from now on can be held to a window and a rate, by the machine.** An op grant's bounds may now carry `_window` (days, from, to and a zone, read on the machine's own clock; a window may wrap midnight) and `_rate` (at most so many applies in any 24 hours, and so many seconds between two). An apply outside them does not run, spends no use and is no strike toward the quarantine: the answer is `not_now`, exit 75, with the bound that held it and when it next could run. A dry run is never held, and an apply with arguments outside the op is still refused as before. The zone database is in the binary, so a zone reads the same on every machine. An older bastion refuses both keys as naming no param, so a grant carrying them fails closed there; the enforcing report says `grant_run_bounds` on a bastion that holds them. - **The machine keeps the runs, and who approved.** Each grant's record in `ops.json` now holds the times of the applies that spent a use, the key that approved it (the approval device's key, or the factor enrolled at this console) and the machine's own seal over the record. The rate is counted from that log, so it holds across a restart. A record edited outside the bastion holds no grant until it is dropped and approved again, and so does a grant installed by an older bastion (none was held when this shipped). A grant whose approving key is no longer pinned is refused; `unpin-device` drops every grant that key approved, so pinning it again brings none back. - **`op_grant_drop ` takes a grant back from the Host.** It is a tighten: no quarantine holds it and no approval is asked. A grant not held here is already gone and is answered as dropped. At the machine, `ops drop --all` takes back every op grant a Host holds, beside `ops drop `. - **An action op may have no program.** With `interpreter: none` and no `run` file, an action op's verbs are its whole work. Nothing the Host wrote executes, so it needs no sandbox and runs on a machine with no overlay. Its dry run is the verbs' own read of what each would move, and its apply the verbs and the log checks. Such an op that ships a `run` file is refused, as is one that names destinations, a builder, reads, or a credential no verb uses. An op with a program is still refused where the sandbox is not. A verb whose tool the machine lacks refuses the run as the machine's own state, never a strike, and the enforcing report now says, for each verb, whether this machine can run it (`verbs`). - **On your own machine, an action op reaches the internet, never your network.** On a machine no fleet has anchored, an action op's destination is never a private, ULA, CGNAT or reserved address (also inside NAT64 or 6to4): one written as an address is refused when the op arrives, and a name is refused at apply if any address it resolves to is one. Another computer of yours is reached through a bastion on that computer. An anchored machine keeps private destinations, since a fleet's ship steps reach its own private control planes. A verb's fetch is held the same way (`tree.checkout` fetching its tree's repository, `k8s.set_image` fetching its source to check ancestry): the repository's host is checked once and git is held to that address, so an op with no program reaches no more than one with a program. On an anchored machine too a verb's fetch is held to its checked address and is never this machine, its loopback, a link-local address or a cloud's metadata service. An image `k8s.set_image` would put live, which the machine's container runtime pulls by name, is refused on your own machine when its registry is on your network or this machine, both when a program reports it and when the verb would set it. `k8s.set_image` now says it needs git as well as kubectl, so a machine without git is told before a run. What stops working: a ship step on your own unanchored bastion that deploys to another computer on your LAN, fetches from a forge on it or rolls an image from a registry on it; and a verb's fetch needs git 2.37 or newer, which holds a fetch to one address. ## 0.57.0 - **Pairing with a Cloud Host is one pasted line.** A Host's "Set up a bastion" card now gives a pairing code, `skp_…`, that carries the token, the Host's enroll address and the SHA-256 of the Host's CA. `pair ` (or the code on standard input after `pair -`) takes the CA from the enroll port's own TLS chain, keeps it only under that fingerprint and only when the port's certificate verifies under it for `host.cseries.internal`, then pairs exactly as `--redeem-url` with `--host-ca` does. There is no certificate file to save, so the step people skipped ("open host-ca.pem: The system cannot find the file specified") is gone. `--host-ca` still works as before. - **The installers pair and start in one go.** `install.sh` with `PRIVATAE_PAIR_CODE=''` in its environment (the card's line is `curl -fsSL https://privatae.ai/bastion/install.sh | PRIVATAE_PAIR_CODE='skp_…' sh`) and `install.ps1 -Pair ''` install or update the bastion as before, hand the code to `pair -` on standard input, then start the bastion, or restart it when it was running, and end with one plain sentence and `status`. On Windows that is the logon task (every running copy is stopped first, so the new one gets its control port), or the service on a networked machine; on a machine whose bastion is already the Windows service, `-Pair` pairs that service from an Administrator PowerShell instead of installing a second bastion. On Linux as yourself it is a systemd user unit, with lingering asked for and the outcome said; on macOS a LaunchAgent; as root, the system service. Without the code both installers behave as they did. - **The code is in no process's arguments.** On macOS and Linux it comes only from the environment: an argument would sit in the installer shell's command line for the whole install, where any user of the machine can read it with `ps`, and one at the same address could redeem it first. A process's environment is readable only by its owner and root. `install.sh` reads the variable before it starts any program and unsets it, so nothing it starts inherits it. An empty or partial code is refused before the download. `install.sh` now names no argument it refuses, so a code given in the wrong place is never printed back. On Windows the code stays a parameter of the script, inside the person's own PowerShell. - **On Linux, as yourself, the copy that serves is the one the installer started.** Before the user unit starts, every copy of your bastion running outside it (left in a terminal, or from another path) is stopped, as install.ps1 does on Windows. Otherwise the unit's copy only waits behind the old one's run lock, and the old copy never reads the new pairing. The installer then checks for a few seconds that the unit is running and keeps the same process. A unit that keeps restarting is reported as not running, with its log command, and the install fails. - **A local bastion can serve a second Host, when you say so at the machine.** A local bastion used to serve one Host and refuse any other, so a person with two Hosts of their own (a Cloud machine beside a platform Host) could not pair the second at all. `pair` now asks on this machine's terminal, `/dev/tty` or the Windows console, never standard input, so a code piped in by an installer still needs a person to answer: "This computer already serves your Host … Add … as well? [y/N]". Only y or yes adds it; the first pairing is untouched; with no terminal it is refused. Other callers (`claim-anchor`, the pairing op) keep the refusal. On a local machine that serves two Hosts, each changes only the servers it built and stops only the coder runs it started. The name in the question is the token's, so anything in it but a plain host name is shown quoted and escaped. - **`pair` exits 3 when the Host is already paired here.** Nothing changes, and the installers report it as already connected. Over TCP, with a pairing code or `--host-ca`, a Host of the same name under another CA (Cloud names repeat) is a plain refusal, not exit 3, before the redeem and after it. A pairing code that names a field twice is refused. - **An enroll port that does not answer says why within 10 seconds.** A Cloud Host opens its pairing port only to the address the browser used when the card was opened, for 15 minutes; from anywhere else the port does not answer, and `pair` used to wait for the system's connect timeout. It now stops after 10 seconds with that reason and what to do. - **The local-mode refusal names this system's way to a networked bastion** (`install.ps1 -Networked` from an Administrator PowerShell on Windows, `install.sh --networked` as root elsewhere) and says that the Host already paired would have to be paired again with it. It used to name the Linux script on Windows. ## 0.56.0 - **New: a Host with no platform can use the model servers on this computer.** A Host running on this computer, or in a virtual machine on it, can reach the model servers here (Ollama, a local language model server, a personal-data filter) by name through the bastion, and is told which of them it can use. It starts working once your Host supports it. - **Changed: for a Host with no platform, model management reaches only this computer.** Listing, downloading and removing models for such a Host now reach the model server set here only when that server is on this computer. One set to another computer is refused, and the bastion's log says which setting is not used and what to set. Such a Host is also told only of the model servers it can use, and no longer of the service slots, or of coder runs when it is in a virtual machine, since neither runs for it. - **Changed: on a local machine, the internet lane reaches only public addresses.** A Host can no longer use a local machine's internet lane (the egress lane) to reach devices on your home or office network at their private addresses, such as 192.168.x.x or 10.x.x.x, or the private ranges many VPNs use, as was already the case on an anchored machine. Your own machines are reached through their approved lanes. A device that has a public address, as many do over IPv6, is still reached at that address. - **Changed: plain-HTTP requests through the bastion are checked one per connection.** On the internet lane and a coder run's web proxy, a plain-HTTP (not HTTPS) connection now carries one request, which is checked before it is sent on; anything sent after it closes the connection. Before, later requests on the same connection reached the destination unchecked. A malformed or ambiguous request is refused, and every refusal now says why. - **Safer: file access in a folder you shared stays inside it.** When a Host lists, reads or moves files in a folder you shared with it, the bastion stays inside that folder. If the folder, or any folder on its path, is later swapped for a link to somewhere else, the bastion refuses until the folder is shared again, and a file or folder inside it swapped for a link is never followed out of it. Search, which goes through the folder's own search agent, is not part of this change. - **Safer: the bastion limits how much it holds of what a Host sends.** On the internet lane, the terminal and root commands, coder runs, the memory mirror (the copy of your Host's database kept here) and the bastion's check-in with its Host, a message from the Host is read only up to a set size and refused past it, or passed along or dropped as it arrives. A Host can no longer make the bastion hold a message of any size in memory. A coder run's web proxy now carries at most 1024 connections at once, and the memory mirror at most 8. - **Safer: asking to share a folder with a Host has limits.** Any user on the computer can send that ask to the bastion, so each ask is now read only up to a set size, and the number of asks open at once is capped, per user and in total; past the cap, the ask is told the bastion is busy and to try again. - **For contributors: the fuzz check in CI no longer fails on a timing bug in Go's own fuzzer.** A real failure still fails it. The program itself is unchanged by this. ## 0.55.3 - **A pairing change reaches every Host on the machine at once.** Each pairing beats its Host every 60 seconds, and the beat is how every Host on the machine learns that a pairing went active or was revoked. Approving or revoking a pairing changed it on disk immediately but triggered no beat, so every Host kept the old state for up to a minute: a connection request to a Host the anchor had just approved was refused as not on a shared machine. Now a grant that makes a pairing active, and a revoke, make every running pairing beat right away. A refused grant, or a re-sync of a pairing that is already active, beats nothing. ## 0.55.2 - **A search reply says how its text was sanitized.** Every successful reply from the bastion agent (search, read range, catalog) was stamped `sanitized: true`, while the agent the bastion deploys runs in `flag` mode, which replaces e-mail addresses, phone numbers, card, bank and ID numbers, IP and MAC addresses and keys, and leaves names, street addresses and dates of birth in. A reply now carries `sanitize_mode` (the mode this bastion wrote into the agent's manifest, or for an agent run by hand the mode it reports, else `unknown`), `sanitized` true only for `sterilize`, and `sanitize_note` saying what the mode leaves in. A reply cannot raise the mode this bastion wrote. - **The README says what the bastion needs.** It said "no Docker, no Python". The bastion itself needs neither, and the README now names the optional features that do: coder runs, the service slot and the memory mirror need a container runtime, folder search needs Python 3.8 or later, Python ops need `/usr/bin/python3`, and the Linux accessibility reader needs python3 with AT-SPI. It also said Go 1.25; `client-go/go.mod` needs 1.26. - **GitHub releases carry their files.** A new workflow, `.github/workflows/release.yml`, runs on each release tag (and by hand for an earlier one): `deploy/release-assets.sh` rebuilds the release from its tag, writes the release manifest from the rebuilt binaries with `deploy/manifest.py` (which `publish.sh` now uses too, so the two cannot differ), and fetches the signature published for that manifest. Only when the signature verifies are the six binaries, `version.json`, its signature and `SHA256SUMS` attached to the release. Releases 0.53.2, 0.54.0 and 0.54.1 were rebuilt this way and each matched its signed manifest. - **The lab harness is out of the release build.** `mode=lab` drove one test lab's own cluster and database, and its layout was in the published source. It is now built only with `-tags lab` and is not in the published source; a release bastion answers a lab run with exit 126, as a bastion without a lab always did. - **staticcheck is clean** on linux, darwin and windows, for both Go modules, and CI runs it (`deploy/staticcheck.sh`, a pinned staticcheck release, with the per-OS runs merged so code used on one OS only is not reported unused). Unused code is removed and error strings no longer end in punctuation; no behaviour changes. - **The claim tests a container cannot run now run on GitHub.** 25 tests the claims table cites (the op and action-op sandboxes, the egress tests in private namespaces, the root command watch) skipped in CI, because a CI container has no systemd, no namespaces of its own and no nftables. A new job runs every cited test as root on GitHub's discarded virtual machine and fails if any skips; `deploy/check-claims.sh --skipped` names them. ## 0.55.1 - **A root op's tasks are capped.** An op's tasks were capped at 64 with `RLIMIT_NPROC`, which the kernel never applies to root, so a root op could start as many tasks as it liked until its time limit. A root op now runs in a systemd scope of its own in `privatae_ops.slice` with `TasksMax=96`, which counts every task in it, root's included: the op's 64 and the bastion's two processes that start it. Those two run with one CPU's worth of Go runtime threads (`GOMAXPROCS=1`), so they take the same share of the cap on a machine of any size: with the runtime set for 64 CPUs they took 44 of the 96, and set for 256 no root op could start. The scope's process reads its own cgroup's `pids.max` before the op starts, and the op's report carries `tasks_capped` only when that is the cap. Where no scope can be made (no systemd, no pids controller, or `systemd-run` failing), the op runs as before and its report's `tasks_note` says its tasks were not capped, and why. A user op's tasks are held by `RLIMIT_NPROC` as before, and its report says so. A scope is killed with the run at the time limit, when the bastion stops, and by the startup sweep. Two ops can run at once, so an action op's memory watch now reads each other op's unit or scope in the slice as another program: it yields when a root op beside it makes the machine wait on memory, as it did when root ops ran in the bastion's own cgroup, and its own unit still never counts. ## 0.55.0 - **A local machine's egress lane never reaches the machine itself.** Before, on a machine with no anchor the lane vetted no destination, so a host could dial this machine's own services through it: `POST http://127.0.0.1:11434/api/pull` reached ollama whatever the `models` switch said. It now refuses `localhost` names, loopback and unspecified addresses (`0.0.0.0/8` and `::` included), link-local addresses (the cloud metadata address included), the rest of `64:ff9b::/32`, the deprecated IPv6 forms below, and any address this machine takes as its own, as the anchored lane checks them, also inside an IPv4-mapped, NAT64 or 6to4 address. The LAN stays reachable: a private, CGNAT or reserved address that is not this machine's is dialled. Every address a name resolves to is checked, and only those addresses are dialled, never the name again. - **A container or VM on this machine is this machine, not the LAN.** On Linux, an address the kernel routes straight onto a container's veth, or onto a bridge whose every port is a veth, a tap or a dummy (the bridge networks Docker, Podman, LXD and libvirt make for their containers and VMs), is refused as this machine. A route through a gateway on such a link leaves, and a link an IPv4 or IPv6 default route of the main table goes out of (any nexthop's, for a multipath default) is never inside: a bastion that is itself in a container reaches the internet through the machine it runs on, and dials the network on its own veth, its LAN included, as before. Before, the local egress lane dialled such an address as LAN, so a host could reach a service in a container here, `172.17.0.2:11434` for one, though the same service on loopback was refused. A bridge with a network card, a bond or a VLAN among its ports is still the LAN. Every lane reads the same check, so a coder run's proxy, the anchored lane and the backup lanes now also refuse a public address on such a network; their private addresses were refused already. macOS and Windows have no route lookup here, so there such an address is still dialled. - **Every lane refuses the deprecated IPv6 forms.** IPv4-compatible `::/96`, IPv4-translated `::ffff:0:0/96` and site-local `fec0::/10` are no destination of their own, and where each is delivered depends on the platform. A coder run's proxy and the backup lanes already refused them; the egress lane now does too, anchored or local, by address and in any answer a name resolves to. The anchored lane refuses nothing else it did not refuse before. All the lanes now read one address classifier. - **Both egress lanes try each vetted address, as Happy Eyeballs does.** A name with more than one address is dialled at each in order, the next starting as soon as an attempt fails or when none has answered within 300 ms, and the first to answer is used; the others are cancelled, and one that connects anyway is closed. Before, the anchored lane dialled only the first address, and a coder run's proxy gave each address an even share of the dial budget, so an address whose packets go nowhere held up the next one for seconds. The next one now starts within 300 ms, about what the OS dialler took when it was given the name. - **`pair` says to enrol a second factor after every pairing that has none.** It said so only after it anchored a networked machine, so a local pairing, one over TCP or a unix socket, and a later host on an anchored machine were told nothing, and on a local machine a folder grant then installs on the host's signed grant alone. The line names `--host` when more than one host is paired, and `sudo -H` only when `pair` itself ran with machine authority, so a local install is told to enrol as its own user. A pairing that keeps its factor (a `--replace`), or a machine with an approval device pinned, is not told. - **A root-command grant lasts at most 30 days.** It was a year. `root-exec grant --for 31d` is refused and says the longest term; grant again before it ends. A grant given under the longer term ends 30 days after it was given, and `root-exec` shows that end. A grant file with no grant time is refused, since it cannot be held to the term. - **SECURITY.md, the README's license section and `docs/host-on-this-machine.md` name each kind of Host.** They described only a Host on Privatae's platform. They now say that a C-Series Host runs on your own machine in its own Linux virtual machine, is open source (Privatae/cseries, AGPL-3.0-only), needs no Privatae account, and that `npx cseries` sets the bastion up networked, paired, anchored and with a second factor on every pairing (the commands are shown); that C-Series Cloud and a network membership are optional and paid; and that a Host on Privatae's own platform is the case where the Host's software is not open source. - **Security reports go to security@privatae.ai,** the address privatae.ai/.well-known/security.txt names. SECURITY.md said info@. - **`docs/models.md` says what stops a host changing your models on a local machine.** It told you to scope the pairing on your host, which a local machine never reads. There, switching `models` off for the host is what filters the ollama forward, and the egress lane is no way around it. On an anchored machine the forward filters for every pairing, which the page now says too. - **A tunnel endpoint the host sends must be one host and port.** The pairing response's `endpoint` and `endpoints`, and the endpoints in a heartbeat's rotate directive, were written into the WireGuard configuration unchecked. That configuration is one setting per line, so an endpoint with a newline in it added settings of its own: `203.0.113.7:51820` followed by a newline and `allowed_ip=::/0` let the tunnel carry every IPv6 address, not only the host's. An endpoint must now be an IP address or a host name with a port from 1 to 65535, and nothing else, no space or control character. A pairing response with any other is refused and nothing is saved, and a rotate directive with one is not acted on. A WireGuard key must be 32 bytes. The tunnel's peer is now configured in one place, which checks every value it writes. - **A pairing file that holds such an endpoint is listed but never served.** An older build may have saved one. Its tunnel and lanes never come up, and `run` names the file and says why. Every other pairing still loads and serves, `status` shows the pairing as `invalid` with the same reason, and `unpair` removes it, as does pairing that host again with `pair --replace`. One such file no longer stops `run`, `status` and `unpair` for the whole machine. - **Such a pairing cannot hold the anchor or be approved, and no host is told of it.** `claim-anchor` marked it active and anchored the machine to it; it now refuses, says why, and changes nothing. An approval from the anchor host marked it active and started it with the endpoint its file holds; it is now refused. The heartbeat and the anchor host's pairing list leave it out: a host knows only the states active, pending and revoked, and could show a pairing in any other state as active. - **A harness run's agent credential is readable only by the harness.** The credential file was readable by every user on the machine (0644), in a folder anyone could open and write (0777), for the length of the run. A bastion running as root now hands the folder (0700) and the file (0600) to the user the harness image runs its agent as (uid 10005). One that is not root cannot hand them over, so it leaves them open for the agent, and the folder sits inside one that only the bastion's own user can enter. - **A harness run's tree is out of other users' reach.** In a worktree run the tree mounted at `/work` was opened to every user (0777), its top folder included, in the shared temporary folder. Any local user could read or change the Host's tree during the run. It now sits in a folder only the bastion's own user can enter, as a git run's tree already did, and a run refuses a tree whose folder other users can enter. Opening the tree for the agent no longer follows a link in it. Before, on a bastion running as root, a link in the repository, to `/etc/shadow` for example, made the linked file world-writable. A file is opened as 0666, or 0777 if it was executable, so a git run no longer commits every file as executable. - **A worktree run returns only what is in its tree.** A file swapped for a link while the tree was being packed, or a folder swapped for a link, was read through the link, so a bastion running as root could return any file on the machine to the Host. Each name is now opened inside the folder already open above it, never through a link, and goes in only if it is the plain file or folder that was looked at. On Linux a device or pipe swapped in for a file in that gap is not opened either: opening some devices does something by itself. Links, pipes, devices and sockets are left out (before, each came back as an empty entry of its kind), and the run's result names them in `worktree_skipped`, with `worktree_skipped_count`. - **A coding agent can no longer run code as the bastion through the repository's git settings.** In a git run the bastion committed and pushed in the agent's own folder, `.git` included, so a hook, a file system monitor, a filter, a signing program or a changed remote the agent set ran as the bastion's own user, which can be root, or sent the push, with its token, to another server. The bastion now makes its own fresh clone, in a folder the agent cannot reach, copies in the agent's plain files and folders (never a link, never anything of its `.git`), and commits and pushes only there. The branch gets one commit with the agent's final files; the agent's own commits are not pushed one by one. A link the agent made is left out and named in `worktree_skipped`; a link the repository already holds stays as it is. Every git the bastion runs for a harness or a mirror runs with no hooks, no file system monitor, no `ext::` transport, and no system or user git settings of the machine. - **The agent in a git run no longer holds the push token, and no process argument shows it.** The agent's clone named the repository with the run's token in it, so the agent could push straight to the repository during the run, past the bastion's copy, and every git the bastion ran for the run had the token in its arguments, which any local user can read. Every git call now names the repository without the token and gets it from its environment, as an `Authorization` header sent to that repository's address only; no clone's config holds it, and a run is refused if the token is in any file of the agent's clone's `.git`. - **A user-privilege op is no longer refused because other programs run as its user.** An op's tasks are capped at 64, and the cap counted every task the machine ran as the op's user (`privatae-session`, or `nobody`, which system daemons share), so on a busy machine a user op could not start or could not fork at all. The op now runs in a user namespace of its own, as the same user and seeing every file's real owner, where the cap counts only its own tasks. Where the machine has no user namespaces, the op runs as before. - **A harness run's git calls carry the harness identity.** git made one from this machine's user and host name instead, which cost a host name lookup per call (seconds where there is no resolver) and wrote this machine's name into the clone handed to the agent. - **A run whose agent container cannot be removed uses nothing of its tree.** A failed removal was ignored, so an agent still running could go on writing the tree while the bastion read it. The run now fails, says so, and copies, returns and pushes nothing. - **A git run whose commit fails pushes nothing and fails.** A failed add or commit was ignored and the branch pushed anyway, and the run read as successful. It now ends the run with an error and no push. A run in which the agent changed no file pushes no branch and reports `changed: false`. - **Repositories that keep files in Git LFS work in git runs again.** No user git settings are read any more, so the LFS filter `git lfs install` sets there was lost. When `git-lfs` is installed, the bastion now supplies the standard LFS filter itself on its own git commands, sends the large files before the push, and sends them only to the repository's own LFS address, whatever a `.lfsconfig` the agent wrote names. A filter of any other name in a `.gitattributes` still runs nothing. Tested with git-lfs 3.7.1. - **Names that open as `.git` on Windows or macOS are left out of a git run's copy.** Besides `.git` in any letter case, the copy now leaves out `.git` followed by dots or spaces or a `:`, the short name `GIT~1`, and `.git` with the characters HFS+ ignores, the names git itself refuses, so no file the agent made can be written into the clean clone's own `.git`. - **A run's tree has a size limit, and a file is never read into memory whole.** A file of any size in the tree, even one that takes no disk space, was read whole into memory, so an agent could make the bastion run out of memory. A worktree run now returns at most 9 MiB of tree, which is what fits in one message to the Host beside the transcript; a larger tree is refused with `worktree_too_large` and none of it is returned. A git run copies at most 2 GiB, and refuses a larger tree with `tree_too_large` before it pushes anything; each file and folder counts 512 bytes and its name besides its content, so a tree of millions of empty files is refused too, and the copy keeps nothing per file in memory. Of the entries left out, the count is kept in full and the first 100 names. A folder is read 512 names at a time, each counted as it is read, so a folder of millions of names is refused without being read whole first. A result too large for one message to the Host is refused with `result_too_large` instead of being lost on the way. - **The local console shows a pairing that is not served as "invalid".** A pairing whose file holds an endpoint this build refuses showed as active on the console when its stored state was empty, as it is in older files. It now reads as the setup page and `status` read it. - **Built with Go 1.26.10, golang.org/x/net 0.61.0, x/crypto 0.58.0 and x/sys 0.49.0.** `govulncheck` finds 13 known vulnerabilities this code reaches in Go 1.25.14 and x/net 0.57.0, which 0.54.1 was built with: in crypto/x509 (intermediate certificates since found compromised were still trusted), crypto/tls, net/http and HTTP/2, net/textproto, os and html/template. With these versions it finds none. The build image is still pinned by digest, and CI runs in the same one. The module now needs Go 1.26, as x/net's fixed release does. A published release is still rebuilt by `deploy/verify-release.sh` with the toolchain its own tag pins. - **`go test ./...` passes for a contributor who is not root.** A root command now starts in `/` when the process cannot enter root's home, as it already did when that home is missing; the bastion runs these commands only as root, so on a machine nothing changes, and the root-command tests now run as any user. `TestTreeCheckoutMovesOnlyForward`, which needs a tree root owns, skips as another user, and `TestClaimAnchorWithNoToken` checks the refusal without machine authority and then skips the rest, where it passed having checked nothing more. - **The tests name no folder of our own build machine.** The sandbox tests' fixture folder is `/srv/privatae-optest` (`/tmp` cannot hold an op's effects), and the action tests' run note asks for any folder outside `/tmp`. - **How this repository is published (docs only).** From this release on, GitHub receives each change merged since the last release as its own commit, titled as the change was, instead of one commit per release. The README, CONTRIBUTING.md and SECURITY.md say so, and say that releases from 0.53.2 on can be verified from a clone, since the earlier tags are not public. - **The personal-data check reads what it used to miss.** A listed term written right after a backslash escape (`\n`, `\t`, `\u00e9`) is found, and so is any value inside a run of base64 or base64url that decodes to text. The reserved entity ids are read from `/etc/privatae/reserved-entity-ids` (one per line, `RESERVED_ENTITY_IDS_FILE` to point elsewhere) instead of being written in the script; where that file is absent, as in public CI, the entity-id check is skipped and the run prints a NOTE, as the term check already does without its key. A file that holds no id, or a line that is not one, fails the run. - **The former-name check reads comments and Markdown.** A kept identifier is set aside only in Go code; in a comment, this changelog or any other file it is a hit like the bare word, and the two entries that spelled the device-fingerprint prefix out now name `fpSANPrefix` instead. - **CI on GitHub.** `.github/workflows/ci.yml` runs the jobs of `.gitea/workflows/ci.yml` on GitHub Actions in the same Go image, pinned by the same digest, with no action from the marketplace and a token that can only read. A step fails if its digest stops matching `deploy/build.sh`. - Tests use documentation addresses (`2001:db8::/32`, `203.0.113.0/24`) and neutral paths, and comments state their reason instead of citing a pull request by number; a test keeps published source free of such numbers. ## 0.54.1 - **No change to what the bastion does.** The README is now a short front page: install, quick start, local and networked modes, a summary of the security model, how this repository is published, and how to build and verify a release. The reference text moved into one page per topic under `docs/`, listed in `docs/README.md`, with only its headings and links changed. The README and CONTRIBUTING.md now say that issues and pull requests are welcome on GitHub. - **The claims table is its own file, `docs/claims.md`,** and `deploy/check-claims.sh` reads it there. Two rows that cited two tests each now cite one test per row, so the check passes again. ## 0.54.0 - **Every audit row also goes to the system log.** The rows written to `mgmt_audit.jsonl`, `pairing_audit.jsonl` and each pairing's `audit.jsonl` (terminals, root commands, store ops, op runs) are also written to the machine's system log, so a log collector can keep them where no host can write: syslog facility `auth`, tag `privatae-bastion-audit` (the journal on systemd) on Linux and macOS, and the Application event log, source `PrivataeBastionAudit`, on Windows, registered when the system service is installed and removed with it. Each line is the file's row with `audit` (the file) and `pairing` added, and a root command's text left out (its digest and length stay). The lines go through a bounded queue, so a slow or stuck system log never holds up the audited op; a full queue drops lines and the next line carries `system_log_dropped`. The file stays the record. - **A server is networked and anchored by default.** `install.sh` run as root now installs the networked bastion (systemd on Linux, launchd on macOS); `--local` keeps a root install local, a reinstall over a machine already paired in local mode keeps it local unless `--networked` is passed, and run as your own user it is local as before. On a networked machine with no anchor, the first `pair` run with machine authority anchors it to that host. Before, such a machine refused every op until `claim-anchor` was run by hand; now it is anchored at once: every terminal and every write waits for the person's approval, and any later host for the anchor's. `pair` then says to enrol the second factor. `claim-anchor` with no token on a machine already anchored to that host now succeeds and changes nothing (an installer that anchors after pairing finds the work done); naming another host is still refused without `--rotate`. The Windows installer is unchanged: `-Networked` as before. ## 0.53.2 - **No change to what the bastion does.** Two code comments named source files of the Host program; they now describe them instead, so the release's source can be published to the member repository as built. CI checks for such names before a change merges. ## 0.53.1 - **`switch` turns a capability family off for a host from a terminal.** `sudo -H privatae-bastion switch on|off` writes the same local deny list the console page's switch does, so it can only remove reach: off makes this machine refuse that family for that host and tell it why, and on clears the refusal without granting anything past the host's signed grant. Both directions need machine authority. `switch --list` prints every paired host's families, on or off, one per line, for a person or a setup script on a machine with no browser; beside a system service on macOS or Windows it is refused to an ordinary account, whose own state is not the machine's. The deny list is now replaced whole (temp file and rename) under a lock both writers take, so the gate never reads it half written and a switch from the page and one from a terminal cannot drop each other. - **`release-sign` signs C-Series Cloud manifests.** It accepts the kind `cseries-cloud` beside `bastion` and `bastion-agent`, with the same statement and keys, for the manifest a C-Series Cloud machine's updater checks before it moves to new images or a new overlay. - **`release-sign` signs the C-Series App Store catalog.** It accepts the kind `cseries-store`, with the same statement and keys, for the catalog a C-Series Host checks before it lists or installs an app from the public store. A signature for one kind does not verify as another. - **Fixed: `stepup-enroll --console-only` refused root on Linux.** The startup check recorded whether the process administers the machine only when a macOS or Windows system service was installed, and on Linux the service is the systemd unit, so that check never ran there and root was told it does not administer the machine. Root on Linux enrols into its own state, which is the state the unit reads. An ordinary account on Linux is still refused, and so is every account on macOS and Windows until the system service is installed. `approvals-owner` shares the rule and now takes root on Linux the same way. - **`pair` takes the token on standard input.** `pair -` reads it as the first line of standard input, so the token is not in the process list while `pair` runs or in the shell's history after. Every form of `pair` takes `-` in place of ``, and the token as an argument works as before. `pair` with no token is still the usage error, whatever standard input is: some terminals do not register as one, and reading there would wait with nothing said. At a terminal, `pair -` asks for the token on standard error. A first line that is empty, more than one word or longer than 4096 bytes is refused, and no refusal repeats what was read. ## 0.53.0 - **A Host with no platform in a VM on this machine can pair over TCP.** `pair --redeem-url https://127.0.0.1:8810/v1/local/redeem --host-ca ` redeems the token on the Host's enroll port (`POST /v1/local/redeem`, the platform redeem's body) over TLS that trusts that CA and nothing else, no system roots, and checks the name `host.cseries.internal` on the Host's certificate. The URL must be `https://[:]/`, with no user info, query or fragment. `--host-ca` is required with it and refused with a unix or platform pairing, and must hold CA certificates only; all of this is checked before the token is read, so a refusal dials nothing. The redeem follows no redirect: an enroll port that answers with one is refused as it answered. The Host's answer must say transport `tcp`, carry the same CA as `--host-ca` and an operator certificate for this bastion's key that the CA signed for client use, and name a lane on the host the redeem URL names. The pairing records transport `tcp`, the lane address and the CA, which is pinned as for a unix pairing: a `pair --replace` under another CA is refused before the redeem dials, and a pairing does not change transport on a `--replace`. - **Its lanes go to the Host's lane port**, the address its answer names, with the same mutual TLS as the unix lane socket: this bastion's operator certificate, which the Host knows it by, the pinned CA as the only root, and the name `host.cseries.internal`. Then the same bearer and protocol: egress, management, the two parked harness workers, sessions, git, the heartbeat and what its reply directs. `status` and `pair` show the lane address. - **What a TCP pairing does not have yet.** No WireGuard forwards: no ollama, LLM or PII forward, no service slots, no endpoint rotation, as for a unix pairing. No coder runs, on any machine, Linux included: a run's control plane is a tunnel port or a socket in a unix pairing's runs directory, and a TCP pairing has neither, so run-up for a TCP-paired Host is refused before any container starts, and so is every run-up while this machine's pairings cannot be read. Off Linux, coder runs stay refused for every pairing, as before; running them inside the Host's VM is not built. - `unix://` redeem URLs work as before. - **Beside a Windows system service, the signed-in person approves in their own tray.** The service serves a named pipe, `\\.\pipe\privatae-bastion-approvals`. Its ACL admits SYSTEM, Administrators and interactive users. Interactive users get read and write and no right to create an instance of the pipe. Remote clients are refused. The service identifies each client from the pipe (`GetNamedPipeClientProcessId` and that process's token) and serves only a process that runs as the person signed in to its own session, in an active session, and of those only the account that owns this machine's approvals (below); every other account is refused for list, deny and approve alike. Through it a client can list the pending step-up requests, in the words `approvals` prints. It can approve one request id with a code from the authenticator enrolled here, checked by the existing step-up check and bound to the sha256 of the purpose the client was shown. It can deny one, which needs no code. - **`privatae-bastion approvals-owner --sid [--replace]`**, with machine authority, records that account in the service's state (`approvals_owner.json`); an installer's elevated step passes the SID of the account that ran it. The same SID again changes nothing. A different SID is refused and the recorded owner stays, unless `--replace` is given, which is taken only on a console (an Administrator terminal), never with output captured as an installer runs it. A record that cannot be read is treated the same way. With no readable owner, the pipe serves nobody. - **The pipe's name is never free while the service serves it.** A replacement instance is created before a failed connect's instance is closed, as it already was before a connected one is handed over. If another process holds the name when the service starts, the first create fails (it insists on a new name), and the service tries again, waiting 1 s and doubling to 60 s, logging once per series of failed attempts and once when serving again. It never serves under a name it did not create. - **What never crosses the pipe.** The TOTP secret, `ui.secret` and approval ids. Device approvals are not listed, because they need no code. - **Wrong codes on the pipe are limited** (NIST SP 800-63B 5.2.2). Five are free. After that the pipe takes one attempt per 30 seconds. After 100 in a row it takes none. A code accepted on any other lane resets the count. - **`privatae-bastion tray`**: the person's icon and approvals window, run as them and drawn from the pipe. `service install` starts it at every sign-in through the HKLM Run value `PrivataeBastionTray`, and `service uninstall` removes that value. One tray runs per session. The tray checks that the pipe's server is the `PrivataeBastion` service process before it sends anything. The daemon's own tray stays as it was for a bastion that runs as the person; the service still shows none. - **`stepup-enroll --console-only`**, for an installer's elevated step. The secret is shown only when standard input and output are a console. It is saved only in the system service's state: on Windows and macOS when the service is installed and the process has machine authority, on Linux when the process is root. Both checks run before a secret exists. - **`release-anchor --host (--host-ca | --force)`**, with machine authority, for an anchor whose Host is gone, e.g. a Host on this machine deleted with its data and set up again. `--host-ca` is the new Host's CA. It is refused when that is the CA this box pinned for the host, since the same CA means the same Host, alive, and when this box pinned no CA to compare. `--force` skips that check and is taken only on a console; an installer never passes it. All of this is checked before anything changes. It drops the anchor if it is that host and refuses any other. It unpairs that host. It renames that host's DCN state (key pin, factor, grants) to `dcn/.released-