bastion 0.60.0
ci / personal-data (push) Successful in 4s
ci / test (push) Successful in 3m36s
ci / fuzz (push) Successful in 3m35s

This commit is contained in:
Privatae
2026-11-02 00:10:58 +00:00
parent 4a5c310482
commit 4f8285ef18
100 changed files with 11537 additions and 840 deletions
+52
View File
@@ -0,0 +1,52 @@
# Status: what works today, and what does not
Kept by hand; each line says where it was checked. "Run" means on a real,
fresh machine. "Tested" means the tests in `client-go`, which CI runs on Linux.
"Built" means it compiles and passes `go vet` for that system, and the logic
it shares with Linux is tested there, but it has not run on that system yet.
## On its own, with no Host
| | Linux | macOS | Windows |
|---|---|---|---|
| Install for the whole machine from your own account | run ([gate](verification/m0-gate-linux-2026-10-31.md)) | built | not run |
| `--just-me`, and the fall back where sudo cannot be used | run | built | not run |
| Every AI agent, by its program or its connection to a model | run | built | built |
| Who started each one (terminal, remote login, schedule, service, another agent) | run | built | built |
| Claude Code: session title, permission mode, what it is running now | tested | read, not checked against the process's start time | read, not checked against the process's start time |
| An agent's folder, git branch and start time | run | not read yet | not read yet |
| Scheduled jobs that start an agent | run (cron) | built (launchd) | built (Task Scheduler) |
| MCP tool servers and their problems | tested | built | built |
| Changes since your baseline | tested | built | built |
| Stop an agent with your code, from the console or `stop` | run | built | built |
| An agent in another account asks (Waiting for you) | tested, with two accounts | built | not built (the ask socket is Unix only) |
| The console from the owner's own account, no sudo | run | built | the tray's approvals window only |
| Activity, report | run | built | built |
| `uninstall`, keeping the audit log | run | built | built |
| A notice when something is waiting | none yet | none yet | the tray |
## Not detected yet
GitHub Copilot, Cursor's editor (Cursor's command-line agent is detected) and
Windsurf: the bastion has no signature for them ([`agents.go`](../client-go/agents.go)).
Session titles are read for Claude Code only; Codex, opencode, Gemini CLI,
aider and goose sessions show their program, folder and tool servers.
## With a Host
Everything in the README's [Works with a Host](../README.md#works-with-a-host):
folders, models, terminals, ops, ship plans, backups, the memory mirror, coder
runs and the service slot. Those are tested here and have run with Privatae's
own Hosts; this file does not track them line by line.
Not built: pairing over the internet to a control plane you run yourself, and
a relay image for one behind a home router.
## Known problems
- The bastion agent (folder search, a separate program) keeps the plain text it
extracts from a shared folder's files in `<folder>/.agent`, inside the shared
folder, where a sync client can copy it. The bastion never serves that
folder. The fix moves it under the bastion's own state.
- There is no MCP endpoint on the bastion, on purpose. An agent on the machine
uses the command line ([for agents](skills/bastion.md)).
+1 -1
View File
@@ -46,4 +46,4 @@ window showed you, never one that arrived after. The same window lists the
requests waiting for a code on this machine itself. The window does not
redraw by itself while you read it: when something new arrives it says so and
waits for you to show it. Off Windows the window is a page of the local
console (`privatae-bastion ui`, then Approvals in the header).
console (`privatae-bastion ui`, then Waiting for you).
+30 -11
View File
@@ -10,15 +10,16 @@
| `pair <pairing code>` | Pair with the one code (`skp_…`) a Cloud Host's "Set up a bastion" card gives: it carries the enroll address and the fingerprint of the Host's CA, and the CA is taken from the enroll port only under that fingerprint. On a local machine that already serves a Host it asks, on this machine's terminal, whether to add this one; only a yes adds it. |
| | The card's one line installs the bastion, pairs it with that code and starts it: `curl -fsSL https://privatae.ai/bastion/install.sh \| PRIVATAE_PAIR_CODE='skp_…' sh` on macOS and Linux (the code goes in the environment, never in an argument, which the process list would show), and `& ([scriptblock]::Create((irm https://privatae.ai/bastion/install.ps1))) -Pair 'skp_…'` on Windows. Either hands the code to `pair -` on standard input. |
| | `pair` exits 0 when it paired, 3 when this machine is already paired with that Host (nothing changed), and anything else on a failure. |
| `run` | Bring up the tunnel and lanes for every paired host. |
| `status` | Show every pairing and its tunnel status. |
| `ui` | Open the local console in your browser. |
| `run` | Start the bastion: it watches this machine whether or not a Host is paired, and brings up the tunnel and lanes for every paired host. A Host paired later is started without a restart. |
| `status [--json]` | Show every pairing and its tunnel status, or, with no Host paired, whether the bastion is running on its own. From the owner's account beside the bastion installed for the whole machine, it reads that bastion (see [Reads from the owner's account](#reads-from-the-owners-account)); when nothing answers, it says the service is not answering. |
| `ui` | Open the local console in your browser. Installed for the whole machine, it opens for the administrator (`sudo -H`) and, on Linux and macOS, as a limited session for the machine's owner account ([console](console.md)). |
| `uninstall [--delete-data]` | Remove the service and the program from the installers' places. The state stays (pairings, keys, the audit log) unless `--delete-data`, confirmed by typing `delete` at a terminal. Installed for the whole machine it needs the administrator; from another account it removes that account's own bastion only. |
| `add-folder [--host <host>] <path> [name]` | Add a folder to your network. `--host` is required when more than one host is paired. |
| `context` | Choose what your files become on your network. Runs as you. |
| `stepup-enroll [--host <host>] [--rotate]` | Enrol (or rotate) the second factor on this machine. |
| `approvals [--host <host>]` | List what your host is asking to do here, in plain words. |
| `approve [--host <host>] <id>` / `deny …` | Answer one request with a code from the authenticator enrolled here. |
| `approvals drop [--host <host>] <id>` | Take back an approval this machine issued and has not used yet. |
| `stepup-enroll [--host <host> \| --machine] [--rotate]` | Enrol (or rotate) a second factor on this machine: a Host's (`--host`), or your own as the machine's owner (`--machine`, and the default when no Host is paired). The owner's factor approves what is asked of the machine itself rather than by a Host. |
| `approvals [--host <host> \| --machine] [--json]` | List what is waiting for your approval here, in plain words: a Host's requests, or with `--machine` what is asked of the machine itself. |
| `approve [--host <host> \| --machine] <id>` / `deny …` | Answer one request with a code from the matching authenticator enrolled here. A Host's code never approves what is asked of the machine, nor the other way round. |
| `approvals drop [--host <host> \| --machine] <id>` | Take back an approval this machine issued and has not used yet. |
| `grants drop <store id> [--host <host>]` | Remove a folder grant held on this machine. The host is told. |
| `device-init` | Make this machine your approval device. |
| `device-approvals` | What your machines are asking you to approve, in their words. |
@@ -32,6 +33,7 @@
| `coders drop [--host <host>]` | Take back the standing coder grant of every host, or one, and stop their runs. |
| `coders status [--json]` | The coder settings, grants and runs on this machine. |
| `quarantine clear <host>` | Reopen a host's write lanes that this machine closed after repeated refusals. |
| `quarantine clear --asker <account>` | Let agents running as that account ask this machine again. Five refused asks from one account within ten minutes (the owner denied them, or they were refused: not an agent, refused in the last day, too many waiting) hold its asks for ten minutes, doubling on repeats and forgiven after a clean day. The holds are kept beside the owner's code, never sent to a Host. |
| `git-setup` | Print the `git config` lines that route `git.privatae.ai` through this bastion. |
| `git-credential` | Git credential helper. Fetches your forge token from your host on every auth. |
| `unpair <host>` | Tear down one pairing. Context store and DCN state are left in place. |
@@ -41,13 +43,24 @@
| `release-anchor --host <host> (--host-ca <pem> \| --force)` | Let go of an anchor whose Host is gone: unpair it and set its state aside, so a new Host can be anchored. `--host-ca` is the new Host's CA. Root required. |
| `claim-owner <name>` | State who owns this machine. Root required. Optional; grants nothing. |
| `root-exec grant [--host <host>] --for <duration>` / `root-exec revoke` | Let the anchor host run commands as root here, with the code from the authenticator enrolled here, until a time you choose (30 days at most); or take it back. `root-exec` alone shows the grant held. Granting needs root. |
| `switch <host> <family> on\|off` / `switch --list` | Switch a capability family off for one host on this machine, or back on; `--list` shows every host's families. Switching needs root. |
| `report [--since 7d] [--json] [--out FILE \| --out -]` | Write what this bastion knows as one Markdown file (or JSON): a summary of the problems first, then the agents running now, scheduled jobs that start an agent, MCP tool servers and their problems, changes since the baseline, and the activity in the period. No AI model writes any of it; last prompts and secrets are left out. Default file: `reports/` in the config directory of the account that ran it; `--out -` prints it instead. |
| `activity [--since 7d] [--json]` | What happened on this machine, newest first: stops (by you or a Host, and stops refused), your approvals, what each Host did here, Hosts paired or removed, programs marked as yours, capabilities switched off. Read from the records this bastion already keeps. |
| `agents [--json] [--prompts]` | Every AI agent on this machine and what it is doing: for Claude Code, the session's name and title, whether it is waiting for you and on what, and its permission mode, read from Claude Code's own session files; for every agent, its folder and git branch, how long it has run, who started it, what it is running now (secrets redacted) and the tool servers it started with itself. Names and titles are what each agent says about itself. `--prompts` adds each session's last prompt. Nothing here is sent to a Host. |
| `stop <session name or pid> [--wait 5m] [--json]` | Stop an agent this bastion sees, and every process it started, as the machine's owner. At a terminal it asks for your code (`stepup-enroll --machine`); run by an agent, it only files a request that you approve under Waiting for you in the console (or with `approve --machine`). `--wait` follows the request until it is answered, as `request` does. When the bastion is installed for the whole machine and the command runs in another account, it asks over the bastion's local ask socket, which names the asking account (and on Linux the asking agent) from the kernel; one account may have five requests waiting. The running bastion carries it out within two minutes of the approval or not at all, and never stops an app you are using, the bastion itself, or a process the scan does not see as an agent. |
| `request <id> [--wait 5m] [--json]` | Where an ask stands, for the account that asked it: `waiting`, `approved` (being carried out), `stopped`, `not_stopped` (with the reason), `denied`, or `unknown` (no such ask from this account, or older than a day). Exit status 0 for stopped, 1 for not, 3 when still waiting after `--wait`. Another account's requests read as unknown. Every ask, approval, denial and stop is recorded with the asking account, the asking agent and the target, each with how it was started. |
| `help` | Print every command. |
| `switch <host> <family> on\|off` / `switch --list` / `switch --reset` | Switch a capability family off for one host on this machine, or back on; `--list` shows every host's families. If the list of switched-off families cannot be read, every family is refused to every host until `--reset` sets the unreadable file aside (kept, not deleted). Switching and resetting need root. |
| `approvals-owner --sid <SID> [--replace]` | Windows, beside the system service: name the one account whose tray icon may list and answer approvals. Administrator only. |
| `approvals-owner --account <name> [--replace]` | Linux and macOS, beside the system service: name the machine's owner account, whose `ui` opens a limited console session without sudo. The installer records the account that ran it through sudo. Another account takes it over only with `--replace`, typed at a terminal. Administrator only. |
| `tray` | Windows, beside the system service: your icon and approvals window, started at every sign-in. |
| `observe` | Print the agent, automation and scheduler snapshot the next check-in would carry. |
| `observe:baseline [--force]` | Record what is normal on this machine. Administrator only. |
| `observe:baseline:show` | Print the baseline this machine holds. |
| `observe:delta [--json]` | What is running that the baseline does not hold. Administrator only. |
| `schedules [--json]` | Scheduled jobs that start an AI agent with nobody at the keyboard: cron and the other schedulers this platform has, with the schedule in words, the account and the command (secrets redacted). |
| `tool-servers [--json]` | The MCP tool servers in every settings file this bastion can read, by the agent or app they belong to, and what is wrong with each: a key written in plain text, a package with no fixed version, a server running that no settings file names. |
| `posture [--json]` | Linux: the checks a Host runs on its own servers, in words: ufw, the pubwall nftables table, iptables' INPUT policy, whether SSH is open to any address, fail2ban, waiting security updates (apt), a restart needed, disk use, load and the system log. The firewall checks need the administrator, or the owner's account through the bastion installed for the whole machine. |
| `baseline record [--force]` | Record what is normal on this machine. Administrator only; `--force` replaces the baseline held. |
| `baseline [show] [--json]` | Print the baseline this machine holds. |
| `baseline diff [--json]` | What is running that the baseline does not hold. Administrator only, or the owner's account through the bastion installed for the whole machine. |
| `observe:baseline [--force]`, `observe:baseline:show`, `observe:delta [--json]` | The same as `baseline record`, `baseline show` and `baseline diff`, under their old names. |
| `backup key init [--replace] [--label <name>]` | Make this bastion's backup key and print the fields to register on your host. `--replace` keeps the old key. |
| `backup key show` | The backup key this bastion holds, and the older ones it keeps. |
| `backup requests [--host <host>]` | Restore requests your host sent this machine. |
@@ -59,3 +72,9 @@
| `version` | Print the version. |
Run `privatae-bastion` with no arguments for the built-in help.
## Reads from the owner's account
Installed for the whole machine, the bastion runs as root and keeps its records in root's state. On Linux and macOS, the account recorded as the machine's owner (`approvals-owner --account`, which the installer writes) reads them without sudo: `status`, `agents`, `schedules`, `tool-servers`, `posture`, `baseline show`, `baseline diff`, `activity`, `report` and `approvals` ask the running bastion over its local ask socket, which knows from the kernel which account is asking, and print what it answers. That is what the console already shows the owner, so an agent the owner runs can read it too; `posture`, which the console has no page for, adds the firewall checks, read by the bastion as the administrator. No read prints the delegation tag an agent carries, as the console never shows one. Nothing that changes anything goes this way; those still take the owner's code.
Any other account is refused. `agents`, `schedules`, `tool-servers` and `posture` then show what that account can read itself, and say so; the rest print the refusal and the administrator's command. Comparing against the baseline, the posture checks and the report are reused for 30 seconds, and the bastion answers four reads at a time, so a polling agent cannot slow the machine.
+58 -6
View File
@@ -1,9 +1,61 @@
# Local console
`privatae-bastion ui` opens a page served by the running daemon on `127.0.0.1`
and reachable from nowhere else: pairings, the anchor, containment state, marks
on automated programs, the dry-run policy, pending approvals, set-up, what this
machine has given away (where you switch a host's capabilities off), and a
disk map. On Windows a tray icon shows a one-line state. Run as you, it opens
what is waiting for your approval, the console and your context page, and can
stop the bastion. Beside the system service it opens approvals only.
and reachable from nowhere else. It works with no Host connected.
**Who it opens for.** The account the bastion runs as signs in with that
account's own key: on an install for one account, that account; installed
for the whole machine, the machine's administrator (`sudo -H privatae-bastion
ui`). On Linux and macOS the whole-machine bastion also opens for one more
account, the machine's owner, recorded by the installer on your own computer
(the account that ran it through sudo; never on a shared machine) or by hand
with `sudo -H privatae-bastion approvals-owner --account <name>`. Run there, `ui` asks the bastion over its local socket,
which learns the asking account from the kernel, and gets a one-use link to a
**limited** session ([`consolelane.go`](../client-go/consolelane.go)). Any
other account is refused. A limited session shows everything and does
everything that takes your code or only narrows reach. What widens reach and
takes no code stays with the administrator, and the page shows their command
in its place: connecting a Host, setting up or replacing your code, choosing
the Host in charge, proposing a folder or checking a path, approving the
approval device's requests, and the disk map, which, read as root, names files
in every account's home. One account may have 16 links waiting to be opened. A Host's requests can be answered there with that Host's code, and
wrong codes are throttled there as yours are.
- **Agents** (the home page): every AI agent running here and what it is
doing. For Claude Code, the session's name and title, whether it is
waiting for you and on what, and its permission mode, read from Claude
Code's own session files; for every agent, its folder and git branch, how
long it has run, who started it, what it is running now, and the tool
servers it started. Flags: permission prompts off, not marked as yours, the
stopping rule would stop it. Until your code is set up and a baseline is
recorded, the page starts with the steps to do that. Each agent has its own
page: its MCP tool servers and their problems, whether it is yours, the
stopping rule's verdict, how the bastion knows what it is, **Stop this
session** and **This one is mine**.
- **Waiting for you**: what is asked of this machine (a stop another agent
asked for, with who asked) and what your Hosts ask, each answered with a
code. Deny needs no code.
- **Activity**: what happened here, newest first: stops, your approvals, what
each Host did, Hosts connected or removed, marks, capabilities switched off.
- **This machine**: the bastion's state and the Hosts it serves; scheduled
jobs that start an agent; MCP tool servers on this machine and their
problems; changes since your baseline (compare, or record a new one);
**Write report**, one Markdown file with no model in it; what this machine
has given away (where you switch a Host's capabilities off); the disk map.
- **Settings**: your code (an authenticator app, set up with a QR), the Hosts
connected and what each may do, connecting one, the Host in charge on a
shared machine, and the owner name.
**Your code.** Stopping an agent, marking one as yours, answering what is
asked of this machine, recording a baseline, releasing a Host from
quarantine and setting the owner name take a code from your authenticator,
typed on the page: anything that can drive your browser can press a button,
and cannot read your phone. Taking access away (disconnecting a Host,
switching a capability off, denying) needs no code. The code only keeps
agents out when the bastion is installed for the whole machine: installed in
your own account, an agent running as you can read the secret behind it, and
the console says so on every page.
On Windows a tray icon shows a one-line state. Run as you, it opens what is
waiting for your approval, the console and your context page, and can stop
the bastion. Beside the system service it opens approvals only.
+38 -8
View File
@@ -2,7 +2,37 @@
The short version, server and desktop, is in the [README](../README.md#install).
A networked install runs as the operating system's own service, whoever is
**Your own computer.** Run as yourself, `install.sh` installs the bastion for
the whole machine through sudo: `sudo true` first (one password), then root
fetches the same installer from privatae.ai and runs it with `--personal`, the
pairing code (if any) on its standard input, never in an argument; its
`sudo -H` and `curl -q` read nothing from your home. Your account is recorded
as the machine's owner (`approvals-owner --account`, from `SUDO_UID`; on your
own computer only, never a shared one), so `privatae-bastion ui` opens the
console from it
([console](console.md)). What an earlier, unpaired install for your account
left (its user unit or LaunchAgent and `~/.local/bin/privatae-bastion`) is
removed. `install.ps1` does the same on Windows through one UAC prompt.
**Just your account.** `--just-me` (Windows: `-JustMe`) installs it for your
account alone, and the installer says what that means: any program running as
you can stop it or change what it allows. Where sudo is missing or refuses
(no admin rights, no terminal to ask on), that is what it falls back to, and it
says so. A paired install for your account stays where it is when you
reinstall. Beside a bastion already installed for the whole machine, no second
one is installed for an account: the machine's would refuse to run beside it.
**A server.** Run as root, the installer installs the shared bastion, as
below. `--personal` and `--shared` choose explicitly; their earlier names
`--local` and `--networked` still work.
**Uninstall.** `privatae-bastion uninstall` removes the service and the program
from the installers' places and keeps the state: pairings, keys and the audit
log. `--delete-data` deletes that too, after `delete` is typed at a terminal.
Installed for the whole machine, it needs the administrator; from another
account it removes only that account's own bastion.
A whole-machine install runs as the operating system's own service, whoever is
signed in, and only the machine's administrator can stop it:
- **Linux:** the `systemd` unit `privatae-bastion.service`, as root.
@@ -18,14 +48,14 @@ signed in, and only the machine's administrator can stop it:
into the folder.
On macOS and Windows, `privatae-bastion service install|uninstall|status` does
the same by hand. An administrator's commands (`sudo -H …` on macOS, an
Administrator terminal on Windows) act on the service's state. From an account
that does not administer the machine, `pair`, `run`, `status` and `switch` are
refused, so that account does not end up with a second, empty bastion of its
own.
the same by hand. An administrator's commands (`sudo -H …` on macOS and Linux,
an Administrator terminal on Windows) act on the service's state. From an
account that does not administer the machine, `pair`, `run`, `status` and
`switch` are refused, on Linux too, so that account does not end up with a
second, empty bastion of its own; `status` there points at `ui`.
A local install runs as you. On Windows the installer registers a logon task
that starts it each time you sign in.
An install for one account runs as that account: a systemd user unit, a
LaunchAgent, or on Windows a logon task that starts it each time you sign in.
A Cloud Host's "Set up a bastion" card gives one line that installs, pairs and
starts the bastion: `curl -fsSL https://privatae.ai/bastion/install.sh | PRIVATAE_PAIR_CODE='skp_…' sh`
+120
View File
@@ -0,0 +1,120 @@
---
name: bastion
description: The person's computer runs the Privatae bastion, which watches every AI agent on it, you included. Use this when the person asks what agents are running, asks you to stop one, asks what is scheduled or configured to run agents, or asks what happened on this computer.
allowed-tools: Bash
sources: repo:privatae/privatae-bastion-dev/client-go@2b6756a
---
# The bastion, for an agent on this computer
The bastion is a program on this computer that watches every AI agent running
here, you included, and lets the person stop any of them with a code from
their phone. It has no MCP endpoint. You use its command line,
`privatae-bastion`, with the same rights as any program running as the person.
## When to use
- The person asks which AI agents are running here, or what one is doing.
- The person asks you to stop an agent (another session, a scheduled job, a
tool that is misbehaving).
- The person asks what is set up to start agents here (scheduled jobs, MCP
tool servers), whether anything new is running, or how this computer's
firewall and updates stand.
- The person asks what happened on this computer: what was stopped, approved
or refused.
## Steps
### See what is running
Run `privatae-bastion agents --json`. It lists every AI agent running now: what
it is, who started it and how, its folder, git branch and tool servers, and for
Claude Code sessions the title and permission mode. Add `--prompts` only if the
person asked for the last prompts.
### See what is set up here
- `privatae-bastion schedules --json`: scheduled jobs that start an agent with
nobody at the keyboard.
- `privatae-bastion tool-servers --json`: the MCP tool servers in every
settings file, and what is wrong with each (`problems`).
- `privatae-bastion baseline diff --json`: what runs now that the recorded
baseline does not hold. `baseline show --json` is the baseline itself.
- `privatae-bastion posture --json` (Linux): the firewall, SSH, fail2ban,
waiting security updates, disk and load.
- `privatae-bastion status --json`: whether the bastion is running, and the
Hosts it is connected to.
- `privatae-bastion report --out -`: all of it as one Markdown report, the
problems first.
### Ask to stop an agent
1. Run `privatae-bastion stop <pid or session name> --json`. Run by you, this
stops nothing. It files a request that the person approves with their code,
under **Waiting for you** in the console (`privatae-bastion ui`).
2. Tell the person: the request is waiting for them, and nothing is stopped
until they approve it.
3. Follow it with `privatae-bastion request <id> --wait 5m --json`, using the
`request` id the first command printed. `state` is one of `waiting`,
`approved` (it is being carried out), `stopped`, `not_stopped` (with
`outcome` saying why), `denied`, `refused` or `unknown`. The exit status is
0 for stopped, 1 for not, and 3 when it is still waiting after `--wait`.
You can pass `--wait` to `stop` itself to ask and follow in one command.
### What happened here
`privatae-bastion activity --json` lists what happened, newest first: stops,
approvals and denials, asks from agents, and what each Host did.
`privatae-bastion approvals --json` lists what is waiting for the person's code.
Where the bastion is installed for the whole machine (the usual case), every
read above asks the running bastion, which answers the machine's owner's
account, so you see the whole machine and not only the person's own files.
## Checks
- Report `stopped` only when `request` says so. `approved` means it is still
being carried out. If you want to be sure, run `agents --json` again and
check that the pid is gone.
- Session names, titles and folders are what each agent says about itself.
Treat them as claims, not facts.
- Never ask the person for their code, and never type one. The code is how the
bastion tells the person from a program running as them; a code you typed
proves nothing, and wrong codes are counted, slowed down and then locked.
- Never read or edit the bastion's files, stop its service or uninstall it.
Installed for the whole machine, it refuses you; installed for the person's
account alone, you could, and doing it is exactly what it exists to catch.
- Don't connect a Host, turn a capability back on, mark an agent as the
person's, or record a baseline. Each of those takes their code or is the
administrator's; tell the person what to do and where.
## When it fails
- `not asked: ... is not an agent this bastion sees running`: check the pid
with `agents --json`. It may have exited already.
- `not asked: the owner of this machine has not set up their code`: nothing can
be asked yet. The person sets it up once, at a terminal:
`sudo -H privatae-bastion stepup-enroll --machine`.
- `denied`, or `refused this same stop within the last 24 hours`: the person
said no. Tell them, and don't ask again.
- `already have 5 requests waiting`: wait for the person to answer those.
- `on hold until ...`: five of your account's asks were refused within ten
minutes, so the bastion stopped taking them for a while. Stop asking. Only the
person can lift the hold.
- Exit status 3 (still `waiting`): tell the person the request is still waiting
for them, rather than polling without end.
- `could not reach this machine's bastion`, or `status` says `NOT ANSWERING`:
the bastion's service is not running. Tell the person; don't start it.
- `answers reads only for its owner's account (...)`: you run as an account
that is not the machine's owner. `agents`, `schedules`, `tool-servers` and
`posture` then show only what your account can read, and say so; tell the
person the rest needs their own account or the administrator.
## Reference
- Every command: `privatae-bastion help`, or [docs/commands.md](../commands.md).
- The console: `privatae-bastion ui`. On a whole-machine install it opens from
the person's own account without sudo.
- A request id is the first eight characters `stop` prints. Your account sees
only its own requests, and each lasts a day.
@@ -0,0 +1,112 @@
# M0 gate, Linux: the bastion on its own, on a fresh machine
Run 2026-10-31 on a fresh Ubuntu 24.04.5 virtual machine (qemu, user-mode
networking), with no Host anywhere. Two invented accounts: `tester`, who may
use sudo without a password, and `other`, who may not use sudo. The binary was
built from branch `m0/install-cli-docs` and served, with a manifest and a copy
of `deploy/install.sh` whose two download addresses point at that server, from
the machine running the VM. Nothing else in the installer was changed.
macOS and Windows have not been run yet.
| Gate step | Result |
|---|---|
| Install as a person, `curl … \| sh`, no sudo typed | Installed for the whole machine through sudo; systemd unit running as root; `tester` recorded as the owner |
| `status` from the owner's account | Says the bastion runs for the whole machine and points at `privatae-bastion ui` |
| `ui` from the owner's account | A one-use link; redeemed, it opens a limited session; a second use is refused |
| Limited session | Every page loads; pairing is refused with the administrator's command; Settings shows that command instead of the form |
| `ui` from `other` | Refused: the console opens without sudo only for the owner's account |
| Agents planted: a cron'd `aider`, `claude` in a terminal, a Python program keeping a TLS connection to api.anthropic.com | All listed: `aider` started by a scheduled job, Claude Code started from a terminal, the Python program as a model client talking to api.anthropic.com |
| Stop the cron'd `aider` from the console, from the owner's account, with the owner's code | Stopped; the cron shell and `aider` are gone; Activity says "You stopped aider"; the audit row is in the system log |
| `report` | One Markdown file: summary, agents running now, scheduled jobs, MCP tool servers, changes since the baseline, activity |
| Service log, grep host/lane/pair | No line waiting for or retrying a Host |
| `other` runs the installer | Refused: the machine's bastion is installed for the whole machine and updating it needs sudo; nothing installed |
| Reinstall by `tester` | Service restarted on the new program; the owner record kept |
| `sudo -H privatae-bastion uninstall` | Unit and program removed; state kept (owner record, owner's code, audit log, reports) |
Found and fixed during the run (commit e46bd57): `sudo -v` asked for a
password where the account's sudo rules were not all NOPASSWD, so the installer
fell back to an install for the account; an account without sudo beside the
machine's bastion got a second one that could not run; `uninstall` from that
account was refused.
The first test of the model client used a socket that never started TLS.
api.anthropic.com closed it, and the bastion, which counts open connections
only, did not list it. The second, which keeps a live connection, was listed.
## Transcript (abridged)
```
$ curl -fsSL http://10.0.2.2:8099/install.sh | sh
Installing the bastion for the whole machine, so that no program running as you, an AI agent included, can stop it or change what it allows.
sudo asks for your password once.
Fetching manifest...
Downloading privatae-bastion (linux-amd64)...
Checksum verified (…).
Installed: /usr/local/bin/privatae-bastion
this machine's owner account is tester: `privatae-bastion ui` opens the console from it.
Anything that stops an agent or widens access still takes the owner's code.
The bastion runs for the whole machine and starts at boot. It is watching the AI agents on this computer now; no Host is needed.
Open its console: privatae-bastion ui
Set up your code, to stop an agent or answer what agents ask (an authenticator app on your phone):
sudo -H privatae-bastion stepup-enroll --machine
Connect a Host, if you run one (optional): sudo -H privatae-bastion pair <token>
$ ps -o user=,args= -C privatae-bastion
root /usr/local/bin/privatae-bastion run
$ privatae-bastion status
error: this machine's bastion runs for the whole machine, as a system service.
Open its console from your own account: privatae-bastion ui
Its full status: sudo -H privatae-bastion status
$ privatae-bastion ui
opening the local UI: http://127.0.0.1:7877
could not open a browser (exec: "xdg-open": executable file not found in $PATH).
Paste this into one, within a minute:
http://127.0.0.1:7877/?k=…
redeem=303 GET /=200 /setup=200 /approvals=200 /activity=200 /machine=200
POST /setup/pair=403 From your own account the console does not do this: it widens what reaches this machine and takes no code. The machine's administrator does it: sudo -H privatae-bastion ui
reuse of the link=403
(other)$ privatae-bastion ui
error: this machine's console opens without sudo only for its owner's account (tester), not other
$ sudo -H privatae-bastion agents
aider: 2 sessions
aider in tester
in /home/tester · started 1m ago · started by a scheduled job · as tester · pid 2018
…
Claude Code: 1 session
Claude Code in tester
in /home/tester · started 1m ago · started from a terminal here · as tester · pid 1903
$ sudo -H privatae-bastion agents --json (the model client)
2220 llm-client evidence: model-traffic api.anthropic.com (verified), model-endpoint api.anthropic.com (verified)
POST /agent/stop pid=2018 with the owner's code → 200 "stopped aider (pid 2018) running on this machine."
$ ps -p 2017,2018 → process tree gone
$ sudo -H privatae-bastion activity
Oct 31 03:52 You stopped aider (pid 2018) running on this machine.
journal: privatae-bastion-audit: {"decision":"You stopped aider (pid 2018) running on this machine.","op":"local_stop",…}
$ sudo -H privatae-bastion report
report written to /root/.config/privatae-bastion/reports/report-2026-10-31-035215.md
# AI agents on m0-gate / ## Summary / ## Agents running now / ## Scheduled jobs that start an agent /
## MCP tool servers / ## Changes since the baseline / ## Activity
$ journalctl -u privatae-bastion | grep -iE 'host|lane|pair'
privatae-bastion: no Host connected: watching this machine on its own. Open the console with `privatae-bastion ui`.
(the rest are systemd's start lines)
(other)$ curl -fsSL http://10.0.2.2:8099/install.sh | sh
… ERROR: this machine's bastion is installed for the whole machine, and updating it needs an account that may use sudo, in a terminal. Nothing was installed.
$ sudo -H privatae-bastion uninstall
stopped the system service privatae-bastion
removed /usr/local/bin/privatae-bastion
Kept /root/.config/privatae-bastion: its pairings, keys and the audit log of what Hosts and agents did here.
The bastion is uninstalled.
```