Files
privatae-bastion/CHANGELOG.md
T
Privatae e4c9b69b46
ci / personal-data (push) Successful in 5s
ci / test (push) Successful in 3m4s
ci / fuzz (push) Successful in 3m45s
bastion 0.53.2
2026-10-20 04:42:14 +00:00

99 KiB

Changelog

All notable changes to the bastion.

The version in deploy/VERSION on main is the next release, not necessarily the one machines run. A merged change waits there until it is published. 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. Between a merge and a publish, main is ahead of the 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.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 urn:skema:hwfp:<fp> (fpSANPrefix), then urn:privatae:hwfp:<fp>, which that Host reads. A platform pairing's request names it once, under urn:skema:hwfp:, 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:"), 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.