Files
Privatae 48899b41a9
ci / personal-data (push) Successful in 4s
ci / test (push) Successful in 4m58s
ci / fuzz (push) Successful in 3m33s
bastion 0.63.1
2026-11-03 22:56:18 +00:00

2628 lines
164 KiB
Markdown

# Changelog
All notable changes to the bastion.
The published version is whatever <https://privatae.ai/bastion/version.json>
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
<Host> 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 <pid|session>`
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 <id>
[--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 <account>`.
- **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 <pid or
session>` 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 <path> [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 <unit> [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 <port> [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 <grant | all>` 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
<grant>`.
- **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 <code>`
(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='<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 '<code>'` 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 <host> <family> 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 `<token>`, 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 <pem file> <token>`
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://<host>[:<port>]/<path>`, 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 <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 <name> (--host-ca <pem file> | --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/<host>.released-<time>`, so a new Host under the same name
starts clean. It succeeds when there is nothing left to release. After it,
`pair` and `claim-anchor` work for the new Host.
- **A step-up code is spent only when the approval lands.** Approving a
request checks the code, records the approval and marks the request
approved under one lock, reading the request again before it is marked. A
request answered meanwhile (another process shares the files, not the
lock), or an approval that cannot be saved, withdraws the approval and
gives the code back, unless a newer code was spent meanwhile. The code is
recorded as spent before the approval exists, so no crash leaves an
approval on a code that still works.
- **`service install` on Windows reads where Program Files is from HKLM**
(`ProgramFilesDir`, the 64-bit view) instead of `%ProgramFiles%`, which an
elevated process inherits from the elevating account's own environment.
- Approvals from an Administrator terminal and from the admin page work as
before.
## 0.52.1
- **The device fingerprint is sent under a second name to a Host with no
platform.** A pairing with such a Host (`--redeem-url unix://…` or
`https://…`) sends an operator certificate request that carries the
fingerprint as two URI SANs: first under the original device-fingerprint
URN prefix (`fpSANPrefix` in pair.go), then `urn:privatae:hwfp:<fp>`, which
that Host reads. A platform pairing's request names it once, under
`fpSANPrefix`, as before: the platform signs a request with that one name
and refuses one with any other. A signed accounting verifies with the
fingerprint under either name, and a signer certificate that names two
different fingerprints is refused rather than resolved.
## 0.52.0
- **A Host with no platform can own the bastion on its own machine.**
`pair --redeem-url unix:///run/privatae-host/enroll.sock <token>` redeems the
token over the Host's enroll socket (`POST /bastion/enroll/redeem`, the
platform redeem's body) instead of `api.privatae.ai`. The pairing records
transport `unix` and the lane socket and runs directory the Host names, both
of which must sit inside the directory of the enroll socket. The redeem URL
names a socket path only: a host, a port or a relative path is refused
before the token is read. The Host's CA is pinned the way the platform's is,
and a `pair --replace` that brings a different CA is refused.
- **Its lanes go over the Host's lane socket.** A unix pairing runs every lane
that goes to the host's `:8443` over the tunnel (egress, management, the two
parked harness workers, sessions, git, the heartbeat) over the lane socket
instead, with the same mutual TLS, bearer and protocol. A pairing with no
bearer opens no lane. It has no inference or service-slot forwards and no
endpoint rotation, which all need the tunnel. Platform pairings run exactly
as before.
- **A coder run of a Host on this machine is a socket.** Its control plane is
served at `<runs_dir>/<run_id>.sock`, 0660, group `privatae-host` (root, with
the reason logged, when the group does not exist), in place of a tunnel
port. Only the owning pairing's directory gets it, every connection passes
the run port's checks, and it is removed at run-down and when the watchdog
stops the run. Run-up's answer carries `control_socket`. The socket is made
only in a runs directory root alone can change: a real directory owned by
root that nobody else may write, under directories that are root's and
either writable by nobody else or sticky. The Host names the runs directory
and writes the shared one, and the socket is chmod-ed and chown-ed by path,
so a directory the Host could change would let it swap the socket for a link
to any file on the machine and get that file. A socket is opened only for a
run that is the pairing's and still up at that moment.
- **The `workspace` engine, and images from where the owner says.**
`workspace` joins the engines a run may name. `coders set --image-prefix
<registry/path/>` sets where run images come from (`default` resets it to
`git.privatae.ai/privatae-harness/`, so machines that never set it are
unchanged). Run-up now takes only `<prefix>harness-<engine>@sha256:<digest>`,
the engine's own image, and pull credentials only for the prefix's registry.
- **An image built or loaded on the machine runs without a pull.** Docker's
classic image store keeps no repo digest for such an image (measured on
Docker 29.1.3: after `docker load` and after `docker build`, inspecting
`<repo>@sha256:<id>` fails and `run` tries to pull; the containerd store
keeps one and needed no change). Run-up now starts such a run from its image
ID, when the image with that ID carries a tag of the named repository. The
ID is the digest of the image's config, which names every layer by its
content's digest.
## 0.51.0
- **Coder runs can no longer take the memory, CPU or GPU the machine itself
needs.**
Run-up used to start a run whenever fewer than `max` were going and free
memory covered one run's limit. Runs that start together all see the same
free memory before any of them has grown, so on a 3.7 GiB machine twelve
512 MiB runs started, together left it 0 MiB free, and sshd stopped
answering until the machine was rebooted. Now:
- Run-up adds this run's limit to the limits of the runs already going and
answers `full` when the total is more than the machine can spare: its
memory minus GKE's node reservation (25% of the first 4 GiB, 20% of the
next 4, 10% of the next 8, 6% of the next 112, 2% above 128 GiB) and a
100 MiB eviction threshold. It also answers `full` while the machine is
stalling on memory (PSI memory "full" avg10 at 60% or more). The `full`
answer adds `mem_budget_mb`, `mem_committed_mb` and `mem_pressure`.
- A watchdog in the daemon looks every 2 s and stops the newest running run
when the machine has under 100 MiB free, or when memory pressure has stayed
at 60% or more for 30 s (systemd-oomd's defaults), then waits 10 s before
pressure may stop another. While the run it stopped last still has
processes, it stops no other for up to 10 s, so a slow exit does not turn
one stop into two. It kills the run through its cgroup
(`cgroup.kill`), so it never waits on a container runtime that is slow
for lack of memory, and keeps the run's record: run-down and run-status
say why in `stopped_by`. On a cgroup v1 or hybrid machine, or a kernel
without `cgroup.kill`, it stops the run through the runtime instead, and
run-up counts such a run at its limit until the run is gone.
- `coders on` sizes `max` from the same budget instead of keeping a flat
2048 MiB for the machine, `coders status` shows the budget and how many
runs fit in it, and the heartbeat's coders block adds `mem_budget_mb`.
- CPU: every run starts with `--cpu-shares 2`, the lowest share (cgroup v2
`cpu.weight` 1; the machine's own services sit at the default 100), as the
kubelet gives a BestEffort pod. A run still uses any CPU nothing else
wants, up to `--cpus`, and yields it whenever the Host, sshd or anything
else on the machine needs it.
- GPU: a run gets none. It has no `--gpus` and no `--device`, and starts with
`NVIDIA_VISIBLE_DEVICES=void`, so a machine whose default container runtime
is NVIDIA's exposes no GPU to it either, even from an image built on a CUDA
base. Where the toolkit's config sets `swarm-resource`, the toolkit reads
the names listed there first, so a run sets each of them to `void` too.
## 0.50.1
- **Coder runs pass `PRIVATAE_*` names for the run's id, its control
capability and its two sockets** (`PRIVATAE_RUN_ID`,
`PRIVATAE_HARNESS_CAP`, `PRIVATAE_HARNESS_CAP_ENFORCE`,
`PRIVATAE_SIDECAR_CONTROL_SOCK`, `PRIVATAE_SIDECAR_EGRESS_SOCK`). The
harness images published with this release read these names first.
## 0.50.0
- **Coder mode: a host can place coding runs on this machine, one container
per run.** Linux only, with the bastion running as root, and off until the
owner turns it on. `privatae-bastion coders set [--max N] [--mem MB]
[--cpus F] [--engines a,b|all] [--allow-lan host[:port],...]` sizes the
machine (at most 1 to 100 runs, 512 to 65536 MiB and 0.25 CPUs up to the
machine's count per run, engines from codex, cursor, grok, claude-code,
opencode, kimi and antigravity) without switching anything on. `coders on`
fills what was not set: 2048 MiB and 2 CPUs per run (the machine's CPU
count when it has fewer than 2), every engine, and as
many runs as fit in the machine's memory less 2048 MiB, refusing when not
even one fits. `coders off` stops new runs; runs already going continue
until they are stopped or reaped. `coders drop [--host <host>]` takes back
the standing coder grant of every host, or one, and stops their runs.
`coders status [--json]` shows the settings, the grants and the runs.
- **The heartbeat says when this machine takes coder runs.** It carries the
`coders` capability and a `coders` block (the limits, how many runs are
going and how many are this host's, the grant's limit, memory, CPUs, the
architecture, the container runtime and the engines) only while this
machine would accept a run from that pairing: Linux, root, coders on, a
container runtime, the pairing allowed the `harness` family, and on an
anchored machine a valid coder grant. Otherwise both are absent, never
empty.
- **Three verbs on the harness lane, mode `coder`.** `run-up` checks the
request (run id, engine, an image `git.privatae.ai/privatae-harness/
harness-<name>@sha256:<digest>`, the engine's uid 10001 to 10099, a
credentials folder `/home/privatae/.<name>`, at most 1 MiB of credentials,
the run's control capability, `max_minutes` 1 to 1440, default 240),
pulls the image only when its digest is not here, with a throwaway
registry config holding only this run's credential, starts the container
and answers as soon as the run answers `/health` on its control socket
(within 60 seconds), so no harness worker is held for the run's length. A
run that does not fit (the machine's limit, the grant's limit, free memory
or a free port) gets `ok`, exit 0, `started: false`, `reason: "full"`: a
capacity answer, never a refusal, so a burst does not trip the quarantine.
`run-down` reads the container's `memory.peak` and `cpu.stat`, stops it,
confirms it is gone, removes its files and reports peak memory and CPU
seconds (both null once the container has exited, since they are read
through its running process), wall time (null when the run has no
record), whether it was OOM killed (null on nerdctl, whose inspect does
not report it), its exit code and how many egress requests were refused.
On an anchored machine only the host that started a run, or the anchor
host, may stop it; on a local machine any paired host may. A container without this
run's `privatae.coder.run` label is never stopped. `run-status`
lists the pairing's runs (every run, for the anchor host).
- **Each run is a hardened container.** No network (`--network none`),
every capability dropped, `no-new-privileges`, the seccomp profile the
platform's own harness pods run under (`seccomp-personal.json`, built into
the bastion and pinned by its SHA-256 in a test), a read-only root, the
engine's uid, memory with no extra swap, CPUs, 4096 processes and a `/tmp`
tmpfs the size of the run's memory. Its workspace, home and control folders
are owned by the engine's uid, mode 0700, and its credential files 0600.
Its deadline is `max_minutes` plus 15 minutes; past it the tunnel port
refuses and the reaper removes the run.
- **A run's only way out is `egress.sock`.** One proxy per run, a unix socket
owned by the engine's uid, mode 0600, taking HTTP CONNECT and absolute-form
plain HTTP. Anchored or not, it refuses loopback, private, CGNAT,
link-local (the cloud metadata address included), ULA, multicast, reserved
and unspecified addresses, the IPv6 forms that carry an IPv4 address and
site-local `fec0::/10`,
`localhost` names, names that do not resolve and a name with even one such
answer, and any address this machine takes as its own (see below). It
dials only the addresses it vetted, IPv4 first, never the name again. A LAN
host passes only when the owner named it in `coders set --allow-lan`.
Destinations are never logged; refusals are counted on the run.
- **The host reaches a run through one tunnel port per run, 18200 to 18299,**
on the owning pairing's tunnel only, joined to the run's `control.sock`.
Every connection is checked when it is accepted: the pairing still holds the
`harness` family, the run is that host's and within its deadline, and the
connection comes from that host's own tunnel address. The socket's inode is
pinned and its far end must be the engine's uid.
- **A reaper removes runs nobody holds.** At start and on every heartbeat
reply it removes a run past its deadline, one whose host is no longer
paired, one that stopped (exited or missing) more than 600 seconds ago,
and one older than 120 seconds that two replies in a row listing
`coder_runs` left out. The 600 seconds count from the exit time the
container runtime reports or, when it reports none (the container is
missing, or the runtime keeps no exit time) or one later than now (the
clock stepped back), from when the reaper first saw the run not running;
such an exit time keeps counting from that sighting once the clock passes
it. That first sighting is kept in memory, so a restart of the bastion starts
those 600 seconds again. When the
container runtime does not answer the listing, it skips that round rather
than read every run as exited.
- **On an anchored machine a standing coder grant replaces the per-run
approval.** `coder_grant_sync` installs a grant signed with the pinned key,
of kind `coder_grant`, for this server and for the host that sends it, naming
its engines, at most how many runs (1 to 100) and an expiry within its
approved days; the person approves it once, here. `coder_grant_drop`, the
host leaving a grant out of two heartbeat replies in a row, or `coders
drop` takes it back, and a drop is never held back. Both ops are in the
`harness` family. `run-up` and `run-down` still honour the quarantine;
`run-status` is a read. The enforcing report lists the coder grants held.
- **This machine's own addresses, on every lane that dials for someone
else.** A destination is this machine when it is on one of its interfaces
(a public address included) or, on Linux, when the kernel's routing table
delivers it here: any route type but unicast (`local`, as for an AnyIP range
on `lo`, broadcast, anycast, multicast), asked of the kernel per connection.
The IPv4 address inside an IPv4-mapped, NAT64 or 6to4 address is checked
too, and when the machine cannot tell, it refuses. A public address that
reaches this machine only through a 1:1 NAT in front of it is not caught.
- **The anchored egress lane refuses destinations that are this machine.**
Before, on an anchored machine it refused only the internal ranges
(loopback, private and ULA, link-local, CGNAT, multicast, unspecified and
reserved, also inside a NAT64 or 6to4 address); it now also refuses this
machine's own addresses, by address and by name, with no exception. A
local (unanchored) machine's egress lane is unchanged and vets no
destination.
- **The backup lanes refuse destinations that are this machine.**
`backup_fetch` and `backup_push` refused only loopback, private,
link-local, multicast, unspecified, CGNAT and ULA addresses. They now also
refuse this machine's own addresses, for every address the URL's name
resolves to, each redirect a fetch follows and the address dialled.
- **The backup lanes classify an address as a coder run's proxy does.**
`backup_fetch` and `backup_push` read the IPv4 address inside a NAT64
`64:ff9b::/96` or 6to4 `2002::/16` address and refuse it when that IPv4
address is internal, so `64:ff9b::a00:1` and `2002:a00:1::1` (10.0.0.1) no
longer pass. They also refuse IPv4-compatible `::a.b.c.d` and
IPv4-translated `::ffff:0:a.b.c.d` addresses, the rest of `64:ff9b::/32`,
site-local `fec0::/10`, `0.0.0.0/8`, `198.18.0.0/15` and `240.0.0.0/4`;
before, all of these passed except `::`, `::1` and `0.0.0.0`. This is a
tightening: a backup URL that resolves to any of them, or a redirect or
dial to one, is refused now.
## 0.49.0
- **The anchor host can run root commands, under one grant given at the
machine.** `sudo -H privatae-bastion root-exec grant --host <anchor host>
--for 30d` shows what it grants, asks for the code from the authenticator
enrolled here, and lets that host run commands as root on this machine one
at a time, with no approval per command, until the time chosen.
`privatae-bastion root-exec` shows the grant and `root-exec revoke` takes it
back. The grant is written only by that command: nothing the host sends can
create, widen or extend it, a member pairing never holds it, and the
`session` family switched off here stops it.
- **One command per frame on the session lane.** The host sends the session
lane's `S` frame with op `session:root:exec`, the command, a deadline and an
output size; the bastion answers `J` with stdout, stderr, the exit status,
the duration and whether anything was cut, then `X`. The command runs as
`/bin/sh -c` in its own process group with a plain root environment and no
input; its deadline (default 10 minutes, at most 8 hours) and the host
closing the socket kill the whole group: SIGTERM, then SIGKILL 2 seconds
later whether or not the shell has already gone, so a child that ignores
SIGTERM does not outlive it. The grant, the local switch and the quarantine
are checked again every 2 seconds while a command runs: a revoke or an
expiry stops a command already running, answered with 126 and a `J` whose
`stopped` says why. That stop kills the command's whole cgroup too, so a
child that left the process group (`setsid`, a double fork) does not keep
running as root after the grant is gone. Each stream returns at most 256 KiB
unless the host asks (at most 4 MiB), keeping its start and end and stating
how much was cut. An older bastion refuses the frame as not a session op.
- **Every root command is recorded here.** The command and its whole output
(up to 64 MiB) go to `sessions/<id>.log` in the context store, and the audit
file gets a start and a stop row with the command, its sha256, the exit,
the duration and the byte counts. Every other outcome gets a row too, with
the command: a refusal, a bad command or session id, a transcript that would
not open, a command that would not start.
- **The bastion records where each root command connects.** On Linux each
command starts in a cgroup of its own, and an nftables table the bastion
creates for that command (and removes when it ends) records every
connection the command and its children open: destination, protocol, port
and how often. The table only records; it never changes what the machine's
own firewall allows. The `J` frame gains `untrusted_connections`: the
destinations outside the platform's own hosts, at most 32, the rest counted
in `untrusted_connections_more`, and absent when there were none, so an
older host reads the frame as before. A host that reads it treats the
output as untrusted and ends root for the turn. The platform's own hosts are
listed in `platform_hosts.json` in the bastion's config folder (addresses,
CIDRs, names); with no list, every connection off the machine counts as
outside, and loopback never does. A command whose connections could not be
watched (not Linux, no `nft`, no cgroup of its own) gets one entry, `*`, that
says why. Every connection, the platform's marked as such, goes into the
transcript and the audit stop row. Connections another program makes for
the command (dockerd pulling for `docker build`, a unit `systemctl` starts)
are not the command's and are not seen.
## 0.48.0
- **On macOS and Windows a networked bastion runs as the system's service.**
A process the signed-in person started is one they can stop, which is
right on their own computer and wrong on a machine an organisation enrols.
`install.sh --networked` on macOS now installs a launchd daemon
(`ai.privatae.bastion`) running as root, and `install.ps1 -Networked` a
Windows service (`PrivataeBastion`) running as LocalSystem. Both start at
boot, before anyone signs in, and come back after they stop: launchd's
KeepAlive on macOS, and on Windows a restart five seconds after any failure,
which also covers the exit after a self-update. `privatae-bastion service
install|uninstall|status` does the same by hand and needs machine
authority. Linux is unchanged: its service is still the systemd unit.
- **The service's program can be replaced only by root or an administrator.**
On macOS the installer puts it in `/Library/Application Support/privatae/bin`
with a link on `PATH`, and `service install` refuses a program that a
non-root user owns or that a group or everyone can write, or that sits in
such a folder (`/usr/local/bin` belongs to the signed-in user on many Macs).
On Windows it goes in `Program Files\Privatae`, and a program anywhere else,
including the per-user install folder, is refused.
- **An administrator's commands act on the service's state.** With the service
installed, a command run with machine authority (`sudo` on macOS, with or
without `-H`, or an Administrator terminal on Windows) reads and writes the
state the service uses: root's home on macOS, LocalSystem's profile on
Windows, which only SYSTEM and administrators can open.
- **No second bastion from an ordinary account beside the service.** With the
service installed, `pair`, `run` and `status` from an account that does not
administer the machine are refused with a sentence saying what to do. This
binds a cooperating binary only. An administrator's `run` beside a running
service is refused too, because it would hold the same pairings and bring
up the same tunnel: on Windows by the existing run lock, and on macOS by a
lock the daemon now takes in its state folder. A Mac without the daemon
takes no lock and behaves as before.
- **As a Windows service there is no tray, no dialog and no detached
relaunch.** A service has no desktop: a dialog would wait for ever where
nobody sees it, and a relaunch would be a second bastion beside the one the
service manager restarts. What the service prints, a crash included, goes
to `bastion.log` in its state folder, set aside once it passes 8 MB. A
fresh service waits for its first pairing instead of failing every few
seconds. The macOS daemon's log is root-only and set aside at the same size
when the daemon starts.
- **The Windows installer's -Networked run** checks for an elevated terminal
before downloading anything, stops a running service before replacing its
program, removes the logon task an earlier install registered and stops any
bastion still running from a per-user install, and adds the program's folder
to the machine's `PATH` as the registry stores it, leaving every
`%VARIABLE%` entry unexpanded. Pairings made by a per-user install are not
carried over; the machine is paired again.
- **The macOS installer prints the anchor command with the program's full
path,** not the name on `PATH`, which may belong to the signed-in user.
- **On a Windows service the folder's agent runs as the folder's owner, never
as LocalSystem.** The Windows form of the unix drop: the owner is read from
the folder's security descriptor and refused when it is SYSTEM, a service
account, a group such as Administrators (the usual owner of what an elevated
administrator made), a built-in account such as the Administrator or Guest,
or not a person's account at all. The agent is started under the owner's own
token from their signed-in session, so it runs while they are signed in and
the store card says so when they are not; starting it for an owner who is
signed out needs a token made without their password, which is not built. The
owner is given access to their store's state leaf, its public cert and the
runtime by a permission entry. The leaf's entry is set only while the leaf is
empty or found already right: a leaf holding another account's state is left
as it is and named. Nothing is written into the person's folder: the agent's
directory stays in the bastion's state. The child's environment names the
owner's profile rather than LocalSystem's, and its log must be a plain file
with one link.
- **Not yet, and said so in the code:** on a Windows service the signed-in
person's `context` cannot reach the bastion, because the named pipe it
would use is not built; folders are added from an Administrator terminal.
## 0.47.3
- **The lane addresses are read under their PRIVATAE_* names only.** The local
inference, sterilizer and bastion agent lanes come from `PRIVATAE_LLM_ADDR`,
`PRIVATAE_PII_ADDR` and `PRIVATAE_BASTION_AGENT_ADDR`, `_CERT`, `_KEY` and
`_CA`. Their older spellings are no longer read, so a lane set under one
stays off until it is renamed in the bastion's unit environment. The
installers set none of these.
- **The source carries the product's own name.** Comments, tests and this
changelog no longer use the platform's former internal name. A test fails on
it anywhere outside the few identifiers the host and its harness image still
use: the delegation tag variable, the harness image's three variables and
workspace path, and the device-fingerprint prefix in the pairing request.
## 0.47.2
- **A move to a new Hub key is not held back by an old failure.** A host can
move its machines to a new Hub key on the same relay entries. The bastion
remembered failed probes per entry, so a dead spell under the old key could
hold the move back for up to a day, and a probe of a key the Hub did not
answer yet could keep the machine off its preferred entry. A failure is now
remembered for the entry and the key that was tried.
- **A move whose save failed is saved on a later beat.** When the bastion
moved to a new entry or key but could not write it to its pairing file, the
file kept the old ones and nothing tried again, so a restart came up on the
old key. Once the host retires that key the machine could no longer reach
it. The save is now retried every minute until it lands; a pairing whose
file was removed is not brought back.
- **A host paired again keeps the entry and key it was given.** After
`pair --replace` (or `unpair` and `pair`) for a host the running daemon
still serves, the daemon could write the old pairing's entry and Hub key
into the new pairing's file, which would then start on them. It now writes
nothing into a pairing file that holds a different WireGuard key.
- **The heartbeat says when a move is not saved yet.** Each beat now carries
`endpoint_save_pending`: true while the bastion runs on an entry and Hub key
it moved to but has not managed to write to its pairing file, false
otherwise. A host can then keep the old Hub key while any machine says true,
so a machine whose save keeps failing is not stranded at its next restart. A
pairing started again from its file reports only its own moves.
- **A pairing made again comes up without a restart.** `pair --replace` run
from a shell (or `unpair` and `pair`) while the daemon runs wrote new
credentials that the daemon did not use until it was restarted. The daemon
now notices on its next check, every 15 seconds, that the pairing file holds
a new WireGuard key, and restarts that pairing's tunnel and lanes from it.
- **A stopped pairing writes nothing to its file.** A heartbeat stuck on a
dead tunnel could come back after its pairing was stopped and started
again, and save the stopped copy's older entry and Hub key over the new
ones. Nothing is written from a stopped pairing any more.
- **No change to the program:** the README's table of how each claim is
tested cites one test per row again, so `deploy/check-claims.sh` passes.
## 0.47.1
- **No change to the program.** Comments and test data added since 0.43.1
no longer name our own machines, repositories, design notes or the
platform's source files; one older test uses neutral sample data and one
older test comment no longer names a platform file. Every check the tests
make is unchanged.
## 0.47.0
- **A backup key can live on this bastion.** A host encrypts its backups to
its recovery key, which its owner keeps, and to any bastion keys registered
with it. `privatae-bastion backup key init` makes an X25519 key here, kept as a standard age key file (0600) in
the bastion's state folder, and prints the three fields to register on
your host: kind `bastion`, a label (this machine's name unless you give
`--label`), and the public key. The host then encrypts its backups to it too.
Only the public half ever leaves this machine. `--replace` makes a new key
and keeps the old one, which still opens the backups made with it; `backup
key show` lists both.
- **A host restores one backup only after you approve it here.** A host that
restores a backup asks this bastion for that backup's own key
(`backup_reseal`). The request waits in `privatae-bastion backup requests`
until you answer it with `backup approve <id>` or `backup deny <id>`;
approving asks for your authenticator's code when one is enrolled here.
The key held here opens the backup's sidecar, the sidecar is checked to be
the backup it names, and the same record is sealed to the requesting
host's own key alone. A request is bound to the pairing it came in on and
that pairing's host number, and a sidecar of another host's backup is
refused, whatever the request calls itself; restoring a lost host's
backups onto its replacement is `backup restore-plan`, at this machine.
A sidecar may name a backup kept in platform storage (`b/`) or staged
there for a copy kept only on a bastion (`s/`). Before you approve,
`backup approve` shows the pairing and its host number, what the host
says, and what the sidecar itself says, opened here (host number, when it
was made, which backup). A host can hold at most 16 requests, or
512 KiB of them, waiting here; answering one frees its room at once, and a
request lapses after a day. The request file is locked while the daemon or
`backup approve`/`deny` changes it, so neither loses the other's change,
and a request file that cannot be read is set aside (and named by `backup
requests`), never overwritten. A host's label is refused if it holds any
control or formatting character. A
sidecar whose `made_at` is not a time is refused, and everything shown
from a sidecar is quoted.
`backup reseal` does the same offline. The owner can also do this in their
browser with the host's recovery key.
- **Your backups held here, listed and sent back.** `backup_list` tells a
host which backups this machine holds for it, with the sizes and SHA-256
of each data file and sidecar; a digest not yet known is read in the
background and appears on a later listing, so a listing never waits on a
large file; a read that does not return within two minutes (a mount that
stopped answering) holds up no other folder's digests. The two minutes
count from the last read of the file that returned, so a large backup on a
slow disk that keeps answering never counts as stuck and refuses no push
from its folder. `backup_push` sends one of those files to a short-lived https
upload address the host gives: only a file named like a backup, directly
in the host's own backup folder or a folder you shared with it, through no
link that leaves it; checked against the SHA-256 the host named before a
byte is sent; never to this machine's own network or through a redirect.
The header the upload address was signed with (`headers`, as the
platform's `backup.put_url` returns them: `If-None-Match: *`, so storage
never writes over an object that is there) goes on the upload. It is the
only header a host may hand over, and only with that value: any other
header name or value is refused before a job starts, and so is a start
with no headers at all (the platform always hands that one over).
`start` answers at once and the check runs in the job; a job whose file
stops answering for two minutes (a mount that died mid-push) fails with a
sentence and its upload is stopped. A push waiting for its turn to read
(behind a stuck read) is not a read: after two minutes of waiting it fails
with a sentence saying what it waited on (its folder's stuck read, or the
cap), never one blaming a folder that answers. Pushes and the background
digests keep one record of reads that have not returned: a folder holds at
most one stuck push read and one stuck digest read however often a push is
started there, and the bastion at most eight of both together; a push is
not started in a folder with a read of either kind that has not returned,
the digests skip a folder whose push read has not returned, and neither
starts while eight reads have not returned. A listing creates
nothing: the host's backup folder is made by the first backup op that
writes. Every folder is read with the same two-second bound as the host's
own: a shared folder on a mount that stopped answering, or one that now
reaches this bastion's own state, is left out of the listing and named
under `unavailable`, and a push from it is refused with a sentence.
- **Sharing a lost host's backups with the host that replaces it.**
`privatae-bastion backup share --with <new host> --from <lost host>` (or
`--dir <folder>`) lets the new host list and ask for the backups in that
folder, as `shared/<name>`; it cannot write there. `backup shares` lists
them, `backup unshare` takes one back. Only a command at this machine
changes this. A share belongs to a host, not a folder, and goes when that
host is unpaired or revoked.
- **One backup folder per host.** A backup op is refused while a host's
backup folder is the same as, inside, or contains another paired host's;
is or contains this bastion's configuration folder, backup keys or backup
records; or sits inside the configuration folder anywhere but under its
`backups` folder, where the default `backups/<host>` is.
Upgrading: if two paired Hosts share a backup_root (or nested folders),
backup copies to this machine stop until each Host has its own folder.
- **Ops cannot touch a host's backup folder.** Every pairing's backup folder,
wherever you put it (a NAS mount too), joins this machine's own record: no
op or action-op verb may name it, anything in it or a folder holding it,
and the sandbox hides it. A backup folder on a network mount that stops
answering does not stop op runs: when checking it fails or takes more than
two seconds, a run goes on without its mask (the run's closed view still
covers it), no op may name it all the same, and the log says so once. The
same two-second bound applies when a backup op checks that each host's
folder is its own: another host's stale folder blocks nothing (it is
compared as written and as it last resolved), and a host whose own folder
does not answer has its backup ops refused until it does. The last place
each backup folder resolved to is kept, so during an outage an op still
cannot name the real place.
- **The backup folder is thinned, only among what this bastion fetched.**
Backup files (`<stamp>.tar.zst.age`, `<stamp>.key.age`) fetched through
`backup_fetch` land at the top of the host's backup folder, and this
bastion records each one (arrival time, size, SHA-256) in its own state
folder. After each arrival the folder keeps the newest 7, and the newest of
each of the last 14 days, 8 weeks and 12 months, by arrival, among the
recorded files still as they arrived. A file you put there, or one from
before this version, is never removed. A `.keep-all` file turns it off.
- **Jobs are each host's own.** Fetch and push job ids are random, a status
call answers only the host that started the job, and no reply or job error
quotes an upload or download address's signature.
- **Restoring a lost host.** `backup restore-plan --dir <folder>` lists the
backups in a folder and reseals the one you choose to the key of the host
that replaces the lost one. What running that host on your own hardware
needs, which this program does not provide, is listed in the README.
- Built with filippo.io/age 1.3.2 for every encryption here; golang.org/x/crypto,
x/sys and x/net move up with it.
## 0.46.0
- **Test data names no machine of ours.** The tests and the action-op example
used our own folder layout, forge, images and service names as sample data.
They now use neutral ones (`/opt/acme`, `forge.example.com`, `acme-web.service`);
every check they make is unchanged. No change to the program.
- **Action ops: ship steps run as ops.** An op with `kind: action` builds,
pushes, moves a tree, restarts a service or rolls a workload, in two parts:
- **Its program runs unprivileged.** Each run gets a uid no run had
before, in its own systemd transient unit, with no capabilities and no
new privileges. It sees
what a files op sees, with the machine read-only and a private work
folder at `/tmp`.
- **Its network, in an apply, is held to the destinations it declares:**
to their addresses by the unit's allowlist, and its TCP to their ports by
Landlock, which also blocks abstract unix sockets and signals out of the
sandbox. UDP is held to the addresses only. A destination on this
machine, a link-local address or a cloud's metadata service is refused.
**A dry run has no network.**
- **No memory ceiling; the op goes first.** A run uses what the machine
has to spare. It is stopped first when the machine runs short, and says
why:
- when free memory falls to a floor above the kubelet's eviction line;
- when the machine's own services start waiting on memory, 7 points more
than the least they had during the run;
- and the kernel kills it before anything else if it must kill.
- **Root work is done by the bastion itself**, as four verbs the manifest
declares with fixed arguments:
- `tree.checkout` moves a git tree forward only, to an exact commit,
with no hooks. It works only in a tree whose `.git` only root can
change, and never writes into a folder another user controls. It never
overwrites an ignored file. It refuses local changes
and any git config that could run a program.
- `unit.reload` and `unit.restart` act on one service.
- `k8s.set_image` rolls one container, only forward:
- to a digest this machine holds a receipt for, from the repository
the manifest names;
- from the commit behind a live image this machine itself rolled.
- `logs.check` returns a verdict and one line, after the apply.
The dry run lists every verb with the state it moves from. The apply is
refused before anything runs if that list changed.
- **Sealed credentials.** Each machine now has an encryption key, which the
heartbeat carries signed by the machine's own key. An action op's declared
credentials arrive sealed to that key and bound to the run. They exist only
in the program's environment and on the run's own tmpfs, are scrubbed from
its output, and are never handed to a dry run. A credential a verb names
goes to the verb and never to the program.
- **Receipts.** What an action op's program reports it pushed becomes a
receipt: a DSSE envelope signed by this machine, naming the op, its
parameters, the image, its digest, its source and its commit. The first
receipt for a digest stands; a conflicting one is refused.
- **The startup sweep.** A run cut short by the bastion's own restart leaves
a mounted tmpfs, a work folder or a unit behind. The next start removes
all three.
- **Fixed: an op run as a user could not write `/dev/null`.** The sandbox's
device nodes were made with the umask applied (0644). They are 0666 now.
- **The heartbeat says whether this machine can run action ops**
(`action_ops`, with the reason when it cannot), and carries
`encryption_key`.
- **One approval for a whole ship.** Your device signs one plan listing every
step of a ship, and each machine runs only its own lines of it, once. The
device fills in each machine's key from its own pins (`pin-machine`,
printed by the machine when you pin the device there), asks for your code
when the plan runs root work (checked against the factor on the device's own
pairing), and never signs such a plan with "approve all shown".
- **Each machine checks the plan itself:**
- the device's signature, under a key pinned there;
- the expiry, and a version that only goes up;
- its own key in the plan;
- that each line is its own and runs once;
- that root lines come with your code;
- that the held op declares exactly the verbs the line showed.
- **Receipts carry values between machines.** Every line ends in a receipt
signed with the machine's own key. A roll takes its digest only from a
successful earlier step of the same plan, at the plan's commit. The first
roll on a machine can take the live image as its starting point, and the
device says so.
- **Lines run in order.** A line runs only after every line before it
succeeded, shown by their signed receipts, and only as many times as the
plan allows. Its attempts run one at a time: the next only after the last
failed, never after one passed or after an undo. The machine keeps this
record itself; an attempt the bastion died in is settled as failed when it
starts again, with a signed receipt saying so. A pod that lost an answer
reads a line's receipts back with `op_plan_status`.
- **Undo** takes a step back, once, to the state its machine recorded before
the step's first attempt. Each change is recorded before it is made, so a
retry is taken back too. It needs a signed receipt that a later step failed
its check on its last allowed attempt, or that the step's own latest attempt
failed and none passed. A tree or workload something else moved since the line's
changes were read — while its program ran, or between attempts — is left
alone: the verb refuses it, so an undo never takes that change back. This
holds for every action op's apply, in a plan or not. Changes this bastion
makes to one tree or workload, forward or undo, take turns, and an undo
reads its target again right before it writes. An op that moves the same
tree or container twice is refused when it arrives. Every command a verb runs has a
deadline, so one that never answers cannot hold a target for good. The state goes back first and a unit restarts
after, and a file the machine keeps is never overwritten.
- **A roll whose image is already set still waits for its rollout,** so a
retry after a rollout that timed out passes only once the rollout is done.
- **Nothing in a plan can hide what you read:** its fields are plain text,
and the device refuses control characters, line breaks and direction marks.
## 0.45.0
- **An op sees only what it declared.** Until now an op's sandbox showed it
the whole machine read-only, so a root op's dry run, which runs before
anyone is asked, could read a credential file and hand it back to the host.
Now an op sees the system's programs and libraries, a few `/etc` files that
hold no secret (`passwd`, `hosts`, `resolv.conf`, certificates and the
like), and the paths its manifest declares: its effects and a new `reads`
list. Every other file and folder is covered with an empty read-only mount,
and a cover that cannot be put in place refuses the run. Links are left in
place (where one points can be read, not what is there), and the op's own
mount table names the folders it was denied. A read on a mount the sandbox
does not carry, such as a network share, is refused, as an effect there
already was.
- **An op that reaches a credential store is refused.** The stores are:
- `/root`;
- `.ssh`, `.docker`, `.git-credentials`, `.kube`, `.gnupg`, `.aws`,
`.netrc`, `.pgpass` and `.config` in any home;
- `/etc/shadow` and `/etc/gshadow`;
- `/etc/ssh`, `/etc/wireguard`, `/etc/privatae`, `/etc/letsencrypt`,
`/etc/ssl/private`, `/etc/pki`, `/etc/systemd`, `/etc/credstore` and
`/etc/docker`;
- docker's, k3s's and Kubernetes' state;
- PostgreSQL's and MySQL's data directories.
You can add more, one absolute path per line, in `sensitive_paths` in this
machine's own record. A path is judged as written and as it resolves, again
at every run. The run judges exactly the list it hands the sandbox, so a
link moved between the check and the run cannot slip a store past it. A
bind mount or hard link you make yourself that puts a credential file
inside a declared path is not caught: list that path too. Such an op waits
for a later release that shows its dry run only on your own device.
- **The heartbeat says so** (`op_view_closed`), so your host knows which
machines show an op only what it declared.
## 0.44.0
- **An update to a newer signed release no longer needs an approval on each
machine.** On an anchored machine, a self-update your host asks for goes
ahead without your approval when the version is newer than the one running.
Our signature on the release, made on the key holder's own computer, is the
approval, and the update still happens only when a signed manifest names
exactly that version. Updating a fleet used to take one approval per
machine. A move to an older version, or to one this build cannot compare
with its own (an unstamped build, a suffixed version), still needs your
approval bound to that version, because a signature shows that a release
was published, not that it is the latest. An update from a member host is
still refused, and switching `directives` off in the local console still
refuses every update.
- The enforcing report carries `update_forward_needs_no_approval`, so the
host learns the rule from the machine instead of inferring it from the
version number, and asks nobody for a forward update. It is false on a
build that cannot read its own version, which gates every update.
- A machine on 0.43.1 or earlier applies its own rule to this release, so it
still needs one approval to move to 0.44.0. Updates to newer versions after
that need none.
## 0.43.1
- No change in behaviour. The tests use documentation addresses and name the
forge through the constant the git lane uses, and one sentence in the 0.43.0
entry below is reworded.
## 0.43.0
- **A pairing that offers more than one tunnel address is refused.** The
host's tunnel address in the pairing response becomes the WireGuard peer's
only allowed address. It used to be taken as given, with `/128` added only
when it carried no prefix, so a response naming a prefix wider than one
address, or `::/0`, widened what the tunnel carried. The platform
sends one bare address. Anything else is now refused when pairing, when the
tunnel comes up and when its endpoint is rotated, and nothing is saved or
configured; a single address becomes `/128` (or `/32`).
- **git's own store and cache helpers no longer keep the forge token.** git
hands an approved credential to every helper it has, and a helper
configured after `git-setup`'s lines, or in a repository's own config, kept
the token. With git 2.46 or later, the credential helper now answers with
a pre-encoded credential marked not to be kept, which git sends as the same
header: git's own store and cache helpers keep nothing. A helper that
records everything it is given still receives the token. A git
older than 2.46 gets the username and password as before, and for it only
helpers configured before `git-setup`'s lines are kept out.
- **Each security claim in the README names the tests behind it.** New tests
cover the tunnel's allowed address, the listeners on the machine and in the
tunnel, the absence of an MCP route, the allowlisted op's fixed argv, file
handlers with no shell, observe ops that write nothing, no input injection,
the self-updater's digest check, the terminal's root and approval walls,
the machine-authority gates and the git helper.
- **A release can be checked from a clone of the public repository.**
`deploy/verify-release.sh` used to check out the manifest's `built_from`,
which names a commit in the repository the release was built in and is not a
commit in the public one, so from a public clone it stopped with
`invalid reference`. From this release on, each public snapshot carries the
annotated tag `v<version>` with the line `built from <built_from>` in its
message. The script checks out that tag when `built_from` is not in the
clone, refuses a tag without that line, reports a failed tag fetch as such,
and prints which source it used. The README and SECURITY.md describe it that
way.
## 0.42.1
- No change in behaviour. The README describes what the bastion does as of
this release: the approval device and the write gate on anchored machines,
host-written ops and their sandbox, the service slot, and the commands that
were missing from the reference. In the built-in help, the description of
`claim-anchor --rotate` no longer sits under `--repin`.
## 0.42.0
- **The agent is read from its own name only.** The bastion fetches
`privatae.ai/bastion-agent/version.json` and installs the
`bastion-agent-<version>.pyz` it names. It no longer falls back to the legacy
`privatae.ai/freeagent/version.json` or accepts the legacy
`freeagent-<version>.pyz` entry. When the address cannot be reached, the
installed agent keeps running, as before.
- The release script checks that the agent is signed at the address this
version reads, and no longer at the legacy one.
- The README lists the names that keep the word freeagent on purpose (the
state directory, the heartbeat fields, the agent's environment variables)
and says when the legacy address is retired.
- **Fuzz targets for every wall a host's input goes through.** The path
sandbox for store reads and move plans, an op's declared effects and path
params, service slot names, the capability family each op needs, step-up
requests, and every signed record and approval the bastion verifies each
have a target that states the property the wall exists for
(`client-go/fuzz_*_test.go`). `go test` runs their seeds;
`deploy/fuzz.sh` runs each target for a bounded time. What they found is
fixed below, and each finding is kept as a seed.
- **A store's excludes hold for the file that is opened.** A read or a move
plan checked the grant's excludes against the text of the path the host
sent. Three ways around that are closed:
- Whitespace: the sandbox trims it before opening, so `" .ssh/id_rsa"` or
`"server.key "` read the excluded file.
- A link inside the store: with `keys` pointing at `.ssh`, `keys/id_rsa`
opened `.ssh/id_rsa`. The excludes are now also judged on where the path
leads.
- Names the file system treats as the same: on Windows and macOS `.SSH` is
`.ssh`, and on Windows `.ssh.` and `server.key::$DATA` name `.ssh` and
`server.key`. Excludes compare names the way the platform does.
- **A store path that goes through a link that leads nowhere (or loops) is
refused.** Nothing can be reached through such a link, and where it would
lead cannot be shown to be inside the store. A broken link as the last part
of a path is unchanged: a stat of it reads as gone, and a move renames the
link.
- **An op's effect glob may not name anything under a system folder.** The
refusal of `/proc`, `/sys`, `/dev`, `/run`, `/tmp`, `/var/tmp` and `/boot`
compared the effect as literal text, so `/b*/*` (which names
`/boot/vmlinuz`) passed. A glob is now judged segment by segment, the way it
already was against the machine's own record. A shallow glob such as `/*/x`
or `/var/*/x` is therefore refused, because it could name `/boot/x` or
`/var/tmp/x`; name the folder instead (`/srv/*/x`).
- **A device approval with a line break inside its purpose or nonce is
refused**, and the device will not sign one. The signed bytes join the
fields with line breaks, so such an approval could be read with part of its
purpose moved into its nonce. The purposes the bastion builds never contain
one, so no approval it asked for is affected.
- The test suite compiles for Windows and macOS again: three tests that use
Linux-only parts of the op sandbox moved to `ops_linux_test.go`, so
`GOOS=windows go vet ./...` and `GOOS=darwin go vet ./...` pass.
## 0.41.0 (not published)
*0.42.0 was merged before this was published, and carries these changes.*
- **Release manifests are signed, and the bastion checks the signature.** The
bastion's own manifest and the agent's are each published with a signature
file beside them (`version.json.<first 16 hex of its sha256>.sig`), an
ed25519 signature over a short statement: what was released, its version,
and the manifest's sha256. The public keys are compiled in
(`release_sign.go`). A self-update, the update banner and the agent download
all refuse a manifest that does not verify. Before this, the digests came
from the same server as the files they checked, so anything that could
change one could change both.
- The release key never reaches the machine that builds and publishes. A
release is built, then waits until the key holder signs it on their own
computer with `deploy/release-sign` (the key is stored encrypted under a
passphrase), and is published only when that signature verifies against the
keys this release pins and the keys of the release the fleet runs now. A
release must pin two keys, a primary and an offline spare, so losing one
strands nothing.
- The agent is never replaced with an older version than the one installed. A
signature proves a manifest was published once, not that it is current, so a
replayed older manifest is kept from moving the agent backwards.
- The manifest and the signature are each fetched with a 20-second limit and
read at most 1 MiB. The agent's manifest was fetched with the two-minute
limit meant for binaries.
- **Release builds are reproducible.** `deploy/build.sh` pins the Go image by
digest and builds with `-trimpath -buildvcs=false -mod=readonly` and
`GOTOOLCHAIN=local`; two builds of one commit from different paths and
module caches came out byte-identical. `deploy/verify-release.sh` rebuilds a
published release and compares every digest.
- SECURITY.md now says what you still have to trust: the release key, the first
install, bastion updates, agent updates (which happen without an approval),
the agent itself, and the platform.
- Upgrading from 0.40.0 or earlier needs nothing: those versions do not check
signatures and update to this one as before. From this version on, an
unsigned manifest is refused.
## 0.40.0 (not published)
*Later releases were merged before this was published; 0.42.0 carries these changes.*
- **Approvals from the taskbar.** On the machine that is your approval device,
an ask from one of your hosts now raises a Windows notification. Clicking it,
or the new first line of the tray menu ("N approvals waiting — open…"), opens
an approvals window with no menu in between: a small app window where Edge is
installed, otherwise a browser tab. It lists every ask in the device's own
words with Approve and Deny, and "Approve all shown" when there are several.
Before this the only way to read or answer an ask was `device-approvals` and
`device-approve` at a console.
- Approve signs exactly as `device-approve` does: this device's key, the
purpose the device derived when the ask was filed, one run, an hour at most.
"Approve all shown" keeps the console's rule: it signs only the asks the
window drew and leaves any that arrived since pending, named. Deny is final
for a day, as on the console.
- The window does not redraw itself while you read it. A line at the top checks
every few seconds and, when an ask arrives or is answered elsewhere, says so
with a "Show them" button; the list, and what "approve all" would sign, change
only when you act. An ask answered at the console while "approve all" runs
is skipped and the rest are still signed.
- The same window lists the requests waiting for a code on this machine
itself, and answering one there keeps you in the window. The front page says
when asks are waiting and links to it.
- A sign-in link may now land on the approvals page directly. It can land on no
other page it names; anything else lands on the front page.
## 0.39.2
- No code change: the same code as 0.39.1 under a new version. 0.39.1 was
held back from automatic updates after one machine that had taken it went
silent. That machine had run 0.39.1 cleanly for over four hours, and it lost
all of its network traffic, not only the bastion's, and came back with no
change while still on 0.39.1. A held version is never released again, only
superseded, so updates can move again from this version. On a machine that
needs an approval for a self-update, 0.39.2 must be approved again; an
approval given for 0.39.1 does not carry over.
## 0.39.1 (held)
*Superseded by 0.39.2, the same code under a new version.*
- The op sandbox shows the op's own directory at `/opt/bastion-op`, a new
name for the same mount point. Nothing on the host side names that path.
- Comments and tests no longer name internal documents, people or platform
source paths, so the published tree reads on its own.
## 0.39.0
- **A terminal no longer needs the host's signed management grant.** A shell
is the person's own act in their Terminal app (rung 8), and the authority
for it is what this machine checks: a root shell is approved here with the
code; `session:open` on a machine anchored to a host takes the person's
approval from their device or this console, bound to that one session; on a local machine the pairing's caps are the
authorization, as for every other write on a person's own machine. A grant
that is presented is still verified (a deny in it still refuses), and the
key the session frame offers is still pinned by the same rules. With this
the host's standing `session:open` / `session:root` grant can be revoked
once the fleet runs this version.
## 0.38.0
- **Mirroring into a slot server, done by this machine.** A slot server has no
network, so it cannot pull the repos the person asked it to mirror. A reach
grant may now carry `mirrors` — each an https upstream and the server repo it
lands in — as part of what the person approves (its purpose binds the list;
a grant without mirrors keeps the 0.36.0 purpose byte for byte). The new
`service` verb `mirror` runs an ordinary `git fetch` of each approved source
over this machine's OWN connection — the code never touches the platform —
and pushes it into the server through its pinned socket.
- The fetch is held to the public internet on every machine: each source's host
must resolve only to public addresses, and git is pinned to the vetted one
(http.curloptResolve) so a second lookup cannot answer with a local one.
https only, no redirects, no credential helpers, no user or system git config.
A git older than 2.37 is refused, because it ignores the pin without an error.
Addresses counted as internal now also include 0.0.0.0/8, 198.18.0.0/15 and
240.0.0.0/4. Addresses in the well-known NAT64 prefix and in 6to4 are judged
by the IPv4 address inside them, so an IPv6-only network with DNS64 still
reaches public sites; the rest of 64:ff9b::/32 (local-use translators) is
refused.
- A mirror source is checked against one allowlist pattern, the same on the
host: a named host whose last label starts with a letter, digits for a port,
and a path of plain characters and two-digit escapes. An IP address or a
numeric host is refused, because curl reads "127.1" or "0x7f000001" as an
address before it looks at the pin. Entries are visible ASCII only, and no two sources may
land in the same repo, since a sync prunes and would erase the other
source's branches. Both sides run the same test vectors.
- The push goes through a loopback bridge opened for that one push. It answers
only a random path given to the git child, and it dials the server's pinned,
uid-checked socket. The path is visible in the git child's command line while
the push runs, which reaches only the server's anonymous pages. The host's credential for its own server rides in the environment,
never on the command line.
- A sync is standing work under the approved list: no per-run approval, but
only for the host the grant names, and not while its writes are quarantined.
- The mirror cache is the bastion's, outside the directory the server owns.
- Tests stub git and DNS; an opt-in end-to-end test (BASTION_MIRROR_E2E=1)
mirrored two public GitHub repos into a no-network Gitea for real.
## 0.37.0
- **Ops: a program the host wrote runs here only under signed terms, in a
sandbox first.** An op is a directory (`op.yaml` + `run`) the host built and
tested; its identity is the sha256 of the directory's canonical form. It
reaches this machine only with terms signed by the host's key and verified
under the pinned one (`op_sync`): the bundle must hash to the sha256 the
terms name, and the manifest must parse — a fixed interpreter (`/bin/sh`,
`python3`), typed params, absolute file effects that are never `/`, a time
limit under ten minutes, `dry_run` / `apply` / `verify` modes, and `undo`
stated. Two terms: `op_run` (once — these params, ≤ 24 h, one use) and
`op_grant` (from now on — param bounds, ≤ 30 days, a use count, `user` only;
root is never reusable), held in `ops.json`; both carry the person's approval
from the device or the console, and a device signs them because their purpose
names the machine.
- **The executor (Linux, the root daemon).** `op_dry_run` runs the op in a
throwaway sandbox — the bastion re-exec'd in fresh namespaces; an overlay
over this machine's root filesystem and one over each of its other mounted
disks (ext4, xfs, btrfs, f2fs, zfs, jfs; a network share or a vfat partition
is not carried, and an effect on one refuses the run by name), all on a
256 MiB tmpfs; no network; a fresh `/proc` with `/proc/sys` and the other
machine-wide knobs read-only; `/sys` `/dev` `/run` `/tmp` `/var/tmp` masked;
the op mounted read-only; rlimits; every capability that reaches past the
filesystem (modules, clock, raw I/O, device nodes, mounts, reboot) dropped
and no new privileges; the person's session user for `privilege: user`;
killed at `time_limit` — and reads what it changed off the upper layers:
created / changed (with a line diff) / removed. Anything else — a symlink, a
hardlink, a replaced directory, a special file — or any change outside the
op's declared effects refuses the run, and the refusal counts as a strike
(a box that cannot sandbox at all is its own state, never the host's
strike). This machine's own record — the config dir with its identity and
pinned keys, pairings, ops.json and nonce ledger, and any pairing's DCN
dir, as spelled and as it resolves, plus `/etc/privatae-bastion`,
`/var/lib/privatae/freeagent` and `/var/lib/privatae/bastion` — is masked
inside the sandbox with an empty read-only tmpfs (an op cannot read,
write or remove it), and no effect or change may sit at, under or above
it, whatever the manifest says — a glob is judged segment by segment
against each of those places, and matched the same way against a change
(equal segment counts, so no class or escape ever spans a `/`; a
backslash is refused outright): `/etc/*.conf` is an effect,
`/etc/privatae-*` or `/etc/*/observe.kubeconfig` is not; a record that
cannot be enumerated refuses every op. A removed
folder is named on the card with what it held and lands as that; one that
holds a mount the sandbox did not carry is refused. **Apply is a committed dry run**: the same run again, checked again,
and bound to the change list the dry run showed — the report names that
list by sha256, the apply's terms and the person's approval carry it, and a
run that would change anything else lands nothing — then the upper landed
file by file, then `verify` in a fresh sandbox. Rung 6
allows file effects only; Windows and macOS refuse every op and the beat says
`sandbox: false` with the reason. `op_grant_run` runs under a held grant,
params checked against the schema and the bounds, a use spent per apply.
Every run writes an audit line and a record under `ops/runs/`; a host
that lost an apply's answer reads what became of it with `op_run_status
<nonce>` (a read: found / applied / the error / the change list; the
box burns a run's nonce BEFORE the op runs, so "applied already" on a
re-send means attempted, not landed). Console:
`privatae-bastion ops` lists what is held; `ops drop <grant>` takes a
standing grant back. The `ops` capability family joins the ceiling and the
console page.
## 0.36.0
- **Reach grants for the service slot.** Holding the `services` family no
longer lets a host reach every server on this machine. The forward now also
requires, on every connection, a verified REACH GRANT naming that host and
that server (service_reach.go) — default-deny. A reach grant is signed by the
host with the entity key pinned here, approved by the person (on an anchored
machine the box verifies and spends that approval, so a revoke is final), and
installed with `service_grant_sync`; `service_grant_drop` and the heartbeat
reconcile (`service_grants`, two missed replies) take it away. A host can
install reach only for itself: the grant's subject must be the pairing that
sends it. It lives beside the store grants in the pairing's grants.json.
- **A signed record's kind is checked.** The store-grant verifier parsed `kind`
and never compared it, so any record the entity key signed could be offered
as a store grant. It now requires `dcn_store_grant` (every store grant has
carried it since store grants existed); a move plan is checked as a move plan
through a shared signature-and-expiry check; a reach grant as a reach grant;
and a terminal is opened only by a management grant — a signed record that
declares ANY kind is refused there.
- The approval device files reach-grant asks (their purpose names the machine),
so a machine that approves only through a pinned device can approve them.
- Stopping a server drops every reach grant for it, for every host, so the next
server built under that name inherits no one's reach.
- The enforcing report lists the reach grants held and names the pairing's own
host name (`pairing_host`) — the subject a reach grant must carry — and the
given page says a server needs a reach grant per host.
## 0.35.0
- A server in the service slot has NO network. It runs with `--network none`
and serves on a unix socket in its own data directory; the host's tunnel
forward is spliced to that socket and is the only way in. Measured before
choosing it: the default bridge reached the internet, the machine's LAN
address and its own services; an `--internal` network still reached the
machine's own services through its gateway; `--network none` reached
nothing. "AI on your network, not your machines": a server a host built here
cannot reach this machine, your network or the internet.
- `up` names the server's socket file (`socket`) instead of a port, and the
approval binds it. Only a server that can listen on a unix socket can run in
a slot (Gitea and Forgejo can: PROTOCOL=http+unix).
- The forward serves a slot only while its container reports network mode
`none`, checked on every connection.
- The forward connects only to the server itself. The container owns its data
directory, so the socket's name must be a socket (a symlink or a plain file
there is refused, never followed), and after connecting the kernel's record
of who is listening (SO_PEERCRED / LOCAL_PEERCRED) must be the server's own
uid. `data_uid` is therefore required and non-root. Without this, a server
could swap its socket for a link to a root-owned one — the container
runtime's, say — and the host's tunnel would reach that instead.
- The forward dials the exact socket it checked. The name is opened with
O_PATH|O_NOFOLLOW (a link fails to open), the open file is confirmed to be a
socket, and the connection goes through /proc/self/fd/N, which the kernel
resolves to that inode whatever the name points at by then. A server that
renames a link to a same-uid host socket into place between check and dial
reaches nothing: measured over 400 racing dials, 0 reached the decoy (dialling
by name reached it 165 times). Linux only; elsewhere the slot is refused and
never advertised.
- The given page says the server has no network.
## 0.34.0
- A host can run a server on this machine that stays up: the service slot.
A new harness mode, `service`, has three verbs. `up` pulls the image the host
names and starts it with a restart policy, no new privileges, every
capability dropped, and one mount: the slot's own data directory under the
bastion's config dir. `down` removes the container and keeps the data unless
the request says `purge`. `status` reports each slot.
- Each slot gets a stable tunnel port in 18100-18199. The host dials this
machine's tunnel address on that port and is spliced to the container on its
bridge IP. Nothing is published on the machine's own network.
- It is its own capability family, `services`, separate from `harness`: a
grant to run jobs here is not a grant to leave a server running. The family
is checked on every accepted connection and on every verb. On an anchored
machine a member host cannot replace or stop a server another host built.
- The heartbeat advertises `services` and lists the slots with their ports, only
to a pairing that holds the family on a machine with a container runtime.
- The "what this machine gives away" page describes the new family and counts
families from the list instead of a fixed seven.
## 0.33.0
- **A write on an anchored machine runs only on the person's approval, verified
and SPENT here.** Every management op is a read or a write (`writes.go`):
reads — `observe:*`, `computer:*`, `uptime`, `audit_fetch`, the dcn reads,
`ctx_get`, `model:list`, `pairing_list`, the stepup and device asks — still
answer on the anchor host's word; every write (`ctx_put/del/extract`,
`backup_fetch`, `model:drop`, `session:open`, `harness:run`, the box-wide
directives `self-update`, `contain` — by pid and by finding — and mirror
placement, and anything unclassified) needs a live approval bound to this
op, this machine and a digest of exactly its arguments, single-use, spent the
moment it is accepted and before the act runs. Ops that already carried an
approval (`dcn_grant_sync`, `dcn_plan_apply`, `model:pull`, `session:root`)
keep theirs; a revoke (`dcn_grant_drop`, `pairing_grant_drop`) is never held
back. `egress` and `inference` USE the machine and change nothing on it — they
run under the user's egress rules and the inference cap as before — and on an
anchored machine the store-mutating half of the ollama forward (`/api/pull`,
`/api/delete`, `/api/create`, …) is refused for every pairing, the anchor
included: models change through `model:pull` / `model:drop`, which spend an
approval. Before this the anchor pairing passed the whole ceiling, and on a
fleet anchored to one host that host's word was the only check on every
write. A local (unanchored) machine is unchanged: the pairing is the
authorization on a person's own computer. "Unenrolled passes" is gone for
anchored machines — no factor and no device means every write is refused,
and the refusal names both ways to fix it.
- **The approval device.** `privatae-bastion device-init` makes the person's
own machine the device; `sudo -H privatae-bastion pin-device <key>` on each
governed machine accepts approvals it signs. The pod files an ask on the
device from the plain DETAILS of what the machine is being asked
(`device_request <details_b64> <server>`); the device derives the purpose it
will sign and the words it shows from those same details — a pod cannot show
one sentence and have another signed — and the person reads them
(`device-approvals`) and taps (`device-approve <id>|all` / `device-deny`).
The governed machine verifies the signature under the pinned key, derives the
purpose itself, refuses an expired, over-long, multi-use, replayed or
wrong-purpose approval, and burns the nonce. The device lane answers only the
person's own host. **Root is never approved from a device**: `session:root`
still takes the code at the machine, on top of anything else. The console
with a factor enrolled is the other way to approve a write, through the
existing `stepup_request` flow with a new `mgmt_op` kind.
- **A repeated directive runs on the approval already given.** The host resends
`self-update` and `contain` on every beat until it reads the result, and a
mirror placement for as long as it stands; the machine remembers the act it
approved (`directive_approvals.json`: ten minutes for a swap or a kill, until
disabled for a mirror) so the person taps once per act, not once per beat.
- **The anchor's key is never taken from a first offer on the DCN or session
lane on an anchored machine.** It is pinned from the anchor's own first
`pairing_grant_sync` (as before) and every later offer is checked against it.
A member host's key still pins on its first use until the anchor-signed
pairing grant carries `verify_key` (the field is read as soon as a host
fills it).
- **No open-ended terms.** A DCN store grant or a session grant with no expiry
no longer verifies. The host has always signed one; pairing grants stay
open-ended this release.
- **The person at the machine always wins.** The local switch covers the
box-wide directives under a new family `directives` (a card on the console
page); `approvals drop <id>` takes back an unused approval; `grants drop
<store>` removes a held folder grant; `quarantine clear <host>` lifts a hold.
`--host` is taken anywhere on those commands.
- **The lane watches itself.** Every refusal on the management lane goes on a
ring the enforcing report carries (`refusals`, newest twenty); five refused
writes from one pairing inside ten minutes — refusals the host could have
known better than to send, never a read, an op this build does not know, or
"this machine holds no way to approve" — close that pairing's write lanes
here for ten minutes, doubling on repeat (fail2ban's defaults), forgiven after
a clean day; reads and revokes untouched; reported as `quarantine` /
`quarantined`. The report also says `write_gate`, `device`, `second_factor`,
the `device_keys` a machine accepts and, on a device, `approval_device` and
`approval_device_key`, so the host can find the device and tell a machine
whose writes are all refused from one that is merely quiet. Audit lines carry
`args_sha256` and the approval each gated write spent.
- **The egress lane reaches the public internet and nothing else on an anchored
machine.** Loopback, link-local and private destinations — this machine's own
services, its LAN, the mesh — are refused before any dial, whether named by
address or by a name that resolves to one; that is what lets egress stay a
USE of the machine rather than a write. The harness approval binds the whole
run (id, mode, image, repo NAME, branch, prompt, command, worktree digest,
port, minute cap, lab fields), and the clone URL must name the approved
repository. The device signs only asks whose purpose names the machine
(`mgmt_op`, `dcn_store_grant`); a move plan or a model pull is approved at
the machine's console. The card shows every argument whole (a long one by
its length and digest) and never the pod's free text. `device-approve all`
signs only the asks the last `device-approvals` printed. A remembered
`contain` is keyed on the agent's signature too, so a reused pid is a new
act. A refusal on the harness lane counts toward the quarantine and a
quarantined pairing spends nothing there.
- On an anchored machine the egress lane dials the ADDRESS the check vetted,
never the name again (a name resolved twice is a rebinding hole). The
harness approval names the repository as `forge-host/owner/name`, so a URL
on another server does not pass as the same repository. The console and
device cards blank C1 controls too, and the folder-grant card names the
machine and drops the pod's free text.
- **Standing work under a store the person configured at the machine takes no
tap:** `backup_fetch` (the nightly ship into the backup root set here) and
`ctx_put` / `ctx_del` (the Host's context store, sandboxed to the context
root set here) run as before: deterministic standing work takes no
approval. `ctx_extract` executes an extractor and is approved per run.
A git harness run must name a forge repository (never blank, never a local
path or file: URL) and the clone takes `--` before the URL. The device and
the console hand an approval to the pod ONCE — a re-filed ask after that is
a new act, and a spent or expired approval is never handed back to be
refused; a re-file that finds the tap the pod missed is answered approved.
- `backup_fetch` dials the address it vetted: the dialer's Control hook
refuses a non-public address at the connection itself, so a name that
answers public to the check and private to the dial gets nowhere. A
refused ask wins over a delivered one for the same purpose on the device
and the console, whatever order the store is read in.
- `backup_fetch`, `ctx_put` and `ctx_del` are
standing work under a root set at the machine — no tap. The standing
context lane cannot touch this box's own record: `sessions/` (the shell
transcripts) is refused as a path for both. The `ctx_extract` approval binds
the tool, the mime and the host's name for the document (`doc`, "ctx:<id>"),
never the bytes or the single-use pull ticket, so one tap covers a re-ask.
- The transcript wall on the context lane is judged on the CLEANED path
(`./sessions/x`, `a/../sessions/x` land there too), case-insensitively.
The inline `ctx_extract` approval also binds the sha256 of the document's
bytes, so a label cannot be approved for one document and used for
another. The backup fetch never goes through an environment proxy.
- The heartbeat directives take their approval beside the directive
(`"approvals": {"self-update": …, "contain:<pid>": …, "contain_finding:<id>":
…, "mirror": …}`); this roll is the last host-directed self-update that runs
on the anchor's word alone.
## 0.32.2
- A containment no longer leaves the processes above the agent hung. The
kill-tree freezes the agent before killing it (0.32.0); sudo and su stop
themselves when the child they wait on is stopped, and under a terminal the
stop is relayed a hop further (sudo's monitor to sudo to the terminal
wrapper). None of them was ever continued, so the killed agent stayed a
zombie under a parent stuck in `T` until someone sent it SIGCONT by hand.
The agent's ancestors are now read before the first freeze and, after the
kill, every one that was running then and is stopped now under the same
identity is resumed; they ride in the frozen-set journal too, so a bastion
that dies between the freeze and the thaw continues them on its next start.
One that was already stopped, or whose pid has changed hands, is left alone.
Linux only for now: on macOS a process's stopped state cannot yet be read,
so no ancestor is recorded there and the 0.32.0 behaviour remains.
## 0.32.1
- Comments only: containfinding and the kill-tree test state their rules without internal references.
## 0.32.0
- A host can stop a program the delta reports, not only one the agent plane
recognises. The heartbeat's new `contain_findings` directive names findings
by the host's order id and the finding's FINGERPRINT, never by pid; the box
resolves the fingerprint to what is running under it now, from a fresh
comparison against an accounting whose digest still verifies, and stops it
only if it is a process reported `proc_new` or `proc_changed` whose
fingerprint the accounting does not hold. Apps, anything the bastion
started, init and the bastion itself are refused. Every order is answered on
the beat under `contain_results` (`killed`, `not_a_finding`, `in_baseline`,
`refused_app`, `refused_ours`, `no_authority`, `no_accounting`,
`walk_failed`, `refused_anchor`, `kill_failed`), per pairing, carried out
once per order id. Anchor-only, like every kill on this box.
- The guards hold across the WHOLE tree, not only the named process. An
accounting entry, an app the user is running, anything the bastion started,
and init are spared together with everything beneath them, and the rest of
the tree is stopped (killing a parent does not kill its children, so a
spared subtree survives re-parented). A finding that is the bastion's own
launch context (an ancestor of it) is refused by name. Only what the box
itself reports is containable: a `proc_new` at the new tier (never a
package-managed binary) or a real `proc_changed` (never an unreadable
digest), through the same noise filter the delta pass uses.
- A pid is not a process: each one's start time is read before it is frozen,
after, and before the kill, so a recycled number is never killed and a
stranger stopped by mistake is resumed at once. A process somebody else had
already stopped is never resumed by us. What is frozen is journalled before
the kill and thawed at the next start if the bastion died mid-kill; finding
results are kept on disk, so an order answered before a restart is not run
twice.
- Contain takes the whole tree with the tree FROZEN first. On unix every
process in it is stopped with SIGSTOP, the tree is walked again until it
stops growing, and only then is anything killed, so a child forked during
the kill no longer survives it. A frozen process that cannot be killed is
resumed. Windows, which has no SIGSTOP, kills as it walks and re-walks.
## 0.31.3
- The local agent runs on machines without openssl, Windows included. The
bastion signs the agent's manifest with the key it minted, in Go (the agent
prints `payload`, the bastion signs it, the agent records it with
`sign --value`), and the agent's gate verifies in its own standard library.
The `missing-openssl` state is gone: before this, every Windows agent
stopped there and its context store was never indexed. Needs bastion agent
0.1.11 or later: an older agent (a cached copy while the release manifest
is unreachable) is refused by name rather than failing on an unknown flag.
- Agent 0.1.11 also reads PDFs itself (browser-printed ones included) and
reads Office files on a memory and time budget.
## 0.31.1
- A process walk that did not happen is never an empty machine. Every
platform's enumerator now returns an error when it could not walk
(`/proc` unreadable, `ps` failing or empty, a refused Toolhelp snapshot,
no enumerator at all), and every consumer says so in its own place: the
heartbeat withholds `agents` and carries `walk_failed` in its detect
summary; the accounting refuses to be taken; a delta pass compares
nothing, keeps its gone-and-held clocks, and prints that nothing was
compared; the machine store closes no finding on such a pass on any
platform; the kill guard stops nothing; the sanction and policy pages
banner the failure instead of drawing the tick; the policy census records
it instead of a clean pass. A measured agent list travels with
`agent_scan_ok: true`, so the host can tell an empty list it can vouch
for from one it cannot. Found in review of a containment report (a containment
the host read as carried out because the box sent an empty list for a
failed walk).
## 0.31.0
- Three new read-only ops, `observe:alerts` / `observe:metrics` /
`observe:logs`: the pull half of the observe family, reading an
Alertmanager / Prometheus / Loki on the box's own network over HTTP. The
admin names the endpoints once at install; the host sends only a name out
of a fixed table, never a URL or a query. Alert counts also ride the
heartbeat.
## 0.30.5
- Comments throughout the source rewritten as plain rationale.
- The installers no longer carry an in-place upgrade from the previous
binary name. A machine still on that name reinstalls and pairs again.
- State directory, mirror container names and the unprivileged session
account now use the `privatae-bastion` name only.
- Module path is the repository's own address.
- Async model pulls: the start reply was read without the lock while the
download goroutine wrote to the same record (a data race in the daemon);
the reply is now a snapshot taken under the lock, and every job signals
its completion. Two intermittently failing tests fixed by the same change.
- The console clock seam is swapped atomically; a disk-map drop timer
firing while a test pinned the clock was a data race.
- A machine marker naming a state directory that no longer exists is
treated as absent, so a machine whose state moved can pair again.
- The other-bastion scan and the unprivileged session account look for the
current name only; an install still under the previous name is not
reported and its session account is not used.
- SECURITY.md, CONTRIBUTING.md and this changelog added.
## 0.30.4
- Licence changed to GNU GPL v3 or later.
- The installers verify the licence file users are pointed at.
## 0.30.1 to 0.30.3
- First releases published to the public repository: neutral names, no
internal paths, and a release ratchet so the published source always
corresponds to a built and verified artifact.
## 0.30.0
- Republish carrying four merged changes the published 0.29.0 predated.
## 0.29.0
- Share one model with another host: a model scope in the grant, and a
redeem the anchor can direct.
## 0.28.0
- The context surface, complete: what it holds, what you asked, and who
asked. The context page reachable from the tray.
## 0.25.0 to 0.27.0
- The local console: banners, the interrupt, what was given away, and setup.
- The disk map, sanctioning, and the dry-run policy.
- Windows: the bastion gets a home directory, `status` says whether it is
running, and a second bastion is refused instead of dying on a port bind.
- A terminal session no longer requires a folder grant first.
## 0.24.0
- The model store: `model:list`, `model:pull`, `model:drop` under their own
capability family, with approval on the machine.
- A networked box with no anchor serves nothing, on every lane.
- Windows: a self-update no longer ends as a silent shutdown.
## 0.22.0 and 0.23.0
- `claim-anchor` takes no token when the box already serves the host, and
`--host` names which pairing on a shared machine.
- Windows can administer its own machine: the anchor gate works there.
## 0.21.0
- Machine marker and singleton guard: one bastion per machine.
- Second-bastion detection: the box looks at its own machine and reports
another install to the anchor host.
## 0.20.0
- On an anchored machine, the inference forwards are their own capability
family, and self-update, containment and mirror placement are directed
only by the anchor host.
- The managed local agent runs as the granted folder's owner on root
installs.
- Endpoint rotation never spends the live tunnel probing an unroutable
endpoint.
## 0.19.0
- The bastion no longer serves an MCP ingress. Nothing on the machine drives
the host.
## 0.18.0
- The user install stops deleting a running bastion.
## 0.17.0
- The session lane (an interactive terminal streamed to the host) honours
the anchored machine's capability grants.
## 0.16.0
- Networked mode: a root-owned service on a shared machine, anchored to one
host that approves every further pairing. Pairing grants.
## 0.15.0
- `observe:*` op family (sockets, firewall, units, disk, kernel, k8s, net)
and posture carried on the heartbeat.
## 0.14.0
- Peer announcement between bastions and machine ownership (`claim-owner`).
## 0.13.13 and 0.13.14
- Relay candidate list, handshake-probed endpoint rotation, self failover,
first-failure grace and a rotate backoff cap.
## 0.13.12
- Every observation carries an origin (interactive, remote, scheduler,
service, agent) and a typed evidence list. The `automation` kind and the
scheduler inventory. Every shell-out runs under a timeout.
## 0.13.10 and 0.13.11
- Computer observe: the machine map (`computer:inventory`,
`computer:windows`) and one app's accessibility tree (`computer:tree`).
## 0.13.9
- GUI helper processes collapse into their app; containment refuses apps.
## 0.13.6 to 0.13.8
- Agent observe on Windows and macOS, local-model detection, the
critical-action approval kind, and scanning the second factor from a QR
code instead of retyping it.
## 0.13.5
- Second factor on the machine: `stepup-enroll`, `approvals`, `approve`,
`deny`. Move plans (`dcn_plan_apply`).
## 0.13.2 to 0.13.4
- The managed local agent: installed and run from the signed grant, living
under the store root, with a paged catalogue.
## 0.13.0
- Git lane: local git reaches the forge through the host.
- Local-agent lane: search and sanitised range reads over mTLS.