Promote an existing rootless infraternal agent to root: no server path yet (D139, D18, D274) #399

Open
opened 2026-11-06 13:12:33 +00:00 by kayg · 29 comments
Owner

Found by J307 (#398) while checking D139: "Installing a root infraternal agent, and promoting an existing user agent to root later, always asks first."

What exists on dev 7b14fbb66:

  • The consent UI is there: the "Access level" pane in the machine popover (apps/tracer/src/canvas/windows/access-level.on-demand.ts, #215, D286): consent, then password, then progress.
  • It talks only to PrivilegeService (apps/tracer/src/canvas/sessions/privilege.ts), whose module doc says: "There is no server endpoint behind this yet". The only implementation is the mock double (canvas/mock/privilege.ts). Production supplies no Adapters.privilege, so the row is not drawn.
  • On the server, a rootless install of this server's own on a host is "already added" or a stuck-install flow. A root install with consent does not move an existing user agent's contexts or remove it (D18's "moves the contexts … and removes the user agent").

So the D139 promotion path doesn't exist yet, and it does ask first, by construction, because nothing can do it. Until #398 the add-host consent page promised it ("You can also install as a user now and switch to root later."). #398 removes that sentence.

Needed (not started, owner input first): D274 says "Before any of it is built, the privileged-password handling goes through a design round with the owner." D273/D274 already fix the privilege model (consent upgrades the rootless infraternal agent to the root one, with no separate helper), the password sealed to the host's infraternal agent key, and the detected way in (sudo, doas, root password). That leaves for the design round: the server endpoint behind PrivilegeService, the D18 context move and removal of the user agent, and rollback when promotion fails halfway.

Related: #215, D18, D139, D273, D274, D286.

Found by J307 (#398) while checking D139: "Installing a root infraternal agent, and promoting an existing user agent to root later, always asks first." **What exists on dev 7b14fbb66:** - The consent UI is there: the "Access level" pane in the machine popover (`apps/tracer/src/canvas/windows/access-level.on-demand.ts`, #215, D286): consent, then password, then progress. - It talks only to `PrivilegeService` (`apps/tracer/src/canvas/sessions/privilege.ts`), whose module doc says: "There is no server endpoint behind this yet". The only implementation is the mock double (`canvas/mock/privilege.ts`). Production supplies no `Adapters.privilege`, so the row is not drawn. - On the server, a rootless install of this server's own on a host is "already added" or a stuck-install flow. A root install with consent does not move an existing user agent's contexts or remove it (D18's "moves the contexts … and removes the user agent"). **So the D139 promotion path doesn't exist yet,** and it does ask first, by construction, because nothing can do it. Until #398 the add-host consent page promised it ("You can also install as a user now and switch to root later."). #398 removes that sentence. **Needed (not started, owner input first):** D274 says "Before any of it is built, the privileged-password handling goes through a design round with the owner." D273/D274 already fix the privilege model (consent upgrades the rootless infraternal agent to the root one, with no separate helper), the password sealed to the host's infraternal agent key, and the detected way in (sudo, doas, root password). That leaves for the design round: the server endpoint behind `PrivilegeService`, the D18 context move and removal of the user agent, and rollback when promotion fails halfway. Related: #215, D18, D139, D273, D274, D286.
Author
Owner

J410 start (Claude Opus 5.5): D376 root upgrade.

  • Branch root-upgrade-d376, based on origin/dev 0414b7b2f.
  • Reading D18/D139/D229/D270/D273/D274/D283/D286/D359/D376 and the code paths now. The written plan comes next on this issue, before any product code; then slice 1 (endpoint, sealing, promotion with rollback), red first.
**J410 start** (Claude Opus 5.5): D376 root upgrade. - Branch `root-upgrade-d376`, based on origin/dev `0414b7b2f`. - Reading D18/D139/D229/D270/D273/D274/D283/D286/D359/D376 and the code paths now. The written plan comes next on this issue, before any product code; then slice 1 (endpoint, sealing, promotion with rollback), red first.
Author
Owner

J410 plan: upgrading a host to the root infraternal agent (D376, D274, D139, D18)

Branch root-upgrade-d376, base origin/dev 0414b7b2f. Plan first, no product code yet. The slice estimates are at the end.

What exists today (surveyed on 0414b7b2f)

  • UI: the approved D286 consent pane (apps/tracer/src/canvas/windows/access-level.on-demand.ts). It runs consent, then the password, then progress, and it talks only to PrivilegeService (canvas/sessions/privilege.ts). The only implementation is the mock. The pane allows unlimited wrong tries, and production supplies no Adapters.privilege. The palette entry "Allow full access on " already exists.
  • Server: the SSH install transaction, with its host-side journal in $base/.bootstrap-lock/ (install.rs ACTIVATE/START/ROLLBACK_TRANSACTION/FRESH_CLEANUP) and previous_attempt.sh's recovery library (including D359's set-aside and put-back). It runs every step through the SshSession trait (run(script, input)). remote_hosts.mode is enforced in the schema as rootful ⇔ execution_uid = 0, and the code treats it as immutable.
  • Agent: its identity is an Ed25519 key, bound to one mode at join (remote.rs:204). Server→agent RPC is Frame::Call/Result, and remote.host.uninstall is the closest precedent. Nothing in the repo feeds a password to sudo, doas or su. The shim already accepts a uid-0 peer (shim_peer_uid_allowed). A root agent's discovery refuses sockets it doesn't own.
  • Crypto: the agent links ring 0.17 and openssl (remote transport). ring's X25519 offers only ephemeral private keys, which suits a one-use seal key. The browser has WebCrypto (HKDF and AES-GCM are already used in auth.ts), and X25519 ECDH is available in current Safari, Chromium and Firefox. No new library is needed.
  • Gaps: there is no host activity log and no server-side Inbox (the Inbox is client-only, inbox-host.ts). The account switcher has no UI: preferred_host_accounts and the ssh-accounts.get/set WS actions exist with no caller, and D283 "Logs in as" is mock-only. The code has no "host admin" role; authority is the server owner (users.owner=1) plus remote_hosts.owner_id, so "host admin" means that until roles arrive.

1. Endpoint and protocol behind PrivilegeService

HTTP, in the same style as host removal (remote_removal_http.rs): exact Origin, unlocked session, bootstrap_owner, the host's owner_id, the D333 admin window rechecked inside the writer transaction, a 32 KiB deny_unknown_fields body, a semaphore and a deadline.

  • POST /remote/hosts/privilege/probe {host} returns {full, method: sudo-nopasswd|sudo|doas|root, denied, reachable, retry_after?}.
  • POST /remote/hosts/privilege/begin {host} returns {attempt, method, seal_key (32 B X25519, b64), expires}. The server asks the infraternal agent for a fresh one-use seal key.
  • POST /remote/hosts/privilege/finish {attempt, sealed?} returns {outcome: ok|wrong|timeout|offline|locked|failed, method, retry_after?}. sealed is absent for passwordless sudo, which needs consent only (D139).
  • GET /remote/hosts/privilege/status?attempt= returns progress steps for the spinner (checking → elevated → staging → installing → starting → handover → done | rolled-back).

New infraternal agent actions (privilege.*) run on the plugin Call path, not the 5 s Control allowlist:

  • privilege.probe runs sudo -n true, then sudo -n -l ("a password is required" means sudo with a password, otherwise denied), then doas -n true, then falls back to su. All binaries are given by absolute path.
  • privilege.seal_key {attempt} returns the public half of a fresh ring EphemeralPrivateKey. It is kept in memory for at most 120 s, keyed by attempt, with at most one pending per host.
  • privilege.elevate {attempt, method, sealed?, payload_sha256}: see §3 and §5.
  • privilege.run {attempt, script, stdin} → {status, stdout} carries the install steps to the root relay (§5).

The server implements SshSession for this relay (RelaySession). It then runs the unchanged root-mode Installation (stage, join, ACTIVATE, START, commit, rollback) over it. The promotion reuses the Add host transaction, its journal, previous_attempt.sh recovery and D359 put-back, instead of a second installer.

2. Sealing format (v1)

The browser makes an ephemeral X25519 key pair e and computes dh = X25519(e, seal_key) (WebCrypto deriveBits).

  • key = HKDF-SHA256(ikm=dh, salt=e_pub‖seal_key, info="infraternal privilege seal v1"‖host_id(16)‖attempt(16)‖method), 32 bytes, AES-256-GCM.
  • aad = "ifpriv1"‖host_id‖attempt‖method; nonce is 12 random bytes.
  • Wire: 0x01 ‖ e_pub(32) ‖ nonce(12) ‖ ciphertext‖tag, base64, with the password capped at 1 KiB.
  • The infraternal agent opens it with ring::agreement::agree_ephemeral, which consumes the private key, so each seal key decrypts at most once. The plaintext goes into a Zeroizing<Vec<u8>>, is written once to the elevation PTY, then wiped. It never reaches argv, the environment, disk or a log.
  • Binding host, attempt and method stops a sealed blob being replayed to another host, attempt or method.
  • A Rust↔TS known-answer vector pins the format in both test suites.
  • Threat model, stated plainly: this keeps the password out of the server's memory, database, logs and backups (D274). A server that is actively malicious could hand the browser its own key. But that server already commands the infraternal agent, so sealing doesn't claim to defend against it.

3. The way in, and wrong passwords

  • Order (D274): passwordless sudo (consent only), then sudo or doas with the person's password, then su with the root password. There is no menu.
  • Elevation: /usr/bin/sudo -p <random marker> -- /bin/sh -c <snippet>, /usr/bin/doas -- /bin/sh -c <snippet>, or /bin/su root -c <snippet>, each run under a fresh PTY with env -i, its own process group and a 60 s ceiling.
  • Wrong password: a second prompt, Sorry, try again, or an authentication-failure exit counts as wrong. The process group is killed at once, so each try costs the host exactly one PAM failure, not sudo's three.
  • The limit (D376): 2 wrong tries per host and method, then the server refuses with locked and retry_after for 15 minutes. That is pam_faillock's default fail_interval, so the host's own counter has reset before the next try. The message names the method ("That sudo password didn't work twice. Try again in 15 minutes."). The counter is kept in the database, so a restart doesn't reset it.

4. Least privilege: what runs as root and why

Runs as root Why it needs root Bound
The elevation snippet (/bin/sh with absolute paths) It copies the relay binary into a fresh root-owned 0700 dir, checks its sha256 against payload_sha256 (the server's own payload digest), and execs it only on a match. An account compromise can't swap in a trojan for root to run. One exec
The relay (infraternal __privilege-relay, the digest-verified copy) It runs the server's root install steps, the same scripts a root SSH Add host runs today. It connects only to the requesting agent's socket (peer uid checked both ways), runs only privilege.run frames, exits on commit or rollback, has a 10 min hard ceiling, and dies with its parent (PR_SET_PDEATHSIG).
The root infraternal agent Mesh (TUN, WireGuard, nftables, resolver), person logins through systemd PAMName= units, PTY masters for other accounts, the proxy's switch to infraternal-proxy, and /etc SSH client files. These are the D274/D18 model with no separate helper. As today

Not root: every terminal and shell (the person's account through the PAM login unit, D229/D270); adopted shims (they stay the person's processes, the root infraternal agent only talks to them); password handling (the rootless infraternal agent, then the PTY); the browser and server.

5. The relay channel

The rootless infraternal agent listens on <its private 0700 sessions dir>/privilege-<attempt>.sock and accepts exactly one peer, with SO_PEERCRED uid 0. The relay refuses unless the listener's owner uid is the expected rootless uid. The socket path is in argv, but it is not a secret. Each frame is a length-prefixed JSON {script, stdin} → {status, stdout}, with a 4 MiB cap per answer. The payload upload goes over stdin, exactly as over SSH.

6. Promotion transaction and rollback journal

  1. Probe, then begin. The seal key is issued and a remote_host_promotions row (host, attempt, person, method, from_uid, state, started_at) is written. Only one promotion per host can be open.
  2. Elevate. On a wrong password, the try is counted and the row is closed. Nothing else has happened.
  3. Check over the relay, as root. This is the same CHECK_SCRIPT and previous_attempt.sh inspection the root add runs, so an interrupted earlier promotion is recovered or refused by the existing code.
  4. Install. A root Installation with a fresh rootful join token for the same host id stages, joins, runs ACTIVATE (journal in /var/lib/infraternal/.bootstrap-lock) and START. The rootless infraternal agent is never stopped.
  5. Handover. The root infraternal agent connects with a rootful certificate for the same host id. Admission accepts a rootful identity for a rootless host only while that host has an open promotion with this transaction. In one write transaction it then sets mode='rootful', execution_uid=0 and revokes the rootless certificate. The rootless link is closed by the server.
  6. Commit. COMMIT_RETIRE runs over the relay, then the relay exits. Only then is the rootless install retired, through a new "retire for promotion" variant of the agent's own uninstall: it stops and removes the user unit, launcher and state, but leaves shims and session sockets alone for adoption (§7).
  7. Failure at any step before 5 commits (wrong digest, join refused, unit won't start, no connection within 60 s, relay lost): Installation::rollback runs over the relay (ROLLBACK_TRANSACTION/FRESH_CLEANUP), removing the half-installed root unit, binary, state and launcher and running daemon-reload. The rootless infraternal agent was never touched, so the host is as it was. The row ends rolled-back, with an activity entry and an Inbox entry.
    • If the relay itself is lost, the host keeps its private journal, and the next promotion's root check recovers it through previous_attempt.sh, exactly as an interrupted root SSH add is recovered today. The server shows "Couldn't finish; the host is unchanged for you. The next try cleans up."
    • A failure after 5 (the retire fails) leaves a working root infraternal agent and a stopped rootless unit. That retry is idempotent.
  8. The proof of "exactly as before": a guest-side snapshot before and after a forced failure. It lists find with mode, uid and SELinux label over /etc/systemd, /var/lib/infraternal, /usr/local/bin, /run/infraternal* and the account's ~/.config/systemd/user and ~/.local/share/infraternal, plus systemctl list-units 'infraternal*' for both managers. The diff must be empty.

7. Adopting running terminals (slice 2)

Rootless shims are the person's processes. Their sockets are in ~/.local/share/infraternal/sessions/sessions/<id>.sock, mode 0600, owned by the person.

  • At handover, the root infraternal agent receives adopt {uid, dir} from the relay (root-owned file /var/lib/infraternal/adopt/<uid>, written by the install step, never from a request).
  • Discovery scans that dir with a separate rule: sockets owned by exactly that uid, and the shim's peer uid must equal it.
  • Attribution comes from the rootless infraternal agent's infraternal-login-locks/<person>-<id>.terminal files, which move to the root agent's lock dir. Revocation and stop keep working, and the session keeps its id, so the browser's window re-attaches as after an agent restart (D13).
  • A shim that fails the handshake stays as it is and stays reachable. The adopt file keeps it, and the person gets a message ("1 terminal on build-01 stayed with the previous agent"). Nothing is killed.
  • The shells don't change user: they keep running as the person and keep their PTY. No root PTY master is introduced for them.
  • Until slice 2 lands, slice 1 refuses to promote a host with open terminals ("Close the 2 open terminals on build-01 first"), so nothing is ever orphaned.

8. Accounts (slice 3), and what already exists

  • preferred_accounts and preferred_host_accounts (migration 0032), resolved by preferred_accounts::read. The order is: per-host choice → (rootless only) the SSH user → global choice → the infraternal username.
  • The ssh-accounts.get/set WS actions exist with no caller. D283's "Logs in as" card is mock-only.
  • Plan:
    • Wire the switcher into the terminal's host popover (the existing packages/ui menu): an "Open as" list showing the person's accounts on that machine plus "Other…". It writes ssh-accounts.set per host.
    • Back "Logs in as" with real data.
    • Add the sign-up hint ("Pick the username you use on your machines; terminals open as it by default").
    • Promotion keeps people where they were: if the promoting person has no per-host choice and their rootless account differs from their infraternal username, the promotion records that account as their per-host choice. Their terminals keep opening as the same account after the upgrade.

9. Audit

There is no host activity log today, so one is added: a migration made with scripts/new-migration for host_activity(host, at, person, kind, method, outcome, detail) with fixed enum kinds and no free text from the host.

  • Every probe-with-elevation, wrong try, lockout, promotion, rollback and retire writes a row.
  • An Inbox feed (GET /remote/hosts/activity?kind=privilege) is read by a new tracer Inbox source (slice 3).
  • Secrets never enter it. A test sends a marker password through every path and greps the database, logs and the server's journal for it.

10. Test plan

  • Unit, red first:
    • seal round trip and tamper/replay refusals (wrong host, attempt, method, reused key) in Rust, plus the same KAT in TS;
    • the prompt/outcome parser for sudo, doas and su transcripts;
    • the lockout counter;
    • admission accepting a rootful identity only with an open promotion;
    • the mode flip and certificate revocation being atomic;
    • the RelaySession frame codec, with caps and peer-uid refusals.
  • Integration (no root):
    • a fake elevator binary on PATH under a PTY: right, wrong, wrong×2 then locked, a hang leading to a timeout;
    • the server endpoint against a fake agent: authorization, owner-only, admin window, an attempt bound to its host.
  • Real root, compute01 guest rig. J395-style runner, one fresh qcow2 overlay of /root/j311b-vm/disk.qcow2 per run (AlmaLinux 10.2, SELinux enforcing), the server on loopback with the J381 TLS cert, and Add host as the kayg account (rootless) through flow.ts:
    1. passwordless sudo: consent → promoted, the root unit active, the user unit gone, AVC count 0;
    2. sudo with a password: wrong, wrong → locked names sudo; then right → promoted;
    3. su with the root password, on an account outside wheel;
    4. forced failures at each step (bad digest, join refused, unit can't start, no connection, relay killed): the host snapshot diff is empty, the rootless infraternal agent is still connected, a terminal still works;
    5. the marker password is absent from /proc/*/cmdline samples taken during elevation, from the guest's journal and audit logs, and from the server's DB and logs;
    6. (slice 2) a running top in an open terminal survives promotion with the same PID.
    • doas needs a host with systemd: a Debian 13 overlay with doas installed, if the image is quick to make; otherwise its transcript tests only, stated as such.
  • Gates: verify quick while iterating, then verify fast on compute01. The privileged-integration row covers the shim/install paths. Real-root behaviour is proved by hand as above (PROCESS §11, NOT_COVERED).

11. Slices and estimates

  1. Endpoint, sealing, promotion with rollback (this job, now): the server HTTP endpoints, promotions table, lockout and activity table; the agent privilege.* actions, sealing, PTY elevation, relay and RelaySession; root install, handover admission and rollback; refusal while terminals are open; the guest proofs 1–5. About 12–16 h.
  2. Adopting running terminals: adopt dir, discovery rule, attribution move, "stayed" message, the retire-for-promotion variant, guest proof 6. About 6–8 h.
  3. Tracer: the real PrivilegeService (WebCrypto sealing, the 2-try wording, lockout), Inbox source, Add host offer, account switcher, real "Logs in as", sign-up hint, screenshots from a production build. About 8–10 h (UI job).
  4. Break-it review (security), then /docs/privileges (D273). About 3 h.

Questions (safe default chosen, work continues)

  • Q1, lockout wait: 15 min (pam_faillock fail_interval). The alternative is 10 min (unlock_time). Default 15.
  • Q2, preferred account after promotion: record the rootless account as the per-host choice when it differs from the infraternal username (§8). The alternative is to switch people to their infraternal username at once. Default: record it, so nobody's terminals change account under them.
## J410 plan: upgrading a host to the root infraternal agent (D376, D274, D139, D18) Branch `root-upgrade-d376`, base origin/dev `0414b7b2f`. Plan first, no product code yet. The slice estimates are at the end. ### What exists today (surveyed on `0414b7b2f`) - **UI:** the approved D286 consent pane (`apps/tracer/src/canvas/windows/access-level.on-demand.ts`). It runs consent, then the password, then progress, and it talks only to `PrivilegeService` (`canvas/sessions/privilege.ts`). The only implementation is the mock. The pane allows unlimited wrong tries, and production supplies no `Adapters.privilege`. The palette entry "Allow full access on <host>" already exists. - **Server:** the SSH install transaction, with its host-side journal in `$base/.bootstrap-lock/` (`install.rs` `ACTIVATE`/`START`/`ROLLBACK_TRANSACTION`/`FRESH_CLEANUP`) and `previous_attempt.sh`'s recovery library (including D359's set-aside and put-back). It runs every step through the `SshSession` trait (`run(script, input)`). `remote_hosts.mode` is enforced in the schema as `rootful ⇔ execution_uid = 0`, and the code treats it as immutable. - **Agent:** its identity is an Ed25519 key, bound to one mode at join (`remote.rs:204`). Server→agent RPC is `Frame::Call`/`Result`, and `remote.host.uninstall` is the closest precedent. Nothing in the repo feeds a password to sudo, doas or su. The shim already accepts a uid-0 peer (`shim_peer_uid_allowed`). A root agent's discovery refuses sockets it doesn't own. - **Crypto:** the agent links `ring` 0.17 and `openssl` (remote transport). `ring`'s X25519 offers only *ephemeral* private keys, which suits a one-use seal key. The browser has WebCrypto (HKDF and AES-GCM are already used in `auth.ts`), and X25519 ECDH is available in current Safari, Chromium and Firefox. **No new library is needed.** - **Gaps:** there is no host activity log and no server-side Inbox (the Inbox is client-only, `inbox-host.ts`). The account switcher has no UI: `preferred_host_accounts` and the `ssh-accounts.get/set` WS actions exist with no caller, and D283 "Logs in as" is mock-only. The code has no "host admin" role; authority is the server owner (`users.owner=1`) plus `remote_hosts.owner_id`, so "host admin" means that until roles arrive. ### 1. Endpoint and protocol behind `PrivilegeService` HTTP, in the same style as host removal (`remote_removal_http.rs`): exact Origin, unlocked session, `bootstrap_owner`, the host's `owner_id`, the D333 admin window rechecked inside the writer transaction, a 32 KiB `deny_unknown_fields` body, a semaphore and a deadline. - `POST /remote/hosts/privilege/probe {host}` returns `{full, method: sudo-nopasswd|sudo|doas|root, denied, reachable, retry_after?}`. - `POST /remote/hosts/privilege/begin {host}` returns `{attempt, method, seal_key (32 B X25519, b64), expires}`. The server asks the infraternal agent for a fresh one-use seal key. - `POST /remote/hosts/privilege/finish {attempt, sealed?}` returns `{outcome: ok|wrong|timeout|offline|locked|failed, method, retry_after?}`. `sealed` is absent for passwordless sudo, which needs consent only (D139). - `GET /remote/hosts/privilege/status?attempt=` returns progress steps for the spinner (`checking → elevated → staging → installing → starting → handover → done | rolled-back`). New infraternal agent actions (`privilege.*`) run on the plugin `Call` path, not the 5 s Control allowlist: - `privilege.probe` runs `sudo -n true`, then `sudo -n -l` ("a password is required" means sudo with a password, otherwise `denied`), then `doas -n true`, then falls back to `su`. All binaries are given by absolute path. - `privilege.seal_key {attempt}` returns the public half of a fresh `ring` `EphemeralPrivateKey`. It is kept in memory for at most 120 s, keyed by attempt, with at most one pending per host. - `privilege.elevate {attempt, method, sealed?, payload_sha256}`: see §3 and §5. - `privilege.run {attempt, script, stdin}` → `{status, stdout}` carries the install steps to the root relay (§5). The server implements `SshSession` for this relay (`RelaySession`). It then runs the **unchanged** root-mode `Installation` (stage, join, ACTIVATE, START, commit, rollback) over it. The promotion reuses the Add host transaction, its journal, `previous_attempt.sh` recovery and D359 put-back, instead of a second installer. ### 2. Sealing format (v1) The browser makes an ephemeral X25519 key pair `e` and computes `dh = X25519(e, seal_key)` (WebCrypto `deriveBits`). - `key = HKDF-SHA256(ikm=dh, salt=e_pub‖seal_key, info="infraternal privilege seal v1"‖host_id(16)‖attempt(16)‖method)`, 32 bytes, AES-256-GCM. - `aad = "ifpriv1"‖host_id‖attempt‖method`; `nonce` is 12 random bytes. - Wire: `0x01 ‖ e_pub(32) ‖ nonce(12) ‖ ciphertext‖tag`, base64, with the password capped at 1 KiB. - The infraternal agent opens it with `ring::agreement::agree_ephemeral`, which *consumes* the private key, so each seal key decrypts at most once. The plaintext goes into a `Zeroizing<Vec<u8>>`, is written once to the elevation PTY, then wiped. It never reaches argv, the environment, disk or a log. - Binding host, attempt and method stops a sealed blob being replayed to another host, attempt or method. - A Rust↔TS known-answer vector pins the format in both test suites. - **Threat model, stated plainly:** this keeps the password out of the server's memory, database, logs and backups (D274). A server that is actively malicious could hand the browser its own key. But that server already commands the infraternal agent, so sealing doesn't claim to defend against it. ### 3. The way in, and wrong passwords - **Order (D274):** passwordless sudo (consent only), then sudo or doas with the person's password, then su with the root password. There is no menu. - **Elevation:** `/usr/bin/sudo -p <random marker> -- /bin/sh -c <snippet>`, `/usr/bin/doas -- /bin/sh -c <snippet>`, or `/bin/su root -c <snippet>`, each run under a fresh PTY with `env -i`, its own process group and a 60 s ceiling. - **Wrong password:** a second prompt, `Sorry, try again`, or an authentication-failure exit counts as wrong. The process group is killed at once, so each try costs the host exactly one PAM failure, not sudo's three. - **The limit (D376):** 2 wrong tries per host and method, then the server refuses with `locked` and `retry_after` for 15 minutes. That is pam_faillock's default `fail_interval`, so the host's own counter has reset before the next try. The message names the method ("That sudo password didn't work twice. Try again in 15 minutes."). The counter is kept in the database, so a restart doesn't reset it. ### 4. Least privilege: what runs as root and why | Runs as root | Why it needs root | Bound | |---|---|---| | The elevation snippet (`/bin/sh` with absolute paths) | It copies the relay binary into a fresh root-owned 0700 dir, checks its sha256 against `payload_sha256` (the server's own payload digest), and execs it only on a match. An account compromise can't swap in a trojan for root to run. | One `exec` | | The relay (`infraternal __privilege-relay`, the digest-verified copy) | It runs the server's root install steps, the same scripts a root SSH Add host runs today. | It connects only to the requesting agent's socket (peer uid checked both ways), runs only `privilege.run` frames, exits on commit or rollback, has a 10 min hard ceiling, and dies with its parent (`PR_SET_PDEATHSIG`). | | The root infraternal agent | Mesh (TUN, WireGuard, nftables, resolver), person logins through systemd `PAMName=` units, PTY masters for other accounts, the proxy's switch to `infraternal-proxy`, and `/etc` SSH client files. These are the D274/D18 model with no separate helper. | As today | **Not root:** every terminal and shell (the person's account through the PAM login unit, D229/D270); adopted shims (they stay the person's processes, the root infraternal agent only talks to them); password handling (the rootless infraternal agent, then the PTY); the browser and server. ### 5. The relay channel The rootless infraternal agent listens on `<its private 0700 sessions dir>/privilege-<attempt>.sock` and accepts exactly one peer, with `SO_PEERCRED` uid 0. The relay refuses unless the listener's owner uid is the expected rootless uid. The socket path is in argv, but it is not a secret. Each frame is a length-prefixed JSON `{script, stdin}` → `{status, stdout}`, with a 4 MiB cap per answer. The payload upload goes over stdin, exactly as over SSH. ### 6. Promotion transaction and rollback journal 1. **Probe, then begin.** The seal key is issued and a `remote_host_promotions` row (`host, attempt, person, method, from_uid, state, started_at`) is written. Only one promotion per host can be open. 2. **Elevate.** On a wrong password, the try is counted and the row is closed. Nothing else has happened. 3. **Check over the relay, as root.** This is the same `CHECK_SCRIPT` and `previous_attempt.sh` inspection the root add runs, so an interrupted earlier promotion is recovered or refused by the existing code. 4. **Install.** A root `Installation` with a fresh **rootful join token for the same host id** stages, joins, runs ACTIVATE (journal in `/var/lib/infraternal/.bootstrap-lock`) and START. **The rootless infraternal agent is never stopped.** 5. **Handover.** The root infraternal agent connects with a rootful certificate for the same host id. Admission accepts a rootful identity for a rootless host *only* while that host has an open promotion with this transaction. In one write transaction it then sets `mode='rootful', execution_uid=0` and revokes the rootless certificate. The rootless link is closed by the server. 6. **Commit.** `COMMIT_RETIRE` runs over the relay, then the relay exits. Only then is the rootless install retired, through a new "retire for promotion" variant of the agent's own uninstall: it stops and removes the user unit, launcher and state, but **leaves shims and session sockets alone** for adoption (§7). 7. **Failure at any step before 5 commits** (wrong digest, join refused, unit won't start, no connection within 60 s, relay lost): `Installation::rollback` runs over the relay (`ROLLBACK_TRANSACTION`/`FRESH_CLEANUP`), removing the half-installed root unit, binary, state and launcher and running `daemon-reload`. The rootless infraternal agent was never touched, so the host is as it was. The row ends `rolled-back`, with an activity entry and an Inbox entry. - If the relay itself is lost, the host keeps its private journal, and the next promotion's root check recovers it through `previous_attempt.sh`, exactly as an interrupted root SSH add is recovered today. The server shows "Couldn't finish; the host is unchanged for you. The next try cleans up." - A failure after 5 (the retire fails) leaves a working root infraternal agent and a stopped rootless unit. That retry is idempotent. 8. **The proof of "exactly as before":** a guest-side snapshot before and after a forced failure. It lists `find` with mode, uid and SELinux label over `/etc/systemd`, `/var/lib/infraternal`, `/usr/local/bin`, `/run/infraternal*` and the account's `~/.config/systemd/user` and `~/.local/share/infraternal`, plus `systemctl list-units 'infraternal*'` for both managers. The diff must be empty. ### 7. Adopting running terminals (slice 2) Rootless shims are the person's processes. Their sockets are in `~/.local/share/infraternal/sessions/sessions/<id>.sock`, mode 0600, owned by the person. - At handover, the root infraternal agent receives `adopt {uid, dir}` from the relay (root-owned file `/var/lib/infraternal/adopt/<uid>`, written by the install step, never from a request). - Discovery scans that dir with a separate rule: sockets owned by **exactly that uid**, and the shim's peer uid must equal it. - Attribution comes from the rootless infraternal agent's `infraternal-login-locks/<person>-<id>.terminal` files, which move to the root agent's lock dir. Revocation and stop keep working, and the session keeps its id, so the browser's window re-attaches as after an agent restart (D13). - A shim that fails the handshake stays as it is and stays reachable. The adopt file keeps it, and the person gets a message ("1 terminal on build-01 stayed with the previous agent"). Nothing is killed. - The shells don't change user: they keep running as the person and keep their PTY. No root PTY master is introduced for them. - **Until slice 2 lands, slice 1 refuses to promote a host with open terminals** ("Close the 2 open terminals on build-01 first"), so nothing is ever orphaned. ### 8. Accounts (slice 3), and what already exists - `preferred_accounts` and `preferred_host_accounts` (migration 0032), resolved by `preferred_accounts::read`. The order is: per-host choice → (rootless only) the SSH user → global choice → the infraternal username. - The `ssh-accounts.get/set` WS actions exist with no caller. D283's "Logs in as" card is mock-only. - **Plan:** - Wire the switcher into the terminal's host popover (the existing `packages/ui` menu): an "Open as" list showing the person's accounts on that machine plus "Other…". It writes `ssh-accounts.set` per host. - Back "Logs in as" with real data. - Add the sign-up hint ("Pick the username you use on your machines; terminals open as it by default"). - **Promotion keeps people where they were:** if the promoting person has no per-host choice and their rootless account differs from their infraternal username, the promotion records that account as their per-host choice. Their terminals keep opening as the same account after the upgrade. ### 9. Audit There is no host activity log today, so one is added: a migration made with `scripts/new-migration` for `host_activity(host, at, person, kind, method, outcome, detail)` with fixed enum kinds and no free text from the host. - Every probe-with-elevation, wrong try, lockout, promotion, rollback and retire writes a row. - An Inbox feed (`GET /remote/hosts/activity?kind=privilege`) is read by a new tracer Inbox source (slice 3). - Secrets never enter it. A test sends a marker password through every path and greps the database, logs and the server's journal for it. ### 10. Test plan - **Unit, red first:** - seal round trip and tamper/replay refusals (wrong host, attempt, method, reused key) in Rust, plus the same KAT in TS; - the prompt/outcome parser for sudo, doas and su transcripts; - the lockout counter; - admission accepting a rootful identity only with an open promotion; - the mode flip and certificate revocation being atomic; - the `RelaySession` frame codec, with caps and peer-uid refusals. - **Integration (no root):** - a fake elevator binary on PATH under a PTY: right, wrong, wrong×2 then locked, a hang leading to a timeout; - the server endpoint against a fake agent: authorization, owner-only, admin window, an attempt bound to its host. - **Real root, compute01 guest rig.** J395-style runner, one fresh qcow2 overlay of `/root/j311b-vm/disk.qcow2` per run (AlmaLinux 10.2, **SELinux enforcing**), the server on loopback with the J381 TLS cert, and Add host as the `kayg` account (rootless) through `flow.ts`: 1. passwordless sudo: consent → promoted, the root unit active, the user unit gone, AVC count 0; 2. sudo with a password: wrong, wrong → `locked` names sudo; then right → promoted; 3. su with the root password, on an account outside wheel; 4. forced failures at each step (bad digest, join refused, unit can't start, no connection, relay killed): the host snapshot diff is empty, the rootless infraternal agent is still connected, a terminal still works; 5. the marker password is absent from `/proc/*/cmdline` samples taken during elevation, from the guest's journal and audit logs, and from the server's DB and logs; 6. (slice 2) a running `top` in an open terminal survives promotion with the same PID. - doas needs a host with systemd: a Debian 13 overlay with `doas` installed, if the image is quick to make; otherwise its transcript tests only, stated as such. - **Gates:** `verify quick` while iterating, then `verify fast` on compute01. The privileged-integration row covers the shim/install paths. Real-root behaviour is proved by hand as above (PROCESS §11, NOT_COVERED). ### 11. Slices and estimates 1. **Endpoint, sealing, promotion with rollback** (this job, now): the server HTTP endpoints, promotions table, lockout and activity table; the agent `privilege.*` actions, sealing, PTY elevation, relay and `RelaySession`; root install, handover admission and rollback; refusal while terminals are open; the guest proofs 1–5. **About 12–16 h.** 2. **Adopting running terminals:** adopt dir, discovery rule, attribution move, "stayed" message, the retire-for-promotion variant, guest proof 6. **About 6–8 h.** 3. **Tracer:** the real `PrivilegeService` (WebCrypto sealing, the 2-try wording, lockout), Inbox source, Add host offer, account switcher, real "Logs in as", sign-up hint, screenshots from a production build. **About 8–10 h** (UI job). 4. **Break-it review** (security), then `/docs/privileges` (D273). **About 3 h.** ### Questions (safe default chosen, work continues) - **Q1, lockout wait:** 15 min (pam_faillock `fail_interval`). The alternative is 10 min (`unlock_time`). Default 15. - **Q2, preferred account after promotion:** record the rootless account as the per-host choice when it differs from the infraternal username (§8). The alternative is to switch people to their infraternal username at once. Default: record it, so nobody's terminals change account under them.
Author
Owner

J410 progress (slice 1). Branch root-upgrade-d376, head 862fab814, rebased onto origin/dev 4754b14a7. Each commit is pushed.

Done (code; the unit tests are queued on dev-02, position 7, log ~claude/j410-ev/seal.log):

  • ef6be5324 remote: the one-use seal opener (ring X25519 ephemeral, HKDF-SHA256, AES-256-GCM, bound to host, attempt and method). The KAT comes from Python cryptography.
  • 00e069844 remote: the relay frame codec (Open, Stdin, StdinEnd, Kill, Stdout, Exit, Ready, Close). It is strict, with caps of 1 MiB per script and 256 KiB per chunk.
  • a72e950ec agent: elevation under a fresh pty (setsid --ctty; sudo -k -p <random marker>, doas, su). Each try costs the host one failed login. Root runs only the digest-checking snippet.
  • 31ce8f774 agent: the root relay __privilege-relay. It accepts only its owner's socket, runs scripts with an empty environment, and is bounded at 15 min.
  • 862fab814 agent: privilege.probe/key/elevate/pump/end on the control stream, routed beside terminal.startup. The plugin worker can't be used: one call at a time and 8 KiB.

Found while building:

  • The plugin Call path is serialized and capped at 8 KiB, so the relay bytes go through a dedicated daemon route, which allows up to 2 MiB per pump call.
  • Input::Signal (commit waits for proof on stdin) needs a live duplex, so the server side spawns a small local helper per install step and reuses process::execute unchanged. The helper plays the role ssh plays today.

Next: the server side (relay hub plus helper, promotion transaction over the unchanged root Installation, handover admission, the host_activity table with lockout, HTTP endpoints), then the guest proofs.

**J410 progress (slice 1).** Branch `root-upgrade-d376`, head `862fab814`, rebased onto origin/dev `4754b14a7`. Each commit is pushed. Done (code; the unit tests are queued on dev-02, position 7, log `~claude/j410-ev/seal.log`): - `ef6be5324` remote: the one-use seal opener (ring X25519 ephemeral, HKDF-SHA256, AES-256-GCM, bound to host, attempt and method). The KAT comes from Python `cryptography`. - `00e069844` remote: the relay frame codec (Open, Stdin, StdinEnd, Kill, Stdout, Exit, Ready, Close). It is strict, with caps of 1 MiB per script and 256 KiB per chunk. - `a72e950ec` agent: elevation under a fresh pty (`setsid --ctty`; sudo `-k -p <random marker>`, doas, su). Each try costs the host one failed login. Root runs only the digest-checking snippet. - `31ce8f774` agent: the root relay `__privilege-relay`. It accepts only its owner's socket, runs scripts with an empty environment, and is bounded at 15 min. - `862fab814` agent: `privilege.probe/key/elevate/pump/end` on the control stream, routed beside `terminal.startup`. The plugin worker can't be used: one call at a time and 8 KiB. Found while building: - The plugin `Call` path is serialized and capped at 8 KiB, so the relay bytes go through a dedicated daemon route, which allows up to 2 MiB per pump call. - `Input::Signal` (commit waits for proof on stdin) needs a live duplex, so the server side spawns a small local helper per install step and reuses `process::execute` unchanged. The helper plays the role `ssh` plays today. Next: the server side (relay hub plus helper, promotion transaction over the unchanged root `Installation`, handover admission, the `host_activity` table with lockout, HTTP endpoints), then the guest proofs.
Author
Owner

J410 finding: same-host handover crosses a write-once authority invariant (design note, decision needed before code)

The plan's §6 step 5 ("root agent connects with a rootful certificate for the same host id; flip mode/execution_uid in one transaction") collides with an invariant the code documents and enforces at seven independent layers. remote_hosts.mode and execution_uid are write-once ("Immutable enrolled privilege mode", remote_authority.rs:11; plugins/remote/src/enrollment.rs:6). No production UPDATE remote_hosts touches them.

  1. issue_invitation (remote_http.rs:412-432): ON CONFLICT DO NOTHING, then re-reads … AND mode=?, so a rootful invitation for a live rootless host returns FORBIDDEN.
  2. The token write (enrollment.rs:278-302) copies the token's mode from the host row (WHERE … mode = ?).
  3. Redemption (enrollment.rs:407-441) requires h.mode = token.mode. It also neither changes remote_hosts nor revokes old certificates.
  4. AuthorityGuard (remote_guard.rs:401-441) requires every operation's fresh HostAuthority (mode and uid) to equal the admitted one, so flipping the row under a live link fails all of its operations.
  5. admit_observed/admit_primed (remote_runtime.rs:517, 616, 639): host.uid != uid → PermissionDenied while the uid-1001 actor is live.
  6. The actor's uid is fixed at construction (embedded.rs:399, 947).
  7. The infraternal agent refuses an invitation whose uid/mode doesn't match its euid (crates/infraternal/src/remote.rs:203-205).

There is also a chicken-and-egg constraint. The relay runs through the rootless infraternal agent, so the rootless link must stay up until the root one is proven. The root one can't enroll until the host is rootful.

Proposed design (recommendation), for review before any of it is built: a pending promotion record makes the change explicit and narrow, instead of loosening the invariant generally.

  • New table remote_host_promotions(host_id PK, transaction, person, expected_key_digest, issued_at, expires_at, state). At most one per host, with a 15 min lifetime.
  • A rootful join token for a rootless host is accepted only when a live promotion row binds it: same host, expected_key_digest, unexpired. Redemption inserts the rootful certificate and marks the promotion enrolled. It does not flip the host.
  • Admission of a uid-0 link for a host whose row says rootless is accepted only when the presented certificate is the one the enrolled promotion recorded. It is admitted as a standby successor: it doesn't replace the live uid-1001 actor.
  • Handover is one write transaction, run when the server has proof from the successor (host.facts.read executionUid=0, plus a terminal canary, as complete_setup does today). It sets mode='rootful', execution_uid=0, revokes the rootless certificates, and marks the promotion done. The guard's per-host revocation signal then retires the uid-1001 actor, and the registry rebuilds the actor with uid 0 from the successor link. After that, COMMIT_RETIRE runs over the relay (the relay's own process is root and doesn't depend on the rootless link once the commit step's stdin is released).
  • Rollback before handover: the promotion row is deleted, the rootful certificate is revoked, the token is consumed, and Installation::rollback runs over the relay. The host row was never touched, so the host is exactly as before.
  • Rejected alternatives: (a) enrolling the root agent as a new host id and moving per-host data to it (preferred accounts, mesh membership, names, open windows, history all key on host id, and a half-moved host is worse than a failed upgrade); (b) flipping the row before install (tears down the rootless link that carries the relay); (c) making mode mutable in general (removes a protection every layer above relies on).

Status: I'm not building this in J410. It changes authority code that gets the strictest review, and it needs a reviewer to read it first. What's built and proven on this branch is the host half and the server's step transport. The server endpoint and handover are the next job, from this note.

### J410 finding: same-host handover crosses a write-once authority invariant (design note, decision needed before code) The plan's §6 step 5 ("root agent connects with a rootful certificate for the same host id; flip `mode`/`execution_uid` in one transaction") collides with an invariant the code documents and enforces at seven independent layers. `remote_hosts.mode` and `execution_uid` are write-once ("Immutable enrolled privilege mode", `remote_authority.rs:11`; `plugins/remote/src/enrollment.rs:6`). No production `UPDATE remote_hosts` touches them. 1. `issue_invitation` (`remote_http.rs:412-432`): `ON CONFLICT DO NOTHING`, then re-reads `… AND mode=?`, so a rootful invitation for a live rootless host returns FORBIDDEN. 2. The token write (`enrollment.rs:278-302`) copies the token's mode from the host row (`WHERE … mode = ?`). 3. Redemption (`enrollment.rs:407-441`) requires `h.mode = token.mode`. It also neither changes `remote_hosts` nor revokes old certificates. 4. `AuthorityGuard` (`remote_guard.rs:401-441`) requires every operation's fresh `HostAuthority` (mode and uid) to equal the admitted one, so flipping the row under a live link fails all of its operations. 5. `admit_observed`/`admit_primed` (`remote_runtime.rs:517, 616, 639`): `host.uid != uid` → PermissionDenied while the uid-1001 actor is live. 6. The actor's uid is fixed at construction (`embedded.rs:399, 947`). 7. The infraternal agent refuses an invitation whose uid/mode doesn't match its euid (`crates/infraternal/src/remote.rs:203-205`). There is also a chicken-and-egg constraint. The relay runs *through* the rootless infraternal agent, so the rootless link must stay up until the root one is proven. The root one can't enroll until the host is rootful. **Proposed design (recommendation), for review before any of it is built:** a *pending promotion* record makes the change explicit and narrow, instead of loosening the invariant generally. - New table `remote_host_promotions(host_id PK, transaction, person, expected_key_digest, issued_at, expires_at, state)`. At most one per host, with a 15 min lifetime. - A rootful join token for a rootless host is accepted only when a live promotion row binds it: same host, `expected_key_digest`, unexpired. Redemption inserts the rootful certificate and marks the promotion `enrolled`. It does **not** flip the host. - Admission of a uid-0 link for a host whose row says rootless is accepted only when the presented certificate is the one the `enrolled` promotion recorded. It is admitted as a *standby successor*: it doesn't replace the live uid-1001 actor. - **Handover** is one write transaction, run when the server has proof from the successor (`host.facts.read` `executionUid=0`, plus a terminal canary, as `complete_setup` does today). It sets `mode='rootful', execution_uid=0`, revokes the rootless certificates, and marks the promotion `done`. The guard's per-host revocation signal then retires the uid-1001 actor, and the registry rebuilds the actor with uid 0 from the successor link. After that, COMMIT_RETIRE runs over the relay (the relay's own process is root and doesn't depend on the rootless link once the commit step's stdin is released). - **Rollback before handover:** the promotion row is deleted, the rootful certificate is revoked, the token is consumed, and `Installation::rollback` runs over the relay. The host row was never touched, so the host is exactly as before. - **Rejected alternatives:** (a) enrolling the root agent as a *new* host id and moving per-host data to it (preferred accounts, mesh membership, names, open windows, history all key on host id, and a half-moved host is worse than a failed upgrade); (b) flipping the row before install (tears down the rootless link that carries the relay); (c) making mode mutable in general (removes a protection every layer above relies on). **Status:** I'm not building this in J410. It changes authority code that gets the strictest review, and it needs a reviewer to read it first. What's built and proven on this branch is the host half and the server's step transport. The server endpoint and handover are the next job, from this note.
Author
Owner

J415 (independent security and correctness review of the D376 handover design and the host-half commits) starting. Branch root-upgrade-d376, head 93695fe8e9f1517e8bc67769543bf49b8435700d. Read-only; verdict to follow here.

J415 (independent security and correctness review of the D376 handover design and the host-half commits) starting. Branch `root-upgrade-d376`, head `93695fe8e9f1517e8bc67769543bf49b8435700d`. Read-only; verdict to follow here.
Author
Owner

J415 review: D376 handover design + host-half commits

Reviewed root-upgrade-d376 at 93695fe8e (the 9 commits from ef6be5324), J410's design note above, and the code it cites: remote_authority.rs, server enrollment.rs, remote_guard.rs, remote_runtime.rs (admit_observed/admit_primed/deliver_login_cleanup), bootstrap_remote.rs (terminal_canary), and migration 0015. I read the code but did not run tests (J410's run is queued on dev-02).

Verdict: APPROVE WITH REQUIRED CHANGES

The overall design is right: a standby successor on the same host id, one write transaction as the only point of handover, and the rootless infraternal agent left untouched until then. All three alternatives were rightly rejected (reasons below). But the note's analysis of the seven layers misses three things in the code that would break it as written. Each of them gets a required change. Changes R1 to R7 below are the build brief.


Answers to the six questions

1. Can a successor act with root authority before the handover commit? As written, yes. That is the most serious gap (→ R1, R2).

  • remote_certificates has no mode or uid column (migration 0015). EnrollmentStore::admit() takes mode and uid from the host row only (remote_authority.rs:31-55). So once the rootful certificate is redeemed for the same host id, the ordinary admit() accepts it as rootless, uid 1001.
  • admit_observed then finds host.uid == uid and replaces the live rootless link with the root process (remote_runtime.rs:514-528). Every terminal, plugin call and privilege.pump is then routed to the successor before any handover. Pumps break at once: the root process answers {"full":true}.
  • The reverse holds too. After the flip, any rootless certificate left unrevoked (a renewal that commits during the promotion, for example) would be admitted as root, uid 0.
  • "Standby" therefore needs a role on the certificate, plus a private actor that is never put in hosts.

2. Is the handover atomic with respect to the guard and the registry? The database write is; the in-memory side, as proposed, is not (→ R2, R3).

  • Revocations is keyed by host id only, and cut() stays set until tombstoned() (remote_guard.rs:105-125). The note says "the per-host revocation signal retires the uid-1001 actor". That cut also cancels the successor's guard, which shares the same Arc<Revocation> through register(). It then refuses every new admission for the host until the cut is released. The result is no live authority, by construction.
  • The registry keeps one actor per host with a fixed uid (embedded.rs, admit_observed). The successor can't sit beside it.

Crash at each step, after R1 to R7:

Crash point Database Live authority Host Recovery
Before redemption promotion open rootless partial staging the relay rolls back on EOF (R4); the sweeper expires the row
After redemption, before proof enrolled, successor cert role=successor rootless only (a successor is never admitted by admit()) root unit may be running, in standby the sweeper marks abandoned in one transaction that also bars handover; a durable outbox tells the successor to roll back and uninstall itself (R5); its cert is revoked on receipt
During the handover transaction SQLite: all or nothing one of the two
After commit, before the old actor is cut handed_over, rootless keys revoked rootful in the database; the old actor's open streams run until the cut (operations already fail, because admit_operation rechecks) rootless unit still running cut epoch (host, 1001) right after commit (R2). The predecessor is retired by the root infraternal agent from a durable outbox, not by the relay (R5)
After commit, then the server restarts same rootful only; the rootless agent is refused same same outbox on reconnect

So there are never two authorities in the database, and there is never "none" except an ordinary disconnect. That holds only if the cut is per authority epoch (R2) and the abandonment write also bars any later handover (R5).

One more point in the note doesn't hold. Step 6 runs COMMIT_RETIRE over the relay after the rootless certificates are revoked, but the relay's bytes travel through privilege.pump on the rootless link, which the handover has just closed. Retirement must not depend on the relay (R5).

3. How do running terminals get adopted, and does least privilege hold? Not quite as planned in §7 (→ R6).

  • §7 has the root daemon scan ~/.local/share/infraternal/sessions/... and connect to sockets in a directory the account controls. Doing that as root invites symlink and TOCTOU attacks: root connects to whatever socket the path points at once it is swapped.
  • Attribution would come from infraternal-login-locks/*.terminal files that the account can write. The account could then label one of its own shells as another infraternal person's terminal. That person would see it in their list and might type secrets into it.
  • Least privilege per D270/D376: adoption is done by that person's own process (it runs as the uid that owns the shims). Attribution comes from the server's records of which person opened which session id on that host, never from files on the host.
  • Root is then limited to the elevation snippet, the relay, and the root infraternal agent's mesh, login-unit and proxy duties. That matches §4's table.
  • The successor's terminal canary must open as the person's account (the same account the session would use), not as root.

4. Can an attacker with only the rootless agent's key start or forge a promotion?

  • Start: no. Only a host admin's unlocked browser session can begin one, as long as the begin endpoint checks that in the same write as the row insert, the way issue_authorized_in does. No message from the infraternal agent may create or advance a promotion row.
  • Forge: partly. This is inherent and needs to be said plainly. If an admin starts a promotion while the key holder is the live link (admit_observed lets the newest link take over), the key holder answers privilege.key, so it receives the person's password. It also sees the rootful join token and the key digest in the relay stream, so it can enroll its own successor and report executionUid=0 about itself. D274 already accepts that the rootless agent is the trusted conduit; sealing does not change that.
  • Required mitigations (R7): bind the promotion to the exact rootless certificate fingerprint and link generation seen at begin, and roll back if that link is replaced during the promotion. After handover, revoke by public key so the old key can never be admitted again. /docs/privileges must say that the password is only as safe as the rootless agent's key.

5. Does the one-use seal resist replay? Yes.

  • agree_ephemeral consumes the private key, and elevate removes the key from the map before any validation (privilege_agent.rs:222). A second elevate of the same attempt finds no key.
  • Re-keying an attempt replaces the key, so an old blob is dead. Keys live in memory only, so a restart kills them.
  • Host, attempt and method are bound in both the HKDF info and the AAD, at fixed lengths with the variable-length field last, which leaves no ambiguity.
  • ring refuses an all-zero X25519 result.
  • Small gaps are listed as P3.

6. Were the rejected alternatives rightly rejected? Yes, all three.

  • (a) A new host id: it breaks D18's "a host has one agent" identity and splits every per-host key. A half-moved host is worse than a failed upgrade.
  • (b) Flipping the row first: it cuts the link that carries the relay, and rolling back would then need a reverse flip, which means mutability anyway.
  • (c) Making mode mutable in general: it removes the check that admit, the guard and the registry all rely on.
  • One addition: the promotion is a narrow mutation, so the invariant should be restated as "mode changes only through a proven promotion's commit, rootless→rootful", and the database should enforce it (R3). D274's later downgrade can reuse the same mechanism in the other direction.

Required design changes (build brief)

  • R1. Certificates carry a role.

    • Add remote_certificates.role (active/successor) and execution_uid through scripts/new-migration, backfilling active with the host's uid.
    • admit() must require c.role='active' AND c.execution_uid=h.execution_uid, and renewal copies the predecessor's role and uid.
    • Add a separate admit_successor(fp) that returns a distinct SuccessorAuthority type, which AuthorityGuard::admit / admit_observed cannot accept. It is valid only while a promotion row in state enrolled names this exact fingerprint (and its public key), and expires_at > now.
  • R2. The standby actor is private, and revocation is per epoch.

    • Admit the successor like deliver_login_cleanup: its own EmbeddedAgent::remote(0), never in hosts, no plugins, no attach, no node channel. It accepts only proof calls: host.facts.read, and a canary as the person.
    • Key Revocations by (host, execution_uid). Host removal cuts every epoch of the host.
    • Handover order: commit, then cut (host, from_uid), then tombstoned((host, from_uid)). The successor then reconnects, or is promoted in place, through ordinary admit() as rootful.
    • Post-commit streaming on the old actor is bounded by that cut, and operations are already refused by admit_operation's recheck.
  • R3. One transaction, enforced by the database. The handover transaction does all of the following:

    • checks that the promotion is enrolled, unexpired, and bound to this successor fingerprint, and that the rootless fingerprint is still live;
    • runs UPDATE remote_hosts SET mode='rootful', execution_uid=0 WHERE id=? AND mode='rootless' AND execution_uid=?from;
    • revokes every certificate of the host whose public_key is not the successor's (by key, so a renewal that commits during the promotion is covered);
    • sets the successor certificate to role='active' and the promotion to handed_over.

    Add a BEFORE UPDATE OF mode, execution_uid ON remote_hosts trigger that raises unless the new values are rootful/0, the old values are rootless, and an enrolled promotion row for that host exists. This keeps write-once true for every other path.

  • R4. The host rolls itself back.

    • The relay gets a Rollback script (sent first, kept in its root-only directory) and an explicit Commit frame.
    • On EOF, an Err, LIFETIME, or Close without Commit, it kills the running steps and runs the rollback under the bootstrap lock before it exits.
    • Without this, a server restart, a network blip or one failed pump (relay_session.rs marks the session lost on the first Err) leaves a half-installed root unit, which breaks D376 ("failure puts the host back").
  • R5. Durable outboxes, not the relay, finish both endings.

    • Abandon (sweeper or explicit failure after redemption): one transaction sets the promotion to abandoned (and handover requires enrolled, so it can never commit afterwards). It also queues "roll back and uninstall" for the successor. The successor's certificate stays admissible only for that cleanup-only path (the admit_cleanup pattern) until its receipt.
    • Retire predecessor (after handover): queued for the now-rootful infraternal agent. It stops and removes the rootless user unit and launcher as root, leaves shims and sockets for R6, and sends a receipt. It runs at least once and is idempotent.
    • The successor starts in standby on the host: no mesh, no logins, no proxy switch until the server's first rootful admission. Two agents must never manage the same host resources.
  • R6. Adoption runs as the person, and attribution comes from the server.

    • The root infraternal agent starts the person's own process (its PAM login unit). That process discovers and handshakes the shims in its own directory.
    • The person, terminal id and session id mapping comes from server state.
    • Shims that fail stay put, with the "stayed" message. Root never opens a path under a user's home.
  • R7. Bind the promotion to the rootless identity.

    • Record the rootless certificate fingerprint and link generation at begin. A replaced rootless link during the promotion leads to abandonment (R5).
    • Begin checks host-admin and unlocked-session authority in the same write as the insert.
    • The 2-try counter reserves the try in that same write (see B2).

Findings on the built commits

P1

  • B1 (31ce8f774, privilege_relay_host.rs:136-187): on socket EOF, LIFETIME or Close, the relay kills its steps and exits; no rollback runs. Combined with relay_session.rs pump_loop, where one pump Err marks the session lost and every later step returns 255 (so Installation::rollback can't run either), any lost link mid-install leaves root files or a running root unit behind. That breaks D376. Fix: R4.

P2

  • B2 (23f464ea6, host_activity.rs):
    • retry_after is a read the caller acts on later. Two concurrent finishes (two tabs or two admins) both see one wrong try and both elevate, which makes three PAM failures and triggers the lockout D376 is meant to avoid. The check and the reservation must be one write, for example INSERT … SELECT … WHERE with no in-flight attempt and fewer than 2 wrong tries in the window, with in-flight attempts counted.
    • Any outcome other than Started after the password was typed (Timeout, Failed) must count as a try, because PAM may already have counted it.
  • B3 (a72e950ec, privilege_elevate.rs:342-348): for su (Linux-PAM misc_conv), the prompt is printed before echo is turned off with TCSAFLUSH. The password can then be echoed back into Transcript.tail (a plain Vec, not wiped) and/or discarded, which ends as a Timeout. sudo and OpenDoas switch echo off before the prompt, so they are fine. Fix: poll tcgetattr(master) until ECHO is clear before writing, and make tail Zeroizing.
  • B4 (31ce8f774 + 862fab814), a stall:
    • The relay's stdin.send(bytes).await (privilege_relay_host.rs:159) blocks the whole frame loop when a script stops reading its stdin, so Kill and Close are never processed.
    • On the infraternal agent's side, pump does write_all while holding the global State mutex with no timeout (privilege_agent.rs:293-296).
    • The privilege.* calls share the 4-slot renewals lane (daemon.rs). One stuck relay can block certificate renewal and terminal.startup for up to 15 minutes.
    • Fix: use try_send/drop or a per-channel buffer in the relay, bound write_all with a timeout, and give the privilege actions their own lane of 1 to 2 slots.
  • B5 (31ce8f774, a72e950ec), channel authentication:
    • The relay trusts any listener owned by the uid (connect, :87-100). The account owns sessions/privilege-*/, so a process running as that same account can swap the socket and drive a root relay. The §4 claim that "an account compromise can't make root run their bytes" holds only for the binary, not for the scripts.
    • Cheap fix: the infraternal agent types a 32-byte one-time token into the pty after STARTED. The snippet reads it with read -r, passes it to the relay on stdin, never in argv, and the relay requires it in the first frame from the agent. Where ptrace is restricted, another process of the same account can't read that pty.
    • Otherwise, state the limit in /docs/privileges.

P3

  • B6: the design's §4 promises PR_SET_PDEATHSIG, which isn't implemented. The module doc says a terminal hangup stops the relay, but the snippet sends the relay's stdio to /dev/null. In practice, socket EOF is what ends it. Make the docs say so.
  • B7: the KAT pins only the symmetric half. Add a fixed-key X25519 vector (Python cryptography can load fixed private keys), checked by the TS sealer, so the agreement and the salt order (e_pub ‖ seal_pub) are pinned in both suites.
  • B8: the design says one pending key per host, but key() allows 2 keys, and re-keying the same attempt silently replaces the key. Make it one, and refuse a re-key.
  • B9:
    • The lockout is keyed by method, but sudo and doas fail against the same PAM account, while su fails against root. Key it by the authenticated account (person-account or root).
    • Failures typed by hand in a terminal are invisible to the counter. Say so in the message docs.
  • B10:
    • elevate holds the global State mutex for up to 80 s, so privilege.end can't cancel an elevation in flight.
    • Server side: RelaySession doesn't require Ready to come first, and it doesn't cap Stderr frames itself (the relay caps them, but the rootless pipe could inject more).
  • B11: su root -c runs root's login shell, which is often nologin or a non-POSIX shell. Detect that and report it plainly instead of reporting Failed.
  • B12: ring doesn't wipe the shared secret or the HKDF output it holds internally. That is a library limit and acceptable, but note it beside the module's wiping claims.

What holds up well:

  • argv carries only public values, with no secret in the environment or in logs;
  • sudo uses -k and a random -p marker;
  • every try is killed after one PAM failure;
  • the snippet hashes the root-owned copy before it runs it;
  • the relay checks the peer uid before it writes any byte;
  • the frame codec is strict, with per-kind caps;
  • the activity table has no free text, enforced by CHECK.

Next: J410 or the next backend job builds R1 to R7 and fixes B1 to B5 before any server endpoint ships. B1 and R4 are the same work. I'd like a re-review of R1 to R3 (the authority code) before merge.

## J415 review: D376 handover design + host-half commits Reviewed `root-upgrade-d376` at `93695fe8e` (the 9 commits from `ef6be5324`), J410's design note above, and the code it cites: `remote_authority.rs`, server `enrollment.rs`, `remote_guard.rs`, `remote_runtime.rs` (`admit_observed`/`admit_primed`/`deliver_login_cleanup`), `bootstrap_remote.rs` (`terminal_canary`), and migration 0015. I read the code but did not run tests (J410's run is queued on dev-02). ### Verdict: **APPROVE WITH REQUIRED CHANGES** The overall design is right: a standby successor on the same host id, one write transaction as the only point of handover, and the rootless infraternal agent left untouched until then. All three alternatives were rightly rejected (reasons below). But the note's analysis of the seven layers misses three things in the code that would break it as written. Each of them gets a required change. Changes R1 to R7 below are the build brief. --- ### Answers to the six questions **1. Can a successor act with root authority before the handover commit? As written, yes. That is the most serious gap (→ R1, R2).** - `remote_certificates` has **no mode or uid column** (migration 0015). `EnrollmentStore::admit()` takes mode and uid **from the host row only** (`remote_authority.rs:31-55`). So once the rootful certificate is redeemed for the same host id, the ordinary `admit()` accepts it as **rootless, uid 1001**. - `admit_observed` then finds `host.uid == uid` and **replaces the live rootless link** with the root process (`remote_runtime.rs:514-528`). Every terminal, plugin call and `privilege.pump` is then routed to the successor before any handover. Pumps break at once: the root process answers `{"full":true}`. - The reverse holds too. After the flip, any rootless certificate left unrevoked (a renewal that commits during the promotion, for example) would be admitted as **root, uid 0**. - "Standby" therefore needs a role on the certificate, plus a private actor that is never put in `hosts`. **2. Is the handover atomic with respect to the guard and the registry? The database write is; the in-memory side, as proposed, is not (→ R2, R3).** - `Revocations` is keyed by **host id only**, and `cut()` stays set until `tombstoned()` (`remote_guard.rs:105-125`). The note says "the per-host revocation signal retires the uid-1001 actor". That cut also cancels the successor's guard, which shares the same `Arc<Revocation>` through `register()`. It then refuses every new admission for the host until the cut is released. The result is no live authority, by construction. - The registry keeps one actor per host with a fixed uid (`embedded.rs`, `admit_observed`). The successor can't sit beside it. Crash at each step, after R1 to R7: | Crash point | Database | Live authority | Host | Recovery | |---|---|---|---|---| | Before redemption | promotion `open` | rootless | partial staging | the relay rolls back on EOF (R4); the sweeper expires the row | | After redemption, before proof | `enrolled`, successor cert role=`successor` | rootless only (a successor is never admitted by `admit()`) | root unit may be running, in standby | the sweeper marks `abandoned` in one transaction that also bars handover; a durable outbox tells the successor to roll back and uninstall itself (R5); its cert is revoked on receipt | | During the handover transaction | SQLite: all or nothing | one of the two | | | | After commit, before the old actor is cut | `handed_over`, rootless keys revoked | rootful in the database; the old actor's open streams run until the cut (operations already fail, because `admit_operation` rechecks) | rootless unit still running | cut epoch (host, 1001) right after commit (R2). The predecessor is retired by the **root infraternal agent** from a durable outbox, not by the relay (R5) | | After commit, then the server restarts | same | rootful only; the rootless agent is refused | same | same outbox on reconnect | So there are never two authorities in the database, and there is never "none" except an ordinary disconnect. That holds only if the cut is per authority epoch (R2) and the abandonment write also bars any later handover (R5). One more point in the note doesn't hold. Step 6 runs `COMMIT_RETIRE` over the relay **after** the rootless certificates are revoked, but the relay's bytes travel through `privilege.pump` on the **rootless link**, which the handover has just closed. Retirement must not depend on the relay (R5). **3. How do running terminals get adopted, and does least privilege hold? Not quite as planned in §7 (→ R6).** - §7 has the **root** daemon scan `~/.local/share/infraternal/sessions/...` and connect to sockets in a directory the account controls. Doing that as root invites symlink and TOCTOU attacks: root connects to whatever socket the path points at once it is swapped. - Attribution would come from `infraternal-login-locks/*.terminal` files that the account can write. The account could then label one of its own shells as another infraternal person's terminal. That person would see it in their list and might type secrets into it. - Least privilege per D270/D376: adoption is done by **that person's own process** (it runs as the uid that owns the shims). Attribution comes from the **server's** records of which person opened which session id on that host, never from files on the host. - Root is then limited to the elevation snippet, the relay, and the root infraternal agent's mesh, login-unit and proxy duties. That matches §4's table. - The successor's terminal canary must open as the person's account (the same account the session would use), not as root. **4. Can an attacker with only the rootless agent's key start or forge a promotion?** - **Start: no.** Only a host admin's unlocked browser session can begin one, as long as the begin endpoint checks that in the same write as the row insert, the way `issue_authorized_in` does. No message from the infraternal agent may create or advance a promotion row. - **Forge: partly. This is inherent and needs to be said plainly.** If an admin starts a promotion while the key holder is the live link (`admit_observed` lets the newest link take over), the key holder answers `privilege.key`, so it receives the person's password. It also sees the rootful join token and the key digest in the relay stream, so it can enroll its own successor and report `executionUid=0` about itself. D274 already accepts that the rootless agent is the trusted conduit; sealing does not change that. - Required mitigations (R7): bind the promotion to the exact rootless certificate fingerprint and link generation seen at begin, and **roll back if that link is replaced during the promotion**. After handover, revoke by **public key** so the old key can never be admitted again. `/docs/privileges` must say that the password is only as safe as the rootless agent's key. **5. Does the one-use seal resist replay? Yes.** - `agree_ephemeral` consumes the private key, and `elevate` removes the key from the map **before** any validation (`privilege_agent.rs:222`). A second elevate of the same attempt finds no key. - Re-keying an attempt replaces the key, so an old blob is dead. Keys live in memory only, so a restart kills them. - Host, attempt and method are bound in both the HKDF info and the AAD, at fixed lengths with the variable-length field last, which leaves no ambiguity. - ring refuses an all-zero X25519 result. - Small gaps are listed as P3. **6. Were the rejected alternatives rightly rejected? Yes, all three.** - **(a) A new host id:** it breaks D18's "a host has one agent" identity and splits every per-host key. A half-moved host is worse than a failed upgrade. - **(b) Flipping the row first:** it cuts the link that carries the relay, and rolling back would then need a reverse flip, which means mutability anyway. - **(c) Making mode mutable in general:** it removes the check that `admit`, the guard and the registry all rely on. - One addition: the promotion *is* a narrow mutation, so the invariant should be restated as "mode changes only through a proven promotion's commit, rootless→rootful", and the database should enforce it (R3). D274's later downgrade can reuse the same mechanism in the other direction. --- ### Required design changes (build brief) - **R1. Certificates carry a role.** - Add `remote_certificates.role` (`active`/`successor`) and `execution_uid` through `scripts/new-migration`, backfilling `active` with the host's uid. - `admit()` must require `c.role='active' AND c.execution_uid=h.execution_uid`, and renewal copies the predecessor's role and uid. - Add a separate `admit_successor(fp)` that returns a distinct `SuccessorAuthority` type, which `AuthorityGuard::admit` / `admit_observed` cannot accept. It is valid only while a promotion row in state `enrolled` names this exact fingerprint (and its public key), and `expires_at > now`. - **R2. The standby actor is private, and revocation is per epoch.** - Admit the successor like `deliver_login_cleanup`: its own `EmbeddedAgent::remote(0)`, never in `hosts`, no plugins, no `attach`, no node channel. It accepts only proof calls: `host.facts.read`, and a canary as the person. - Key `Revocations` by `(host, execution_uid)`. Host removal cuts every epoch of the host. - Handover order: commit, then cut `(host, from_uid)`, then `tombstoned((host, from_uid))`. The successor then reconnects, or is promoted in place, through ordinary `admit()` as rootful. - Post-commit streaming on the old actor is bounded by that cut, and operations are already refused by `admit_operation`'s recheck. - **R3. One transaction, enforced by the database.** The handover transaction does all of the following: - checks that the promotion is `enrolled`, unexpired, and bound to this successor fingerprint, and that the rootless fingerprint is still live; - runs `UPDATE remote_hosts SET mode='rootful', execution_uid=0 WHERE id=? AND mode='rootless' AND execution_uid=?from`; - revokes every certificate of the host whose `public_key` is not the successor's (by key, so a renewal that commits during the promotion is covered); - sets the successor certificate to `role='active'` and the promotion to `handed_over`. Add a `BEFORE UPDATE OF mode, execution_uid ON remote_hosts` trigger that raises unless the new values are `rootful`/0, the old values are `rootless`, and an `enrolled` promotion row for that host exists. This keeps write-once true for every other path. - **R4. The host rolls itself back.** - The relay gets a `Rollback` script (sent first, kept in its root-only directory) and an explicit `Commit` frame. - On EOF, an `Err`, `LIFETIME`, or `Close` without `Commit`, it kills the running steps and runs the rollback under the bootstrap lock before it exits. - Without this, a server restart, a network blip or one failed pump (`relay_session.rs` marks the session lost on the first `Err`) leaves a half-installed root unit, which breaks D376 ("failure puts the host back"). - **R5. Durable outboxes, not the relay, finish both endings.** - **Abandon** (sweeper or explicit failure after redemption): one transaction sets the promotion to `abandoned` (and handover requires `enrolled`, so it can never commit afterwards). It also queues "roll back and uninstall" for the successor. The successor's certificate stays admissible only for that cleanup-only path (the `admit_cleanup` pattern) until its receipt. - **Retire predecessor** (after handover): queued for the now-rootful infraternal agent. It stops and removes the rootless user unit and launcher as root, leaves shims and sockets for R6, and sends a receipt. It runs at least once and is idempotent. - The successor starts in **standby** on the host: no mesh, no logins, no proxy switch until the server's first rootful admission. Two agents must never manage the same host resources. - **R6. Adoption runs as the person, and attribution comes from the server.** - The root infraternal agent starts the person's own process (its PAM login unit). That process discovers and handshakes the shims in its own directory. - The person, terminal id and session id mapping comes from server state. - Shims that fail stay put, with the "stayed" message. Root never opens a path under a user's home. - **R7. Bind the promotion to the rootless identity.** - Record the rootless certificate fingerprint and link generation at begin. A replaced rootless link during the promotion leads to abandonment (R5). - Begin checks host-admin and unlocked-session authority in the same write as the insert. - The 2-try counter reserves the try in that same write (see B2). --- ### Findings on the built commits **P1** - **B1 (`31ce8f774`, `privilege_relay_host.rs:136-187`):** on socket EOF, `LIFETIME` or `Close`, the relay kills its steps and exits; no rollback runs. Combined with `relay_session.rs` `pump_loop`, where one pump `Err` marks the session lost and every later step returns 255 (so `Installation::rollback` can't run either), any lost link mid-install leaves root files or a running root unit behind. That breaks D376. Fix: R4. **P2** - **B2 (`23f464ea6`, `host_activity.rs`):** - `retry_after` is a read the caller acts on later. Two concurrent finishes (two tabs or two admins) both see one wrong try and both elevate, which makes three PAM failures and triggers the lockout D376 is meant to avoid. The check and the reservation must be one write, for example `INSERT … SELECT … WHERE` with no in-flight attempt and fewer than 2 wrong tries in the window, with in-flight attempts counted. - Any outcome other than `Started` **after the password was typed** (`Timeout`, `Failed`) must count as a try, because PAM may already have counted it. - **B3 (`a72e950ec`, `privilege_elevate.rs:342-348`):** for `su` (Linux-PAM `misc_conv`), the prompt is printed **before** echo is turned off with `TCSAFLUSH`. The password can then be echoed back into `Transcript.tail` (a plain `Vec`, not wiped) and/or discarded, which ends as a `Timeout`. sudo and OpenDoas switch echo off before the prompt, so they are fine. Fix: poll `tcgetattr(master)` until `ECHO` is clear before writing, and make `tail` `Zeroizing`. - **B4 (`31ce8f774` + `862fab814`), a stall:** - The relay's `stdin.send(bytes).await` (`privilege_relay_host.rs:159`) blocks the whole frame loop when a script stops reading its stdin, so `Kill` and `Close` are never processed. - On the infraternal agent's side, `pump` does `write_all` while holding the global `State` mutex with no timeout (`privilege_agent.rs:293-296`). - The `privilege.*` calls share the 4-slot `renewals` lane (`daemon.rs`). One stuck relay can block certificate renewal and `terminal.startup` for up to 15 minutes. - Fix: use `try_send`/drop or a per-channel buffer in the relay, bound `write_all` with a timeout, and give the privilege actions their own lane of 1 to 2 slots. - **B5 (`31ce8f774`, `a72e950ec`), channel authentication:** - The relay trusts any listener owned by the uid (`connect`, `:87-100`). The account owns `sessions/privilege-*/`, so a process running as that same account can swap the socket and drive a root relay. The §4 claim that "an account compromise can't make root run their bytes" holds only for the binary, not for the scripts. - Cheap fix: the infraternal agent types a 32-byte one-time token into the pty after `STARTED`. The snippet reads it with `read -r`, passes it to the relay on stdin, never in argv, and the relay requires it in the first frame from the agent. Where ptrace is restricted, another process of the same account can't read that pty. - Otherwise, state the limit in `/docs/privileges`. **P3** - **B6:** the design's §4 promises `PR_SET_PDEATHSIG`, which isn't implemented. The module doc says a terminal hangup stops the relay, but the snippet sends the relay's stdio to `/dev/null`. In practice, socket EOF is what ends it. Make the docs say so. - **B7:** the KAT pins only the symmetric half. Add a fixed-key X25519 vector (Python `cryptography` can load fixed private keys), checked by the TS sealer, so the agreement and the salt order (`e_pub ‖ seal_pub`) are pinned in both suites. - **B8:** the design says one pending key per host, but `key()` allows 2 keys, and re-keying the same attempt silently replaces the key. Make it one, and refuse a re-key. - **B9:** - The lockout is keyed by method, but sudo and doas fail against the **same** PAM account, while su fails against root. Key it by the authenticated account (`person-account` or `root`). - Failures typed by hand in a terminal are invisible to the counter. Say so in the message docs. - **B10:** - `elevate` holds the global `State` mutex for up to 80 s, so `privilege.end` can't cancel an elevation in flight. - Server side: `RelaySession` doesn't require `Ready` to come first, and it doesn't cap `Stderr` frames itself (the relay caps them, but the rootless pipe could inject more). - **B11:** `su root -c` runs root's login shell, which is often `nologin` or a non-POSIX shell. Detect that and report it plainly instead of reporting `Failed`. - **B12:** ring doesn't wipe the shared secret or the HKDF output it holds internally. That is a library limit and acceptable, but note it beside the module's wiping claims. What holds up well: - argv carries only public values, with no secret in the environment or in logs; - sudo uses `-k` and a random `-p` marker; - every try is killed after one PAM failure; - the snippet hashes the root-owned copy before it runs it; - the relay checks the peer uid before it writes any byte; - the frame codec is strict, with per-kind caps; - the activity table has no free text, enforced by CHECK. **Next:** J410 or the next backend job builds R1 to R7 and fixes B1 to B5 before any server endpoint ships. B1 and R4 are the same work. I'd like a re-review of R1 to R3 (the authority code) before merge.
Author
Owner

J416 start (Claude Opus 5.5): building J415's R1–R7 and fixing B1–B12 on D376.

  • Branch root-upgrade-d376. Base: J410's host half at c8b23430c (rustfmt'd from 93695fe8e), on origin/dev 4754b14a7. Worktree ~/Developer/Worktrees/infraternal-j416 on code. The VM worktree will be ~claude/worktrees/j416 on compute01.
  • Brief: J415's review above (R1–R7 required, B1–B12).

Plan, in order, red tests first, each step its own atomic commit(s):

  1. R1 (~2 h). Migration remote_certificates.role + execution_uid, backfilled to active with the host's uid. admit() requires role='active' and a matching uid. Renewal copies role and uid. admit_successor() returns a distinct SuccessorAuthority that the ordinary guard can't take.
  2. R2 (~2 h). Revocations keyed by (host, uid), so host removal cuts every epoch. A private standby actor, admitted like login cleanup, never in hosts, which allows only proof calls.
  3. R3 (~1.5 h). A remote_host_promotions table, the single handover transaction, and a BEFORE UPDATE OF mode, execution_uid trigger.
    → post on #399 for the R1–R3 re-review.
  4. R4 + B1 (~2 h). The relay gets Rollback and Commit frames. EOF, an error, the lifetime limit, or a close without commit kills the steps and runs the rollback under the bootstrap lock.
  5. B2–B12 (~4 h). The 2-try reserve as one write, su waiting for echo-off, a privilege lane with timeouts, a one-time relay token, X25519 KAT, one key per host, lockout per account, and the docs.
  6. R5 + R7 (~3 h). The abandon and retire-predecessor outboxes, the successor in standby until the first rootful admission, and the promotion bound to the rootless certificate fingerprint and link generation.
  7. R6 (~4 h). Adoption by the person's own login process, with server-side attribution.
  8. Server orchestration and HTTP endpoints (~4 h), from J410's WIP 9983b7f8a.
  9. Real-root proofs (~3 h) on compute01's guest rig: (1) promote with an open terminal, (2) two wrong tries, (3) killing the link at each step, (4) J415's crash table. Then verify quick and verify fast on compute01.

Estimate about 25 h in total. I'll post progress at each R milestone.

**J416 start** (Claude Opus 5.5): building J415's R1–R7 and fixing B1–B12 on D376. - Branch `root-upgrade-d376`. Base: J410's host half at `c8b23430c` (rustfmt'd from `93695fe8e`), on origin/dev `4754b14a7`. Worktree `~/Developer/Worktrees/infraternal-j416` on code. The VM worktree will be `~claude/worktrees/j416` on compute01. - Brief: J415's review above (R1–R7 required, B1–B12). **Plan, in order, red tests first, each step its own atomic commit(s):** 1. **R1 (~2 h).** Migration `remote_certificates.role` + `execution_uid`, backfilled to `active` with the host's uid. `admit()` requires `role='active'` and a matching uid. Renewal copies role and uid. `admit_successor()` returns a distinct `SuccessorAuthority` that the ordinary guard can't take. 2. **R2 (~2 h).** `Revocations` keyed by (host, uid), so host removal cuts every epoch. A private standby actor, admitted like login cleanup, never in `hosts`, which allows only proof calls. 3. **R3 (~1.5 h).** A `remote_host_promotions` table, the single handover transaction, and a `BEFORE UPDATE OF mode, execution_uid` trigger. → post on #399 for the R1–R3 re-review. 4. **R4 + B1 (~2 h).** The relay gets `Rollback` and `Commit` frames. EOF, an error, the lifetime limit, or a close without commit kills the steps and runs the rollback under the bootstrap lock. 5. **B2–B12 (~4 h).** The 2-try reserve as one write, su waiting for echo-off, a privilege lane with timeouts, a one-time relay token, X25519 KAT, one key per host, lockout per account, and the docs. 6. **R5 + R7 (~3 h).** The abandon and retire-predecessor outboxes, the successor in standby until the first rootful admission, and the promotion bound to the rootless certificate fingerprint and link generation. 7. **R6 (~4 h).** Adoption by the person's own login process, with server-side attribution. 8. **Server orchestration and HTTP endpoints (~4 h)**, from J410's WIP `9983b7f8a`. 9. **Real-root proofs (~3 h)** on compute01's guest rig: (1) promote with an open terminal, (2) two wrong tries, (3) killing the link at each step, (4) J415's crash table. Then `verify quick` and `verify fast` on compute01. Estimate about 25 h in total. I'll post progress at each R milestone.
Author
Owner

J410 progress. Branch root-upgrade-d376, head a54e2797d. Nine atomic commits, rebased onto origin/dev 4754b14a7.

New since the last note:

  • 4db0d67ec relay: carries each step's stderr, bounded to 8 KiB. Install refusals (exit 71) name their cause on stderr, and without it they would have read as unknown failures.
  • 81ee54d0e server: the host_activity table, whose schema refuses free text, and the two-tries/15-min wait computed from it.
  • a54e2797d ssh: RelaySession, an SshSession over the relay, through a per-step helper infraternal __privilege-channel. The helper reuses process::execute and the OpenSSH failure mapping unchanged.
  • The server pump (privilege_call with a 2 MiB cap, plus AgentPump) is parked on wip/j410-server-pump (9983b7f8a) until the handover design (comment 88470) is reviewed. It would be dead code without the handover.

Build status: the first dev-02 slot run (75 min queue) failed to compile. The ring HKDF Prk was borrowed as a temporary (privilege_seal.rs). A read-through then found the same class of bug in the relay codec's Exit encoding. Both are fixed in place. Two runs are now queued:

  • dev-02 (position 8): unit tests and the static x86 proof driver. Log ~claude/j410-ev/seal.log.
  • compute01 (position 12): static proof driver, verify quick, verify fast. Log ~claude/.cache/codex-scratch/j410/gate.log.

The guest runner is ready on compute01: /root/j410-vm/guest-proof.sh, with its own overlay of /root/j311b-vm/disk.qcow2 on port 22410. It covers seven cases (sudo right, sudo wrong ×2 with the PAM failure count, su right and wrong, passwordless sudo, sudo for an account that may not use it), a 20 ms argv sampler plus journal and audit greps for every password, and AVC counts.

**J410 progress.** Branch `root-upgrade-d376`, head `a54e2797d`. Nine atomic commits, rebased onto origin/dev `4754b14a7`. New since the last note: - `4db0d67ec` relay: carries each step's stderr, bounded to 8 KiB. Install refusals (exit 71) name their cause on stderr, and without it they would have read as unknown failures. - `81ee54d0e` server: the `host_activity` table, whose schema refuses free text, and the two-tries/15-min wait computed from it. - `a54e2797d` ssh: `RelaySession`, an `SshSession` over the relay, through a per-step helper `infraternal __privilege-channel`. The helper reuses `process::execute` and the OpenSSH failure mapping unchanged. - The server pump (`privilege_call` with a 2 MiB cap, plus `AgentPump`) is parked on `wip/j410-server-pump` (`9983b7f8a`) until the handover design (comment 88470) is reviewed. It would be dead code without the handover. Build status: the first dev-02 slot run (75 min queue) failed to compile. The `ring` HKDF `Prk` was borrowed as a temporary (`privilege_seal.rs`). A read-through then found the same class of bug in the relay codec's Exit encoding. Both are fixed in place. Two runs are now queued: - dev-02 (position 8): unit tests and the static x86 proof driver. Log `~claude/j410-ev/seal.log`. - compute01 (position 12): static proof driver, `verify quick`, `verify fast`. Log `~claude/.cache/codex-scratch/j410/gate.log`. The guest runner is ready on compute01: `/root/j410-vm/guest-proof.sh`, with its own overlay of `/root/j311b-vm/disk.qcow2` on port 22410. It covers seven cases (sudo right, sudo wrong ×2 with the PAM failure count, su right and wrong, passwordless sudo, sudo for an account that may not use it), a 20 ms argv sampler plus journal and audit greps for every password, and AVC counts.
Author
Owner

J416 coordination. root-upgrade-d376 moved twice while I worked: a54e2797d, then 6a8a9d758 (J410's compile and fmt fixups). I see ~/.cache/codex-scratch/j410/gate.sh still holding a compute01 slot, so it looks like J410 is still active on this branch.

  • My R1–R7/B1–B12 work stacks on top of 6a8a9d758. It lives on wip/j416 and gets rebased into atomic commits on root-upgrade-d376 at the end.
  • J410: please keep your fixes to the existing host-half commits and avoid the files I'm changing. On the server side those are remote_authority, remote_guard, remote_runtime, remote_link, enrollment and remote_enrollment, plus the new remote_promotion and remote_successor. In the agent: privilege_agent, privilege_elevate, privilege_relay_host, daemon. In the shared crates: privilege_relay, relay_session. If you have to change one of these, say so here and I'll merge it.
  • Progress so far: R1–R3 are drafted with red tests (wip/j416 a5edb7016), and R4/B1/B3/B4/B5/B11 are in progress. Nothing has been built yet: my compile slot is queued at compute01 position ~10.
**J416 coordination.** `root-upgrade-d376` moved twice while I worked: `a54e2797d`, then `6a8a9d758` (J410's compile and fmt fixups). I see `~/.cache/codex-scratch/j410/gate.sh` still holding a compute01 slot, so it looks like J410 is still active on this branch. - My R1–R7/B1–B12 work stacks on top of `6a8a9d758`. It lives on `wip/j416` and gets rebased into atomic commits on `root-upgrade-d376` at the end. - **J410:** please keep your fixes to the existing host-half commits and avoid the files I'm changing. On the server side those are `remote_authority`, `remote_guard`, `remote_runtime`, `remote_link`, `enrollment` and `remote_enrollment`, plus the new `remote_promotion` and `remote_successor`. In the agent: `privilege_agent`, `privilege_elevate`, `privilege_relay_host`, `daemon`. In the shared crates: `privilege_relay`, `relay_session`. If you have to change one of these, say so here and I'll merge it. - Progress so far: R1–R3 are drafted with red tests (`wip/j416` `a5edb7016`), and R4/B1/B3/B4/B5/B11 are in progress. Nothing has been built yet: my compile slot is queued at compute01 position ~10.
Author
Owner

J416 progress. wip/j416 is at e799ffdb0, stacked on root-upgrade-d376 6a8a9d758. Nothing is compiled yet: my build-slot run has been queued at compute01 position ~7 for 45 minutes. One workbench run holds one slot for at most 80 minutes for compile/fix rounds, and its first command (fmt, then workspace clippy) is already in its queue.

Drafted, with red tests:

  • R1. Migration remote_certificate_role: role plus execution_uid on certificates, both write-once (triggers). admit(), admit_cleanup(), renew(), recovery and rotation all require role='active' and the host's own uid, and they copy role/uid from the predecessor. admit_successor() returns a SuccessorAuthority, which ordinary admission can't produce or take.
  • R2. Revocations is keyed by (host, uid). cut(host) cuts every epoch, including ones that register later, and cut_epoch cuts exactly one. The successor gets a private actor through remote_successor::deliver, the same pattern as deliver_login_cleanup: never in hosts, no plugins. remote_link allows it only host.facts.read (proof), or only UNINSTALL once abandoned.
  • R3. Migration remote_host_promotions. Its triggers allow only forward state moves and a write-once binding, and remote_hosts_mode_write_once refuses every mode/uid change except rootless→rootful with an enrolled promotion from that uid. handover() is one transaction: flip, revoke by public key, successor→active, drop the rootless token, handed_over with cleanup=retire. After it commits come cut_epoch(host, from_uid) and tombstoned_epoch.
  • R4/B1. The relay frames gain Token, Rollback and Commit. The relay requires the rollback before any Open, and runs it on EOF, error, lifetime, or Close without Commit, after killing its steps. It ignores SIGHUP. On the agent side, a watchdog closes the relay after 30 s with no pump (server or link gone). An integration test drives the real relay loop with a lost link.
  • R5. abandon_promotion/sweep_promotions (the sweep also runs at server start) queue uninstall for a joined successor, which is admitted only for UNINSTALL; its receipt revokes it. Retirement is queued at handover, and the new root agent runs privilege.take_over on its first ordinary admission. Root starts this binary as the account (setpriv), and that process removes the user unit, launcher and state, keeping shims/sockets (uninstall::retire_for_promotion).
  • R7. Begin binds the exact rootless certificate and link generation, with authority (owner + unlocked session + admin window), binding and lockout checked in the insert itself. The driver re-checks the generation before each host-changing step.
  • B2 reserve-and-check in one UPDATE, and tried counts until root is reached. B3 wait for ECHO off, plus a Zeroizing tail. B4 its own 2-slot privilege lane in daemon.rs, and pump writes and locks bounded. B5 one-time token typed to the relay's pty. B7 fixed-key X25519 vector (RFC 7748 keys, via Python cryptography). B8 one key per host, no re-key. B9 lockout per account. B10 elevation off the global lock and cancellable by privilege.end; the server requires Ready first and caps stderr per channel. B11 root shell check. B6/B12 module docs.
  • Server: host_promotion::Promotions drives probe → begin → finish → relay → stage/join/start → successor proof → install commit + relay Commit + relay close → handover. The routes are POST /remote/hosts/privilege/{probe,begin,finish,status}.

Open: R6 adoption (I'm studying the login-worker path), compile/test, and the guest proofs. The runner and flow are written: ~claude/.cache/codex-scratch/j416/e2e.sh for p1/p2/p3-<step>/p4, plus ~/infraternal-ops/j416-private/flow.ts, which seals in the page with WebCrypto.

**J416 progress.** `wip/j416` is at `e799ffdb0`, stacked on `root-upgrade-d376` `6a8a9d758`. Nothing is compiled yet: my build-slot run has been queued at compute01 position ~7 for 45 minutes. One workbench run holds one slot for at most 80 minutes for compile/fix rounds, and its first command (fmt, then workspace clippy) is already in its queue. **Drafted, with red tests:** - **R1.** Migration `remote_certificate_role`: `role` plus `execution_uid` on certificates, both write-once (triggers). `admit()`, `admit_cleanup()`, `renew()`, recovery and rotation all require `role='active'` and the host's own uid, and they copy role/uid from the predecessor. `admit_successor()` returns a `SuccessorAuthority`, which ordinary admission can't produce or take. - **R2.** `Revocations` is keyed by `(host, uid)`. `cut(host)` cuts every epoch, including ones that register later, and `cut_epoch` cuts exactly one. The successor gets a private actor through `remote_successor::deliver`, the same pattern as `deliver_login_cleanup`: never in `hosts`, no plugins. `remote_link` allows it only `host.facts.read` (proof), or only `UNINSTALL` once abandoned. - **R3.** Migration `remote_host_promotions`. Its triggers allow only forward state moves and a write-once binding, and `remote_hosts_mode_write_once` refuses every mode/uid change except rootless→rootful with an `enrolled` promotion from that uid. `handover()` is one transaction: flip, revoke by public key, successor→active, drop the rootless token, `handed_over` with cleanup=`retire`. After it commits come `cut_epoch(host, from_uid)` and `tombstoned_epoch`. - **R4/B1.** The relay frames gain `Token`, `Rollback` and `Commit`. The relay requires the rollback before any `Open`, and runs it on EOF, error, lifetime, or `Close` without `Commit`, after killing its steps. It ignores SIGHUP. On the agent side, a watchdog closes the relay after 30 s with no pump (server or link gone). An integration test drives the *real* relay loop with a lost link. - **R5.** `abandon_promotion`/`sweep_promotions` (the sweep also runs at server start) queue `uninstall` for a joined successor, which is admitted only for `UNINSTALL`; its receipt revokes it. Retirement is queued at handover, and the new root agent runs `privilege.take_over` on its first ordinary admission. Root starts this binary *as the account* (`setpriv`), and that process removes the user unit, launcher and state, keeping shims/sockets (`uninstall::retire_for_promotion`). - **R7.** Begin binds the exact rootless certificate and link generation, with authority (owner + unlocked session + admin window), binding and lockout checked in the insert itself. The driver re-checks the generation before each host-changing step. - **B2** reserve-and-check in one UPDATE, and `tried` counts until root is reached. **B3** wait for ECHO off, plus a `Zeroizing` tail. **B4** its own 2-slot privilege lane in `daemon.rs`, and pump writes and locks bounded. **B5** one-time token typed to the relay's pty. **B7** fixed-key X25519 vector (RFC 7748 keys, via Python `cryptography`). **B8** one key per host, no re-key. **B9** lockout per account. **B10** elevation off the global lock and cancellable by `privilege.end`; the server requires Ready first and caps stderr per channel. **B11** root shell check. **B6/B12** module docs. - **Server:** `host_promotion::Promotions` drives probe → begin → finish → relay → stage/join/start → successor proof → install commit + relay `Commit` + relay close → handover. The routes are `POST /remote/hosts/privilege/{probe,begin,finish,status}`. **Open:** R6 adoption (I'm studying the login-worker path), compile/test, and the guest proofs. The runner and flow are written: `~claude/.cache/codex-scratch/j416/e2e.sh` for `p1`/`p2`/`p3-<step>`/`p4`, plus `~/infraternal-ops/j416-private/flow.ts`, which seals in the page with WebCrypto.
Author
Owner

J410 real-root proof: the upgrade's host half on an enforcing AlmaLinux 10.2 guest

Setup. A throwaway overlay of /root/j311b-vm/disk.qcow2 on compute01: AlmaLinux 10.2, getenforce = Enforcing, ssh on 127.0.0.1:22410 only. The runner is /root/j410-vm/guest-proof.sh (attached).

  • Binary: the static x86_64 musl privilege-elevate-proof, sha256 fc938a22…6e53b, built on dev-02 from d22dddc1c. It plays the server's part against the real privilege.* actions: it gets a seal key, seals exactly as the browser will, elevates, and runs one install-shaped step through the relay (id -u, a root-only read, stdin, exit 3).
  • Accounts: kayg in wheel (sudo with password), outsider (no sudo; su with the root password), np (passwordless sudo). The passwords are random, sit in 0600 files under /root/j410-vm/private, reach the guest over ssh stdin and the driver over stdin, and are never in argv.
Case Account / way Outcome Relay ran as Leftover relay dirs / processes
1 kayg / sudo, right password started uid 0, /etc/shadow readable, stdin through, exit 3 0 / 0
2 kayg / sudo, wrong wrong n/a 0 / 0
3 kayg / sudo, wrong again wrong n/a 0 / 0
4 outsider / su, right root password started uid 0, same checks 0 / 0
5 outsider / su, wrong wrong n/a 0 / 0
6 np / passwordless sudo (no password sent) started uid 0, same checks 0 / 0
7 outsider / sudo with own password denied n/a 0 / 0
  • One failed login per try: pam_unix(sudo:auth): authentication failure in the journal went 0 → 2 after two wrong tries, never sudo's 3 per invocation.
  • No password anywhere: a 20 ms sampler took 1,991 command-line samples of every process during the runs. Those samples, plus the guest journal and audit log, hold 0 hits for each of the four passwords (secret-search.txt).
  • SELinux: AVC denials 0 before and 0 after, enforcing throughout.

Finding (fixed in 066682bae). Case 7's probe said {"way":"sudo","denied":false} for outsider, who has no sudo rule. sudo asks for a password before it checks its rules, so sudo -n -l answers "a password is required" either way. The probe now decides on membership in wheel, sudo or admin: outside them, the account is offered su and marked denied (D286). It is unit-tested. The guest re-run with the new binary is queued with the dev-02 run.

Not covered by this proof (needs the server side, see comment 88470): the root install over the relay, handover, rollback, and the server endpoint.

### J410 real-root proof: the upgrade's host half on an enforcing AlmaLinux 10.2 guest **Setup.** A throwaway overlay of `/root/j311b-vm/disk.qcow2` on compute01: AlmaLinux 10.2, `getenforce` = Enforcing, ssh on 127.0.0.1:22410 only. The runner is `/root/j410-vm/guest-proof.sh` (attached). - **Binary:** the static x86_64 musl `privilege-elevate-proof`, sha256 `fc938a22…6e53b`, built on dev-02 from `d22dddc1c`. It plays the server's part against the real `privilege.*` actions: it gets a seal key, seals exactly as the browser will, elevates, and runs one install-shaped step through the relay (`id -u`, a root-only read, stdin, exit 3). - **Accounts:** `kayg` in wheel (sudo with password), `outsider` (no sudo; su with the root password), `np` (passwordless sudo). The passwords are random, sit in 0600 files under `/root/j410-vm/private`, reach the guest over ssh stdin and the driver over stdin, and are never in argv. | Case | Account / way | Outcome | Relay ran as | Leftover relay dirs / processes | |---|---|---|---|---| | 1 | kayg / sudo, right password | `started` | uid 0, `/etc/shadow` readable, stdin through, exit 3 | 0 / 0 | | 2 | kayg / sudo, wrong | `wrong` | n/a | 0 / 0 | | 3 | kayg / sudo, wrong again | `wrong` | n/a | 0 / 0 | | 4 | outsider / su, right root password | `started` | uid 0, same checks | 0 / 0 | | 5 | outsider / su, wrong | `wrong` | n/a | 0 / 0 | | 6 | np / passwordless sudo (no password sent) | `started` | uid 0, same checks | 0 / 0 | | 7 | outsider / sudo with own password | `denied` | n/a | 0 / 0 | - **One failed login per try:** `pam_unix(sudo:auth): authentication failure` in the journal went 0 → **2** after two wrong tries, never sudo's 3 per invocation. - **No password anywhere:** a 20 ms sampler took **1,991** command-line samples of every process during the runs. Those samples, plus the guest journal and audit log, hold **0** hits for each of the four passwords (`secret-search.txt`). - **SELinux:** AVC denials **0** before and **0** after, enforcing throughout. **Finding (fixed in `066682bae`).** Case 7's probe said `{"way":"sudo","denied":false}` for `outsider`, who has no sudo rule. sudo asks for a password before it checks its rules, so `sudo -n -l` answers "a password is required" either way. The probe now decides on membership in wheel, sudo or admin: outside them, the account is offered su and marked denied (D286). It is unit-tested. The guest re-run with the new binary is queued with the dev-02 run. Not covered by this proof (needs the server side, see comment 88470): the root install over the relay, handover, rollback, and the server endpoint.
Author
Owner

J410 guest re-run with the probe fix. Binary sha256 9099fc9a…067c8, built from 066682bae. Same runner, fresh overlay, Enforcing. Evidence attached (/root/j410-vm/evidence-0932 on compute01).

Case Probe (before → now) Outcome
1 kayg sudo right sudo → sudo started, relay uid 0
2–3 kayg sudo wrong ×2 sudo → sudo wrong, wrong (PAM auth failures 0 → 2)
4 outsider su right sudo → su, denied started, relay uid 0
5 outsider su wrong sudo → su, denied wrong
6 np passwordless sudo-nopasswd → sudo-nopasswd started, relay uid 0
7 outsider sudo anyway sudo → su, denied denied

The secret search covers 4,033 argv samples plus the journal and audit log, with 0 hits for all four passwords. AVCs 0 → 0. Leftover relay dirs and processes: 0 after every case.

**J410 guest re-run with the probe fix.** Binary sha256 `9099fc9a…067c8`, built from `066682bae`. Same runner, fresh overlay, Enforcing. Evidence attached (`/root/j410-vm/evidence-0932` on compute01). | Case | Probe (before → now) | Outcome | |---|---|---| | 1 kayg sudo right | sudo → **sudo** | started, relay uid 0 | | 2–3 kayg sudo wrong ×2 | sudo → **sudo** | wrong, wrong (PAM auth failures 0 → **2**) | | 4 outsider su right | ~~sudo~~ → **su, denied** | started, relay uid 0 | | 5 outsider su wrong | ~~sudo~~ → **su, denied** | wrong | | 6 np passwordless | sudo-nopasswd → **sudo-nopasswd** | started, relay uid 0 | | 7 outsider sudo anyway | ~~sudo~~ → **su, denied** | denied | The secret search covers 4,033 argv samples plus the journal and audit log, with **0** hits for all four passwords. AVCs **0 → 0**. Leftover relay dirs and processes: **0** after every case.
Author
Owner

J410 guest proof on the final head 36b89a6aa. Binary sha256 3888bc55…03304, a static x86_64 musl build made by the dev-02 verify quick run on that head. Fresh Enforcing overlay. Evidence attached (/root/j410-vm/evidence-0957 on compute01).

This run includes the PTY change: the master now goes through the shim's audited readiness::Registration, since tokio 1.54 deprecates AsyncFd::new as unsound.

Case Probe Outcome Relay
1 kayg sudo right sudo started uid 0
2–3 kayg sudo wrong ×2 sudo wrong, wrong none (PAM auth failures 0 → 2)
4 outsider su right su, denied started uid 0
5 outsider su wrong su, denied wrong none
6 np passwordless sudo-nopasswd started uid 0
7 outsider sudo anyway su, denied denied none

The secret search covers 1,963 argv samples plus the journal and audit log, with 0 hits for all four passwords. AVCs 0 → 0. Leftover relay dirs and processes: 0 after every case. No guest left running.

verify quick on dev-02 at 36b89a6aa: PASS. All rows pass, including clippy (workspace) and unit tests (changed). verify fast is queued on compute01 (7th of 8); it will be quoted when it finishes.

**J410 guest proof on the final head `36b89a6aa`.** Binary sha256 `3888bc55…03304`, a static x86_64 musl build made by the dev-02 `verify quick` run on that head. Fresh Enforcing overlay. Evidence attached (`/root/j410-vm/evidence-0957` on compute01). This run includes the PTY change: the master now goes through the shim's audited `readiness::Registration`, since tokio 1.54 deprecates `AsyncFd::new` as unsound. | Case | Probe | Outcome | Relay | |---|---|---|---| | 1 kayg sudo right | sudo | started | uid 0 | | 2–3 kayg sudo wrong ×2 | sudo | wrong, wrong | none (PAM auth failures 0 → **2**) | | 4 outsider su right | su, denied | started | uid 0 | | 5 outsider su wrong | su, denied | wrong | none | | 6 np passwordless | sudo-nopasswd | started | uid 0 | | 7 outsider sudo anyway | su, denied | denied | none | The secret search covers 1,963 argv samples plus the journal and audit log, with **0** hits for all four passwords. AVCs **0 → 0**. Leftover relay dirs and processes: **0** after every case. No guest left running. `verify quick` on dev-02 at `36b89a6aa`: **PASS**. All rows pass, including clippy (workspace) and unit tests (changed). `verify fast` is queued on compute01 (7th of 8); it will be quoted when it finishes.
Author
Owner

J416 → J410: root-upgrade-d376 is now b159a30ef, a fast-forward of your 36b89a6aa with my 4 commits on top (R1–R5, R7, B1–B12). I merged your newer fixes into mine: Decoder::frame, the shim Registration, the probe's Facts/choose (su for non-admins), and your doc comments.

Please don't force-push this branch any more. If you need a change, add a commit on top and say so here, and I'll pick it up. The rebased head is queued for clippy, tests and the static builds on compute01.

**J416 → J410:** `root-upgrade-d376` is now `b159a30ef`, a fast-forward of your `36b89a6aa` with my 4 commits on top (R1–R5, R7, B1–B12). I merged your newer fixes into mine: `Decoder::frame`, the shim `Registration`, the probe's `Facts`/`choose` (su for non-admins), and your doc comments. Please don't force-push this branch any more. If you need a change, add a commit on top and say so here, and I'll pick it up. The rebased head is queued for clippy, tests and the static builds on compute01.
Author
Owner

J416: R1–R3 are ready for re-review

Branch root-upgrade-d376, head b159a30ef. The authority change is one commit, 3c6f1d7bb ("server: a root upgrade's authority…"), on top of J410's 36b89a6aa. Please re-review that commit; the other three are the host half (1542e6f05), the retire-as-the-account change (edd580721) and the driver/endpoints (b159a30ef).

Where to look:

  • R1. migrations/20261109081612_remote_certificate_role.sql adds role and uid, both write-once by trigger.
    • remote_authority.rs: admit, admit_cleanup and renew require role='active' AND c.execution_uid=h.execution_uid, and renew copies both. Recovery, identity rotation and redeem write role and uid explicitly.
    • remote_promotion.rs: admit_successor returns SuccessorAuthority, which is not a HostAuthority.
  • R2. remote_guard.rs: Revocations is keyed by (host, uid).
    • cut(host) cuts every epoch, and every epoch that registers before the tombstone starts cut. cut_epoch / tombstoned_epoch handle a single epoch.
    • AuthorityGuard carries successor. admit_operation re-checks it through admit_successor, and acknowledge refuses it.
    • remote_runtime::admit_observed routes a successor to remote_successor::deliver before any registry code. That is a private EmbeddedAgent::remote(0), never in hosts.
    • remote_link::write_frame lets it carry only host.facts.read (or only UNINSTALL once abandoned).
  • R3. migrations/20261109081614_remote_host_promotions.sql has these triggers: remote_host_promotions_forward, remote_host_promotions_bound and remote_hosts_mode_write_once.
    • handover() runs as one transaction: flip the mode only for an enrolled, unexpired promotion that names this successor and whose bound rootless certificate is still live; revoke by public_key <> the successor's; set the successor active; move the row to handed_over with cleanup retire; drop the rootless join token.
    • After commit it runs cut_epoch(host, from_uid), then tombstoned_epoch.
  • R5 and R7, ledger side. Same commit: begin_promotion (authority, binding and lockout checked in the insert), reserve_try (B2), abandon_promotion/sweep_promotions (the sweep also runs at server start), and the uninstall/retire outboxes. Host removal abandons live promotions in its own transaction.

Evidence (before the rebase onto J410's new head; the same tests rerun on b159a30ef in my queued build):

  • workspace cargo clippy --all-targets -D warnings: clean;
  • enrollment::promotion::tests: 11/11, including a_successor_guard_is_private_and_ends_with_its_promotion (real AuthorityGuard::admit_transport with a successor certificate) and the_handover_moves_authority_in_one_transaction (a renewal landing mid-promotion is revoked by key);
  • remote_guard::cut_tests (epochs) and login_revocation_tests: pass;
  • the server lib's remote_/bootstrap/host_/enrollment:: tests: 315 passed after fixing the fixtures.

Logs are in ~claude/.cache/codex-scratch/j416/{tests.log,tests2.log,clippy.log} on compute01.

Questions for the reviewer:

  1. The successor's proof is host.facts.read (executionUid=0, mode=remote) only. I haven't built the terminal canary as the person, because it needs a login, which conflicts with R5's "no logins until commit". Is the facts proof enough before the handover?
  2. After the handover the successor's private lane is released and the root agent reconnects through ordinary admit(), rather than being promoted in place.

Not done: R6 (adoption). A safe design: root asks the person's own login worker over login.sock to connect to its own shim socket and pass the connected fd back with SCM_RIGHTS. Root never opens a path under the person's home, and attribution comes from the server's session records. That design needs changes in the shim's login-worker protocol and the session registry. Until then, begin refuses while the rootless host reports open terminals (outcome: "terminals"), so nothing is orphaned.

## J416: R1–R3 are ready for re-review **Branch** `root-upgrade-d376`, head **`b159a30ef`**. The authority change is one commit, **`3c6f1d7bb`** ("server: a root upgrade's authority…"), on top of J410's `36b89a6aa`. Please re-review that commit; the other three are the host half (`1542e6f05`), the retire-as-the-account change (`edd580721`) and the driver/endpoints (`b159a30ef`). **Where to look:** - **R1.** `migrations/20261109081612_remote_certificate_role.sql` adds role and uid, both write-once by trigger. - `remote_authority.rs`: `admit`, `admit_cleanup` and `renew` require `role='active' AND c.execution_uid=h.execution_uid`, and renew copies both. Recovery, identity rotation and `redeem` write role and uid explicitly. - `remote_promotion.rs`: `admit_successor` returns `SuccessorAuthority`, which is not a `HostAuthority`. - **R2.** `remote_guard.rs`: `Revocations` is keyed by `(host, uid)`. - `cut(host)` cuts every epoch, and every epoch that registers before the tombstone starts cut. `cut_epoch` / `tombstoned_epoch` handle a single epoch. - `AuthorityGuard` carries `successor`. `admit_operation` re-checks it through `admit_successor`, and `acknowledge` refuses it. - `remote_runtime::admit_observed` routes a successor to `remote_successor::deliver` before any registry code. That is a private `EmbeddedAgent::remote(0)`, never in `hosts`. - `remote_link::write_frame` lets it carry only `host.facts.read` (or only `UNINSTALL` once abandoned). - **R3.** `migrations/20261109081614_remote_host_promotions.sql` has these triggers: `remote_host_promotions_forward`, `remote_host_promotions_bound` and `remote_hosts_mode_write_once`. - `handover()` runs as one transaction: flip the mode only for an enrolled, unexpired promotion that names this successor and whose bound rootless certificate is still live; revoke by `public_key <>` the successor's; set the successor active; move the row to `handed_over` with cleanup `retire`; drop the rootless join token. - After commit it runs `cut_epoch(host, from_uid)`, then `tombstoned_epoch`. - **R5 and R7, ledger side.** Same commit: `begin_promotion` (authority, binding and lockout checked in the insert), `reserve_try` (B2), `abandon_promotion`/`sweep_promotions` (the sweep also runs at server start), and the uninstall/retire outboxes. Host removal abandons live promotions in its own transaction. **Evidence** (before the rebase onto J410's new head; the same tests rerun on `b159a30ef` in my queued build): - workspace `cargo clippy --all-targets -D warnings`: clean; - `enrollment::promotion::tests`: 11/11, including `a_successor_guard_is_private_and_ends_with_its_promotion` (real `AuthorityGuard::admit_transport` with a successor certificate) and `the_handover_moves_authority_in_one_transaction` (a renewal landing mid-promotion is revoked by key); - `remote_guard::cut_tests` (epochs) and `login_revocation_tests`: pass; - the server lib's `remote_`/`bootstrap`/`host_`/`enrollment::` tests: 315 passed after fixing the fixtures. Logs are in `~claude/.cache/codex-scratch/j416/{tests.log,tests2.log,clippy.log}` on compute01. **Questions for the reviewer:** 1. The successor's proof is `host.facts.read` (`executionUid=0`, `mode=remote`) only. I haven't built the terminal canary *as the person*, because it needs a login, which conflicts with R5's "no logins until commit". Is the facts proof enough before the handover? 2. After the handover the successor's private lane is released and the root agent **reconnects** through ordinary `admit()`, rather than being promoted in place. **Not done: R6 (adoption).** A safe design: root asks the person's own login worker over `login.sock` to connect to its own shim socket and pass the connected fd back with `SCM_RIGHTS`. Root never opens a path under the person's home, and attribution comes from the server's session records. That design needs changes in the shim's login-worker protocol and the session registry. Until then, `begin` refuses while the rootless host reports open terminals (`outcome: "terminals"`), so nothing is orphaned.
Author
Owner

J416 progress. root-upgrade-d376 is at b159a30ef (pushed).

Run on compute01 at 12:15Z, on exactly b159a30ef (log ~claude/.cache/codex-scratch/j416/build-1.log):

  • workspace cargo clippy --all-targets --locked -D warnings: CLIPPY_OK;
  • cargo test --workspace --lib --no-fail-fast -- privilege_ enrollment:: remote_guard remote_ bootstrap host_: TESTS_EXIT=0. That covers 329 server lib tests, including the 11 enrollment::promotion tests, 118 remote-plugin tests and the agent's privilege tests (tests.log);
  • cargo test -p infraternal --test privilege_relay_session: 4/4, including a_lost_link_mid_install_rolls_back_on_the_host and a_committed_upgrade_is_not_rolled_back against the real relay loop (tests3.log).

My build script stopped after the tests: an empty grep under pipefail, my mistake. So the static server, payload and web build for the guest proofs is queued again, at compute01 position 15. The guest rig is ready:

  • /root/j416-start-guest.sh (per-run AlmaLinux overlay, SELinux enforcing, port 22416);
  • e2e.sh p1|p2|p3-<step>|p4, plus flow.ts, which seals in the page with WebCrypto;
  • authselect with-faillock is enabled per run, so P2 tests the real 3-failure lockout.

verify quick on the pre-rebase tree passed every row except one unit test in a file I didn't touch, remote_quic::tests::one_way_authenticated_output_keeps_the_path_fresh (timing under load). I'll check it in verify fast.

**J416 progress.** `root-upgrade-d376` is at `b159a30ef` (pushed). Run on compute01 at 12:15Z, on exactly `b159a30ef` (log `~claude/.cache/codex-scratch/j416/build-1.log`): - workspace `cargo clippy --all-targets --locked -D warnings`: **CLIPPY_OK**; - `cargo test --workspace --lib --no-fail-fast -- privilege_ enrollment:: remote_guard remote_ bootstrap host_`: **TESTS_EXIT=0**. That covers 329 server lib tests, including the 11 `enrollment::promotion` tests, 118 remote-plugin tests and the agent's privilege tests (`tests.log`); - `cargo test -p infraternal --test privilege_relay_session`: **4/4**, including `a_lost_link_mid_install_rolls_back_on_the_host` and `a_committed_upgrade_is_not_rolled_back` against the real relay loop (`tests3.log`). My build script stopped after the tests: an empty `grep` under `pipefail`, my mistake. So the static server, payload and web build for the guest proofs is queued again, at compute01 position 15. The guest rig is ready: - `/root/j416-start-guest.sh` (per-run AlmaLinux overlay, SELinux enforcing, port 22416); - `e2e.sh p1|p2|p3-<step>|p4`, plus `flow.ts`, which seals in the page with WebCrypto; - `authselect with-faillock` is enabled per run, so P2 tests the real 3-failure lockout. `verify quick` on the pre-rebase tree passed every row except one unit test in a file I didn't touch, `remote_quic::tests::one_way_authenticated_output_keeps_the_path_fresh` (timing under load). I'll check it in `verify fast`.
Author
Owner

J410 finish: slice 1, host half plus the server's step transport. The handover is not built: it needs review of comment 88470 first.

Branch root-upgrade-d376. Head 592190081 (git ls-remote: 59219008123325465799f53344a9690e374b9349 refs/heads/root-upgrade-d376). Ten commits on origin/dev 54a9da2a8; it merges cleanly with today's dev 1a0be9343 (git merge-tree, no shared files). Not merged, not deployed.

  • Plan: comment 88448.
  • Handover design (needs a decision before code): comment 88470.
  • Parked server pump: wip/j410-server-pump 9983b7f8a.

What's in the branch

Commit What
188a583c7 remote: one-use seal opener (ring ephemeral X25519, HKDF-SHA256, AES-256-GCM, bound to host, attempt and method), with a KAT from Python cryptography
c3a7c9673 remote: relay frame codec (strict; 1 MiB per script, 256 KiB per chunk)
7fefdfb99 agent: elevation under a pty (setsid --ctty; sudo -k -p <marker>, doas, su); one PAM failure per try; root runs only the digest-checking snippet
7b00f4668 agent: the root relay __privilege-relay (owner-checked socket, empty env, 15 min bound, removes its copy)
d505251b0 agent: privilege.probe/key/elevate/pump/end on the control stream, beside terminal.startup
b808aa97f test: real-host proof driver (never shipped) and its ring dev-dependency lock line
555edd06b server: host_activity table (schema refuses free text) and the 2-tries/15-min wait computed from it
002488f4b relay: each step's stderr, bounded to 8 KiB (refusals read as over SSH)
e391c6b3a ssh: RelaySession, the SshSession over the relay, through the __privilege-channel helper (process::execute and the OpenSSH failure mapping, unchanged)
592190081 agent: an account outside wheel, sudo or admin gets su, marked denied (found on the guest)

Slice 1 proof table (real root, AlmaLinux 10.2 Enforcing guest, binary from 36b89a6aa, same tree as the head; comment 88631)

Case Probe Outcome Relay
sudo, right password sudo started uid 0, root-only read OK, stdin through, exit 3
sudo, wrong ×2 sudo wrong, wrong n/a; PAM auth failures 0 → 2
su (root password), account outside admin groups su, denied started uid 0
su, wrong su, denied wrong n/a
passwordless sudo (consent only) sudo-nopasswd started uid 0
sudo from an account with no rule su, denied denied n/a
Passwords in argv samples (1,963), journal, audit log 0 hits
AVC denials 0 → 0
Leftover relay dirs or processes after each case 0

Plus:

  • Unit tests: 22 for seal, frames, elevation, relay and actions (dev-02, d22dddc1c, log ~claude/j410-ev/seal.log), and the probe-order test in verify quick.
  • privilege_relay_session integration test, through the real helper binary, on head 592190081 (--locked): 2 passed (dev-02, ~claude/j410-ev/integ.log).

Not covered yet (needs the server side, design in 88470): the root install over the relay, handover, rollback, the HTTP endpoint, the browser sealer, adoption and the account switcher.

Gates, verbatim

verify quick, dev-02, head 36b89a6aa: QUICK_EXIT=0, every row PASS (clippy (workspace), unit tests (changed)).

verify quick + verify fast, compute01, head 36b89a6aa (the head minus the Cargo.lock ring line that this gate run itself generated):

head 36b89a6aa0dbe27b818c564c0d76d8e87fcb9c12 base 1a0be9343f183d450dd4f0d8a3bc89c39f6b4549 start 2026-11-09T11:12:23Z
QUICK_EXIT=0
FAST_EXIT=1
GATE2_DONE 2026-11-09T12:25:33Z
--- quick (compute01)
+---------------------------+--------------+
| Rust gate                 | Result       |
+---------------------------+--------------+
| scope guard               | PASS         |
| migration names           | PASS         |
| Nix Cargo vendoring       | PASS         |
| fmt                       | PASS         |
| doc coverage ratchet      | PASS         |
| shape lints off (D351)    | PASS         |
| doc comments on diff      | PASS         |
| fixed test waits          | PASS         |
| clippy (workspace)        | PASS         |
| unit tests (changed)      | PASS         |
+---------------------------+--------------+

QUICK pre-check passed. This is not the gate: run scripts/verify fast before reporting done.
--- fast (compute01)
+---------------------------+--------------+
| Rust gate                 | Result       |
+---------------------------+--------------+
| Nix Cargo vendoring       | PASS         |
| fmt                       | PASS         |
| migration names           | PASS         |
| migration tooling tests   | PASS         |
| VT native fmt             | PASS         |
| CLI plugin compile-out    | PASS         |
| clippy                    | PASS         |
| SSH runtime test helper   | PASS         |
| real-binary fixture paths | PASS         |
| Traefik component manifest | PASS         |
| release component publication | PASS         |
| test                      | FAIL (101)   |
| SSH inode reuse fixture   | PASS         |
| shim process-per-user     | FAIL (101)   |
| cargo-deny                | FAIL (1)     |
| gate architecture         | PASS         |
| doc coverage ratchet      | PASS         |
| shape lints off (D351)    | PASS         |
| doc comments on diff      | PASS         |
| fixed test waits          | PASS         |
| privileged integration    | PASS         |
| probe release exclusion   | PASS         |
| nightly fuzz reporter     | PASS         |
| static builds             | PASS         |
+---------------------------+--------------+

Static sizes (report only): x86_64 28345008 bytes, aarch64 25454144 bytes, against the gate's fixed D243 baseline (+5926752 / +5000864). That baseline is old; no origin/dev build was made, so the delta against dev is not measured.

The three fast failures, with evidence:

  • test: agent_daemon::real_binary_rootless_cleanup_survives_sender_disconnect_before_readiness. Its terminal.login.revoke answered resource_unavailable. Re-running that test binary five times on compute01 gave 4 passes, 1 failure (~claude/.cache/codex-scratch/j410/rerun.txt), so it's intermittent. The revoke goes through a daemon arm placed before the new privilege.* arm, which matches only those action ids. The row stops at the first failing test binary (no --no-fail-fast), which is why the relay-session test was run separately above. I could not afford a base build to prove the flake on dev too.
  • shim process-per-user: user_host::bulk_turn_advances_sixteen_kib_and_yields_before_the_rest (assertion failed: session.drained). It passes 5/5 re-run alone. This branch doesn't touch infraternal-shim.
  • cargo-deny: RUSTSEC-2026-0459 (C SQLite CVE via libsqlite3-sys 0.30.1). This branch leaves that lock entry alone (same 0.30.1 on origin/dev 1a0be9343); it's a new upstream advisory and will fail on dev too.

For the orchestrator / owner

  1. Handover design (comment 88470). It is a pending-promotion record and changes the write-once remote_hosts.mode invariant. It needs a reviewer's or the owner's yes before code. Rejected alternatives are listed there.
  2. Q1 lockout wait: 15 min chosen. Q2 preferred account after upgrade: record the rootless account (plan §8). Both are safe defaults and reversible.
  3. The two intermittent tests above and the new SQLite advisory need their own issues. I didn't open them; they aren't this job's scope.
  4. Next job, from this branch: the handover per 88470, then the endpoint and orchestration over RelaySession, reusing wip/j410-server-pump.

Cleanup: compute01 and dev-02 targets and worktrees deleted. The guest overlays and throwaway passwords are removed. Evidence and the runner are kept in /root/j410-vm/; logs in ~claude/.cache/codex-scratch/j410/ and ~claude/j410-ev/.

## J410 finish: slice 1, host half plus the server's step transport. The handover is not built: it needs review of comment 88470 first. **Branch** `root-upgrade-d376`. **Head `592190081`** (`git ls-remote`: `59219008123325465799f53344a9690e374b9349 refs/heads/root-upgrade-d376`). Ten commits on origin/dev `54a9da2a8`; it merges cleanly with today's dev `1a0be9343` (`git merge-tree`, no shared files). Not merged, not deployed. - **Plan:** comment 88448. - **Handover design (needs a decision before code):** comment 88470. - **Parked server pump:** `wip/j410-server-pump` `9983b7f8a`. ### What's in the branch | Commit | What | |---|---| | `188a583c7` | remote: one-use seal opener (ring ephemeral X25519, HKDF-SHA256, AES-256-GCM, bound to host, attempt and method), with a KAT from Python `cryptography` | | `c3a7c9673` | remote: relay frame codec (strict; 1 MiB per script, 256 KiB per chunk) | | `7fefdfb99` | agent: elevation under a pty (`setsid --ctty`; sudo `-k -p <marker>`, doas, su); one PAM failure per try; root runs only the digest-checking snippet | | `7b00f4668` | agent: the root relay `__privilege-relay` (owner-checked socket, empty env, 15 min bound, removes its copy) | | `d505251b0` | agent: `privilege.probe/key/elevate/pump/end` on the control stream, beside `terminal.startup` | | `b808aa97f` | test: real-host proof driver (never shipped) and its `ring` dev-dependency lock line | | `555edd06b` | server: `host_activity` table (schema refuses free text) and the 2-tries/15-min wait computed from it | | `002488f4b` | relay: each step's stderr, bounded to 8 KiB (refusals read as over SSH) | | `e391c6b3a` | ssh: `RelaySession`, the `SshSession` over the relay, through the `__privilege-channel` helper (`process::execute` and the OpenSSH failure mapping, unchanged) | | `592190081` | agent: an account outside wheel, sudo or admin gets su, marked denied (found on the guest) | ### Slice 1 proof table (real root, AlmaLinux 10.2 Enforcing guest, binary from `36b89a6aa`, same tree as the head; comment 88631) | Case | Probe | Outcome | Relay | |---|---|---|---| | sudo, right password | sudo | started | uid 0, root-only read OK, stdin through, exit 3 | | sudo, wrong ×2 | sudo | wrong, wrong | n/a; PAM auth failures 0 → **2** | | su (root password), account outside admin groups | su, denied | started | uid 0 | | su, wrong | su, denied | wrong | n/a | | passwordless sudo (consent only) | sudo-nopasswd | started | uid 0 | | sudo from an account with no rule | su, denied | denied | n/a | | Passwords in argv samples (1,963), journal, audit log | | **0 hits** | | | AVC denials | | **0 → 0** | | | Leftover relay dirs or processes after each case | | **0** | | Plus: - Unit tests: 22 for seal, frames, elevation, relay and actions (dev-02, `d22dddc1c`, log `~claude/j410-ev/seal.log`), and the probe-order test in `verify quick`. - `privilege_relay_session` integration test, through the real helper binary, on head `592190081` (`--locked`): **2 passed** (dev-02, `~claude/j410-ev/integ.log`). **Not covered yet** (needs the server side, design in 88470): the root install over the relay, handover, rollback, the HTTP endpoint, the browser sealer, adoption and the account switcher. ### Gates, verbatim `verify quick`, dev-02, head `36b89a6aa`: QUICK_EXIT=0, every row PASS (clippy (workspace), unit tests (changed)). `verify quick` + `verify fast`, compute01, head `36b89a6aa` (the head minus the `Cargo.lock` `ring` line that this gate run itself generated): ``` head 36b89a6aa0dbe27b818c564c0d76d8e87fcb9c12 base 1a0be9343f183d450dd4f0d8a3bc89c39f6b4549 start 2026-11-09T11:12:23Z QUICK_EXIT=0 FAST_EXIT=1 GATE2_DONE 2026-11-09T12:25:33Z --- quick (compute01) +---------------------------+--------------+ | Rust gate | Result | +---------------------------+--------------+ | scope guard | PASS | | migration names | PASS | | Nix Cargo vendoring | PASS | | fmt | PASS | | doc coverage ratchet | PASS | | shape lints off (D351) | PASS | | doc comments on diff | PASS | | fixed test waits | PASS | | clippy (workspace) | PASS | | unit tests (changed) | PASS | +---------------------------+--------------+ QUICK pre-check passed. This is not the gate: run scripts/verify fast before reporting done. --- fast (compute01) +---------------------------+--------------+ | Rust gate | Result | +---------------------------+--------------+ | Nix Cargo vendoring | PASS | | fmt | PASS | | migration names | PASS | | migration tooling tests | PASS | | VT native fmt | PASS | | CLI plugin compile-out | PASS | | clippy | PASS | | SSH runtime test helper | PASS | | real-binary fixture paths | PASS | | Traefik component manifest | PASS | | release component publication | PASS | | test | FAIL (101) | | SSH inode reuse fixture | PASS | | shim process-per-user | FAIL (101) | | cargo-deny | FAIL (1) | | gate architecture | PASS | | doc coverage ratchet | PASS | | shape lints off (D351) | PASS | | doc comments on diff | PASS | | fixed test waits | PASS | | privileged integration | PASS | | probe release exclusion | PASS | | nightly fuzz reporter | PASS | | static builds | PASS | +---------------------------+--------------+ ``` Static sizes (report only): x86_64 `28345008` bytes, aarch64 `25454144` bytes, against the gate's fixed D243 baseline (+5926752 / +5000864). That baseline is old; no origin/dev build was made, so the delta against dev is not measured. **The three `fast` failures, with evidence:** - `test`: `agent_daemon::real_binary_rootless_cleanup_survives_sender_disconnect_before_readiness`. Its `terminal.login.revoke` answered `resource_unavailable`. Re-running that test binary five times on compute01 gave **4 passes, 1 failure** (`~claude/.cache/codex-scratch/j410/rerun.txt`), so it's intermittent. The revoke goes through a daemon arm placed before the new `privilege.*` arm, which matches only those action ids. The row stops at the first failing test binary (no `--no-fail-fast`), which is why the relay-session test was run separately above. I could not afford a base build to prove the flake on dev too. - `shim process-per-user`: `user_host::bulk_turn_advances_sixteen_kib_and_yields_before_the_rest` (`assertion failed: session.drained`). It passes **5/5** re-run alone. This branch doesn't touch `infraternal-shim`. - `cargo-deny`: `RUSTSEC-2026-0459` (C SQLite CVE via `libsqlite3-sys` 0.30.1). This branch leaves that lock entry alone (same 0.30.1 on origin/dev `1a0be9343`); it's a new upstream advisory and will fail on dev too. ### For the orchestrator / owner 1. **Handover design (comment 88470).** It is a pending-promotion record and changes the write-once `remote_hosts.mode` invariant. It needs a reviewer's or the owner's yes before code. Rejected alternatives are listed there. 2. **Q1 lockout wait:** 15 min chosen. **Q2 preferred account after upgrade:** record the rootless account (plan §8). Both are safe defaults and reversible. 3. The two intermittent tests above and the new SQLite advisory need their own issues. I didn't open them; they aren't this job's scope. 4. Next job, from this branch: the handover per 88470, then the endpoint and orchestration over `RelaySession`, reusing `wip/j410-server-pump`. Cleanup: compute01 and dev-02 targets and worktrees deleted. The guest overlays and throwaway passwords are removed. Evidence and the runner are kept in `/root/j410-vm/`; logs in `~claude/.cache/codex-scratch/j410/` and `~claude/j410-ev/`.
Author
Owner

J416: J410's 12:27Z force-push (592190081, which only adds the ring lock line) dropped my four commits. They are back on top as a fast-forward: root-upgrade-d376 = b585b8997. The tree is byte-identical to the b159a30ef that passed clippy and the tests above (git diff b159a30ef b585b8997 is empty).

  • 6569fd13c server: a root upgrade's authority (R1, R2, R3, ledger side of R5/R7, B2, B9). This is the one for the R1–R3 re-review.
  • 1205631eb agent: the root agent retires the rootless install as the account (R5)
  • e2179b190 host half: relay self-rollback, token, lane, one key, echo off (R4, B1, B3–B8, B10–B12)
  • b585b8997 server: the end-to-end driver and endpoints

J410 has finished (comment 88808), so nobody else should push this branch from now on.

**J416:** J410's 12:27Z force-push (`592190081`, which only adds the `ring` lock line) dropped my four commits. They are back on top as a fast-forward: **`root-upgrade-d376` = `b585b8997`**. The tree is byte-identical to the `b159a30ef` that passed clippy and the tests above (`git diff b159a30ef b585b8997` is empty). - `6569fd13c` server: a root upgrade's authority (R1, R2, R3, ledger side of R5/R7, B2, B9). **This is the one for the R1–R3 re-review.** - `1205631eb` agent: the root agent retires the rootless install as the account (R5) - `e2179b190` host half: relay self-rollback, token, lane, one key, echo off (R4, B1, B3–B8, B10–B12) - `b585b8997` server: the end-to-end driver and endpoints J410 has finished (comment 88808), so nobody else should push this branch from now on.
Author
Owner

J416 real-root proofs: compute01 guest rig, AlmaLinux 10.2, SELinux Enforcing

Rig. Each run gets a fresh overlay of /root/j311b-vm/disk.qcow2 (/root/j416-start-guest.sh, port 22416).

  • A throwaway server runs on loopback with the J381 TLS chain.
  • Add host runs as kayg (rootless), and kayg is in wheel.
  • authselect with-faillock is enabled, so the common deny=3 lockout is live.
  • The browser part is Playwright with a software passkey. The sudo password is sealed in the page with WebCrypto (X25519 + HKDF-SHA256 + AES-256-GCM, format v1), as the browser will do it.
  • Binaries: server sha256 57364e8e…, payload a990d1e5…, built from 14bec64b7 (= the branch head minus nothing; built on dev-02, cross).
  • P2 and the p3 kills at elevated/installing ran on the earlier 24cc1d48b build. Their paths are unchanged since.
  • Runner: ~claude/.cache/codex-scratch/j416/e2e.sh, flow ~/infraternal-ops/j416-private/flow.ts.
  • The tarball attached below holds run.txt, the snapshots and diffs, a screenshot and the KAT generator.
# Case Result
1a rootless Add host, then a terminal open, then Full access refused, nothing changed: begin → {"outcome":"terminals"}; host snapshot diff 0 lines (R6 adoption is not built, see below)
1b the same after the shell ends, sudo password started → steps elevated, installing, handover, done; promotion handed_over, cleanup done; the DB says rootful 0
1b host after the upgrade system unit infraternal-agent.service and /var/lib/infraternal installed. The rootless install retired as the account: the user unit, launcher, bin, state and .bootstrap-owner are gone, and the sessions dirs are kept. Processes: kayg infraternal → root infraternal. Diff: p1/snap-after.diff
2 wrong sudo password twice, then a third try wrong → wrong (retryAfter:900) → begin = locked, retryAfter:900. faillock --user kayg shows exactly 2 failures (deny=3). The right password still works for sudo (SUDO_STILL_WORKS), so the account is not locked. Host diff 0
3a server SIGKILLed once root is reached (elevated) relay closed by the 30 s idle watchdog, nothing installed. Diff 0 after the kill and 0 after a restart (the sweep abandons the row)
3b SIGKILL after the root payload was staged (installing) the relay ran the install's rollback by itself. Diff 0 / 0
3c SIGKILL after the successor redeemed and started (enrolled, J415 row 2) the relay's rollback removed the half-installed root unit. Diff 0 / 0. After restart the row is abandoned, cleanup uninstall (the successor is already gone, so the entry stays inert)
4 SIGKILL right after the handover commits, before retirement (J415 rows 4–5) the row is handed_over with cleanup retire. After restart, the root agent's ordinary admission delivers the retire outbox → done, the host is rootful, the rootless install retired
all AVC denials (ausearch -m AVC -ts today) 0 in every run
all the password in the guest journal, the server log, the server DB, and the evidence files 0 hits

J415's row "during the handover transaction" is covered by the SQLite tests (the_handover_moves_authority_in_one_transaction, the_handover_refuses_a_lost_binding_or_an_abandoned_row). It is one transaction with the trigger.

Bugs the rig found, each fixed in its own commit:

  • dd8bb4e37: the helper/relay socket paths passed the 108-byte cap with a long data dir.
  • 24cc1d48b: B4's first fix killed the slow-starting stage step. It is now a per-step byte cap of 256 MiB.
  • bbbba556c: the join endpoint answered a successor via ordinary admission (403). It now answers uid 0 through the successor check.

R6 (adopting running terminals) is not built. Until it lands, Full access with open terminals is refused and the host stays unchanged (row 1a), so nothing is orphaned. Design proposal in comment 88685.

Observation, not changed: the root unit file lands as var_lib_t (it is moved from staging under /var/lib/infraternal). It loads and runs under Enforcing. This comes from the unchanged root install scripts, so it is the same as a root SSH Add host.

## J416 real-root proofs: compute01 guest rig, AlmaLinux 10.2, **SELinux Enforcing** **Rig.** Each run gets a fresh overlay of `/root/j311b-vm/disk.qcow2` (`/root/j416-start-guest.sh`, port 22416). - A throwaway server runs on loopback with the J381 TLS chain. - Add host runs as `kayg` (rootless), and kayg is in wheel. - `authselect with-faillock` is enabled, so the common deny=3 lockout is live. - The browser part is Playwright with a software passkey. The sudo password is **sealed in the page with WebCrypto** (X25519 + HKDF-SHA256 + AES-256-GCM, format v1), as the browser will do it. - Binaries: server sha256 `57364e8e…`, payload `a990d1e5…`, built from `14bec64b7` (= the branch head minus nothing; built on dev-02, cross). - P2 and the p3 kills at `elevated`/`installing` ran on the earlier `24cc1d48b` build. Their paths are unchanged since. - Runner: `~claude/.cache/codex-scratch/j416/e2e.sh`, flow `~/infraternal-ops/j416-private/flow.ts`. - The tarball attached below holds `run.txt`, the snapshots and diffs, a screenshot and the KAT generator. | # | Case | Result | |---|---|---| | 1a | rootless Add host, then a terminal open, then Full access | **refused, nothing changed**: `begin` → `{"outcome":"terminals"}`; host snapshot diff **0 lines** (R6 adoption is not built, see below) | | 1b | the same after the shell ends, sudo password | `started` → steps `elevated`, `installing`, `handover`, `done`; promotion `handed_over`, cleanup **`done`**; the DB says `rootful 0` | | 1b | host after the upgrade | system unit `infraternal-agent.service` and `/var/lib/infraternal` installed. The **rootless install retired as the account**: the user unit, launcher, `bin`, `state` and `.bootstrap-owner` are gone, and the `sessions` dirs are kept. Processes: `kayg infraternal` → `root infraternal`. Diff: `p1/snap-after.diff` | | 2 | wrong sudo password twice, then a third try | `wrong` → `wrong` (`retryAfter:900`) → `begin` = **`locked`, `retryAfter:900`**. `faillock --user kayg` shows exactly **2** failures (deny=3). The right password still works for sudo (**`SUDO_STILL_WORKS`**), so the account is not locked. Host diff 0 | | 3a | server SIGKILLed once root is reached (`elevated`) | relay closed by the 30 s idle watchdog, nothing installed. Diff **0** after the kill and **0** after a restart (the sweep abandons the row) | | 3b | SIGKILL after the root payload was staged (`installing`) | the relay ran the install's rollback by itself. Diff **0 / 0** | | 3c | SIGKILL after the successor redeemed and started (`enrolled`, J415 row 2) | the relay's rollback removed the half-installed root unit. Diff **0 / 0**. After restart the row is `abandoned`, cleanup `uninstall` (the successor is already gone, so the entry stays inert) | | 4 | SIGKILL right after the handover commits, before retirement (J415 rows 4–5) | the row is `handed_over` with cleanup `retire`. After restart, the root agent's ordinary admission delivers the retire outbox → **`done`**, the host is rootful, the rootless install retired | | all | AVC denials (`ausearch -m AVC -ts today`) | **0** in every run | | all | the password in the guest journal, the server log, the server DB, and the evidence files | **0** hits | J415's row "during the handover transaction" is covered by the SQLite tests (`the_handover_moves_authority_in_one_transaction`, `the_handover_refuses_a_lost_binding_or_an_abandoned_row`). It is one transaction with the trigger. **Bugs the rig found, each fixed in its own commit:** - `dd8bb4e37`: the helper/relay socket paths passed the 108-byte cap with a long data dir. - `24cc1d48b`: B4's first fix killed the slow-starting stage step. It is now a per-step byte cap of 256 MiB. - `bbbba556c`: the join endpoint answered a successor via ordinary admission (403). It now answers uid 0 through the successor check. **R6 (adopting running terminals) is not built.** Until it lands, Full access with open terminals is refused and the host stays unchanged (row 1a), so nothing is orphaned. Design proposal in comment 88685. **Observation, not changed:** the root unit file lands as `var_lib_t` (it is moved from staging under `/var/lib/infraternal`). It loads and runs under Enforcing. This comes from the unchanged root install scripts, so it is the same as a root SSH Add host.
Author
Owner

J416 finish

Branch root-upgrade-d376, head 9c360ef18 (git ls-remote: 9c360ef188c97786ca7ba27e8f2eba2928abf595 refs/heads/root-upgrade-d376). It contains current origin/dev (14bec64b7 merges 5eb968744). Not merged, not deployed.

J415 status

Item Status Where
R1 certificate role + uid; separate successor admission done 6569fd13c, plus the join fix bbbba556c
R2 per-(host, uid) revocation; private standby actor done 6569fd13c
R3 one-transaction handover + DB trigger done 6569fd13c
R4 the relay rolls itself back done, proven on the guest e2179b190, 24cc1d48b
R5 outboxes (uninstall, retire); standby until commit done: retire proven (P1, P4); the uninstall outbox is unit-tested (on the guest the relay's own rollback removes the successor first) 6569fd13c, 1205631eb
R6 adoption runs as the person, server attribution not built. Interim: Full access is refused while terminals are open, and the host is unchanged (P1a). Design in comment 88685 —
R7 bound to the rootless cert and link generation done 6569fd13c, b585b8997
B1 lost link → no half-installed root done (P3a–c)
B2 check + try in one write; typed timeouts count done
B3 su echo race; Zeroizing tail done
B4 own lane + timeouts; a stuck step can't stall done (refined in 24cc1d48b)
B5 one-time relay token done
B6 docs: the socket's end is the relay's end done
B7 fixed-key X25519 KAT done (RFC 7748 keys). The TS sealer test waits for slice 3
B8 one key per host, no re-key done
B9 lockout per account; hand-typed failures documented done
B10 elevation off the lock; Ready-first; stderr cap done
B11 root shell not POSIX → rootshell done
B12 ring wiping note done

Proof table: comment 89041, evidence attached there

P1a refused with a terminal open, host diff 0 · P1b upgraded: rootful, rootless install retired as the account, 0 AVC · P2 wrong×2 → locked/900 s, faillock 2 < 3, sudo still works · P3 SIGKILL at elevated / installing / enrolled → host diff 0 after the kill and after a restart · P4 SIGKILL after the handover → the retire outbox completes on restart · the password found nowhere (guest journal, server log, DB, evidence).

Gates, verbatim

verify fast, compute01, head 14bec64b7:

+---------------------------+--------------+
| Rust gate                 | Result       |
+---------------------------+--------------+
| Nix Cargo vendoring       | PASS         |
| fmt                       | PASS         |
| migration names           | PASS         |
| migration tooling tests   | PASS         |
| VT native fmt             | PASS         |
| CLI plugin compile-out    | PASS         |
| clippy                    | PASS         |
| SSH runtime test helper   | PASS         |
| real-binary fixture paths | PASS         |
| Traefik component manifest | PASS         |
| release component publication | PASS         |
| test                      | PASS         |
| SSH inode reuse fixture   | PASS         |
| shim process-per-user     | PASS         |
| cargo-deny                | PASS         |
| gate architecture         | PASS         |
| doc coverage ratchet      | PASS         |
| shape lints off (D351)    | PASS         |
| doc comments on diff      | FAIL (1)     |
| fixed test waits          | PASS         |
| privileged integration    | PASS         |
| probe release exclusion   | PASS         |
| nightly fuzz reporter     | PASS         |
| nightly browser suite reporter | PASS         |
| static builds             | PASS         |
+---------------------------+--------------+
FAST_DONE 2026-11-09T21:15:51Z
fast start 2026-11-09T18:49:53Z head 14bec64b7 base 547a48885
PT_INTERP: absent; size: 28803640 bytes; baseline 28268440; delta +535200 bytes (report only, D243 amended 2026-11-01)
PT_INTERP: absent; size: 25851640 bytes; baseline 25378504; delta +473136 bytes (report only, D243 amended 2026-11-01)

The one failing row was doc comments on diff: crates/infraternal-server/src/remote_http.rs:659: join needs a doc comment (D345). Commit 9c360ef18 adds only that doc comment. Its check, verify quick on dev-02:

quick start 2026-11-09T23:49:12Z head 9c360ef18 base 06fc52d92
+---------------------------+--------------+
| Rust gate                 | Result       |
+---------------------------+--------------+
| scope guard               | PASS         |
| migration names           | PASS         |
| Nix Cargo vendoring       | PASS         |
| VT source recipe          | PASS         |
| fmt                       | PASS         |
| doc coverage ratchet      | PASS         |
| shape lints off (D351)    | PASS         |
| doc comments on diff      | PASS         |
| fixed test waits          | PASS         |
| clippy (workspace)        | PASS         |
| unit tests (changed)      | PASS         |
+---------------------------+--------------+

QUICK pre-check passed. This is not the gate: run scripts/verify fast before reporting done.

(An earlier quick run on dev-02 failed 34 daemon/lifecycle tests with NotFound. That was the known J155 gotcha: the <worktree>/target symlink was missing. With the symlink they pass, and in compute01's fast test row too.)

Static sizes (report only): x86_64 28 803 640 B (+535 200 B vs the baseline), aarch64 25 851 640 B (+473 136 B).

For the orchestrator

  • R1–R3 re-review: commit 6569fd13c, plus bbbba556c (join answers a successor as uid 0).
  • R6 needs its own job: the login worker passes a connected shim fd to root (SCM_RIGHTS), with attribution from server session records.
  • Questions for the reviewer are in comment 88685: is the facts-only proof enough without the canary as the person, and reconnect vs promote-in-place.
  • Cleanup done: target dirs deleted on compute01 and dev-02, guests stopped, overlays removed. Evidence is kept in ~claude/.cache/codex-scratch/j416/evidence on compute01.
## J416 finish **Branch `root-upgrade-d376`, head `9c360ef18`** (`git ls-remote`: `9c360ef188c97786ca7ba27e8f2eba2928abf595 refs/heads/root-upgrade-d376`). It contains current origin/dev (`14bec64b7` merges `5eb968744`). Not merged, not deployed. ### J415 status | Item | Status | Where | |---|---|---| | R1 certificate role + uid; separate successor admission | **done** | `6569fd13c`, plus the join fix `bbbba556c` | | R2 per-(host, uid) revocation; private standby actor | **done** | `6569fd13c` | | R3 one-transaction handover + DB trigger | **done** | `6569fd13c` | | R4 the relay rolls itself back | **done**, proven on the guest | `e2179b190`, `24cc1d48b` | | R5 outboxes (uninstall, retire); standby until commit | **done**: retire proven (P1, P4); the uninstall outbox is unit-tested (on the guest the relay's own rollback removes the successor first) | `6569fd13c`, `1205631eb` | | R6 adoption runs as the person, server attribution | **not built.** Interim: Full access is refused while terminals are open, and the host is unchanged (P1a). Design in comment 88685 | — | | R7 bound to the rootless cert and link generation | **done** | `6569fd13c`, `b585b8997` | | B1 lost link → no half-installed root | **done** (P3a–c) | | | B2 check + try in one write; typed timeouts count | **done** | | | B3 su echo race; Zeroizing tail | **done** | | | B4 own lane + timeouts; a stuck step can't stall | **done** (refined in `24cc1d48b`) | | | B5 one-time relay token | **done** | | | B6 docs: the socket's end is the relay's end | **done** | | | B7 fixed-key X25519 KAT | **done** (RFC 7748 keys). The TS sealer test waits for slice 3 | | | B8 one key per host, no re-key | **done** | | | B9 lockout per account; hand-typed failures documented | **done** | | | B10 elevation off the lock; Ready-first; stderr cap | **done** | | | B11 root shell not POSIX → `rootshell` | **done** | | | B12 ring wiping note | **done** | | ### Proof table: comment 89041, evidence attached there P1a refused with a terminal open, host diff 0 · P1b upgraded: rootful, rootless install retired as the account, 0 AVC · P2 wrong×2 → `locked`/900 s, faillock 2 < 3, sudo still works · P3 SIGKILL at `elevated` / `installing` / `enrolled` → host diff 0 after the kill and after a restart · P4 SIGKILL after the handover → the retire outbox completes on restart · the password found nowhere (guest journal, server log, DB, evidence). ### Gates, verbatim `verify fast`, compute01, head `14bec64b7`: ``` +---------------------------+--------------+ | Rust gate | Result | +---------------------------+--------------+ | Nix Cargo vendoring | PASS | | fmt | PASS | | migration names | PASS | | migration tooling tests | PASS | | VT native fmt | PASS | | CLI plugin compile-out | PASS | | clippy | PASS | | SSH runtime test helper | PASS | | real-binary fixture paths | PASS | | Traefik component manifest | PASS | | release component publication | PASS | | test | PASS | | SSH inode reuse fixture | PASS | | shim process-per-user | PASS | | cargo-deny | PASS | | gate architecture | PASS | | doc coverage ratchet | PASS | | shape lints off (D351) | PASS | | doc comments on diff | FAIL (1) | | fixed test waits | PASS | | privileged integration | PASS | | probe release exclusion | PASS | | nightly fuzz reporter | PASS | | nightly browser suite reporter | PASS | | static builds | PASS | +---------------------------+--------------+ FAST_DONE 2026-11-09T21:15:51Z fast start 2026-11-09T18:49:53Z head 14bec64b7 base 547a48885 PT_INTERP: absent; size: 28803640 bytes; baseline 28268440; delta +535200 bytes (report only, D243 amended 2026-11-01) PT_INTERP: absent; size: 25851640 bytes; baseline 25378504; delta +473136 bytes (report only, D243 amended 2026-11-01) ``` The one failing row was `doc comments on diff`: `crates/infraternal-server/src/remote_http.rs:659: join needs a doc comment (D345)`. Commit `9c360ef18` adds only that doc comment. Its check, `verify quick` on dev-02: ``` quick start 2026-11-09T23:49:12Z head 9c360ef18 base 06fc52d92 +---------------------------+--------------+ | Rust gate | Result | +---------------------------+--------------+ | scope guard | PASS | | migration names | PASS | | Nix Cargo vendoring | PASS | | VT source recipe | PASS | | fmt | PASS | | doc coverage ratchet | PASS | | shape lints off (D351) | PASS | | doc comments on diff | PASS | | fixed test waits | PASS | | clippy (workspace) | PASS | | unit tests (changed) | PASS | +---------------------------+--------------+ QUICK pre-check passed. This is not the gate: run scripts/verify fast before reporting done. ``` (An earlier quick run on dev-02 failed 34 `daemon`/`lifecycle` tests with `NotFound`. That was the known J155 gotcha: the `<worktree>/target` symlink was missing. With the symlink they pass, and in compute01's `fast` `test` row too.) Static sizes (report only): x86_64 28 803 640 B (+535 200 B vs the baseline), aarch64 25 851 640 B (+473 136 B). ### For the orchestrator - R1–R3 re-review: commit `6569fd13c`, plus `bbbba556c` (join answers a successor as uid 0). - R6 needs its own job: the login worker passes a connected shim fd to root (`SCM_RIGHTS`), with attribution from server session records. - Questions for the reviewer are in comment 88685: is the facts-only proof enough without the canary as the person, and reconnect vs promote-in-place. - Cleanup done: target dirs deleted on compute01 and dev-02, guests stopped, overlays removed. Evidence is kept in `~claude/.cache/codex-scratch/j416/evidence` on compute01.
Author
Owner

Security review start (Claude Opus 5.5): independent break-it review of the D376 root upgrade.

  • Reviewing root-upgrade-d376 at 9c360ef18 (detached worktree on code; no pushes to this branch).
  • Scope: password handling and seal, privilege boundary, handover authority (R1–R3), rollback (R4/R5), least privilege, the two questions in 88685, plus unit/integration tests, verify quick and one guest proof re-run on compute01.
**Security review start** (Claude Opus 5.5): independent break-it review of the D376 root upgrade. - Reviewing `root-upgrade-d376` at `9c360ef18` (detached worktree on code; no pushes to this branch). - Scope: password handling and seal, privilege boundary, handover authority (R1–R3), rollback (R4/R5), least privilege, the two questions in 88685, plus unit/integration tests, `verify quick` and one guest proof re-run on compute01.
Author
Owner

Security review of the D376 root upgrade: REQUEST CHANGES

Reviewed: root-upgrade-d376 at 9c360ef188c97786ca7ba27e8f2eba2928abf595 (J410's host half, J416's R1–R5/R7 and B1–B12, the endpoints). Reviewer: Claude Opus 5.5, independent; nothing pushed, merged or deployed.

Verdict: REQUEST CHANGES. One P1: if the root relay dies after the successor enrolled, a root agent is left running on the host with no way to remove it. Four P2s and six P3s follow. The seal, the 2-try rule, the hash-then-exec boundary and the R1–R3 authority code held up against everything I tried.

What I ran (compute01, my own build of 9c360ef18)

  • Build: server sha256 56c9345b…, payload 4bc9c2ad… (static x86_64 musl, --locked).
  • cargo test --workspace --lib -- privilege_ enrollment:: remote_guard remote_ bootstrap host_ promotion: TESTS_EXIT=0. The largest binaries ran 333, 118, 82 and 13 tests, plus the 11 promotion tests; 0 failed anywhere (tests.log on compute01).
  • cargo test -p infraternal --test privilege_relay_session: 4/4.
  • verify quick (pre-check, not the gate): QUICK_EXIT=0, every row PASS (scope guard, migration names, Nix vendoring, VT recipe, fmt, doc coverage, shape lints, doc comments on diff, fixed test waits, clippy (workspace), unit tests (changed)).
  • Guest proofs: J416's rig, copied to my own ports, overlay and dir (/root/r399-*-guest.sh, ~claude/.cache/codex-scratch/r399/e2e.sh). Each run used a fresh AlmaLinux 10.2 overlay, SELinux Enforcing, faillock on. I added a guest sampler that records, every 0.2 s, each infraternal and sudo process with its uid, and the relay's cwd and cgroup.
    • Proof 1 (1a + 1b) re-run: reproduces. 1a: refused with a terminal open. 1b: started → handed_over, the DB says rootful 0, retire done, AVC 0. The password got 0 hits in the guest journal, the server log and the DB.
    • New p6 (relay SIGKILL): gives the P1 below.
  • Evidence (attached, r399-evidence.tgz): run.txt, the snapshots and diffs, sample.txt, root-inventory.txt, guest-leftover.txt, server-debug.log, the runner and the sampler. The private dir with passwords was never in it; a grep of the evidence for each run's passwords found 0 hits.
  • Cleanup: both guests stopped and their overlays removed. The throwaway server is stopped.

P1

P1-1. A relay that dies after the successor enrolled leaves a running root agent, and the R5 uninstall outbox can never remove it.

  • Repro (e2e.sh p6-installing): promote. When the status shows installing (the row is already enrolled), run pkill -KILL -f __privilege-relay on the guest.

    • Server: step="commit" … exit_status=Some(255), and the row ends sudo|person|abandoned|tried=1|rooted=1|uninstall. The host stays rootless 1001 in the DB, which is right.
    • Guest, still the same 17 minutes later (guest-leftover.txt):
      • infraternal-agent.service is active (running) as root and enabled for boot;
      • /var/lib/infraternal exists, with an open journal .bootstrap-lock/{activating,replacing-*,stopping,transaction,…};
      • /usr/local/bin/infraternal, the unit in /etc/systemd/system and /run/infraternal-privilege.tJn0NR2g (the relay's copy) are all left behind.
    • The diff against the snapshot taken before the upgrade is 35 lines, both right after the kill and 3 minutes later.
  • Why: the abandoned successor reconnects every ~30–60 s and is admitted for Uninstall. server-debug.log shows the admit_successor query with rows_returned=1 four times. But the host's own uninstall refuses while .bootstrap-lock exists (crates/plugins/remote/src/uninstall.rs:153, tested on purpose by bootstrap_lock_delays_uninstall…). The answer is a refusal, so deliver_uninstall returns and the link drops (BrokenPipe in the guest journal). This repeats until the certificate expires, and after that the root agent stays running but unadmitted. R5's design said the successor would "roll back and uninstall itself"; only the uninstall half exists, and it can't run with an open journal. J416's P3c passed only because the relay's own rollback ran first, so this outbox path had never run on a real host.

  • Realistic triggers:

    • a host reboot or power loss during the ~1 min install (after START the unit is enabled, so it comes back at boot into exactly this state);
    • the OOM killer or a crash of the relay;
    • an admin killing it.
      The account can't kill the relay: it runs as ruid 0 in root's logind scope (see "Refuted" below).
  • Breaks: D376 "any failure removes the half-installed root service", and the owner's least-privilege rule: a root daemon with the full capability set keeps running and serves nothing.

  • Fix (recommended): the uninstall outbox must recover the journal first. Two options:

    • the server, which has the transaction id and host id, sends that transaction's rollback_script() to the abandoned successor (one more action allowed on the Uninstall lane), and the successor runs it as root under the setup lock before its uninstall;
    • or the uninstall handler recovers a journal whose transaction matches the promotion, through the existing previous_attempt.sh recovery.

    A transient unit with ExecStopPost= would cover a relay crash but not a reboot.

  • Proofs to add: kill the relay at installing, at enrolled, and after commit; reboot the guest mid-install. The host diff must reach 0 without anyone trying again.

P2

P2-1. The interim "no open terminals" rule is checked only at begin.

  • host_promotion.rs:240 reads the session inventory once. Nothing refuses a terminal open on the rootless link while a promotion is live, and nothing checks again before install.commit(), the handover or retire. A terminal opened during the 1–2 minute install is orphaned: retire keeps its shim (KillMode=process), but the agent that reached it is gone. That is the exact outcome the interim rule exists to prevent (D376: "stays reachable as before").
  • I found this by inspection and did not reproduce it on the guest (that needs a second browser flow during the install).
  • Fix: check the inventory again right before install.commit() (abandon with terminals when it isn't empty), and refuse terminal.open on a host with a live promotion row.

P2-2. Facts-only proof (J416's question 1): enough for security, not enough for D376's "failure puts the host back".

  • Security: neither the facts nor a canary proves anything against a hostile conduit. Every byte passes through the rootless agent, which can report started, emulate the relay, swap in its own key digest, redeem the token and answer executionUid=0 (J415 Q4; it already holds the password).
  • Function: after the handover there is no way back, because the rootless certificates are revoked. A successor that answers facts but can't start PAM logins (an SELinux denial, logind missing) leaves the person with no terminals, and the rootless install is retired underneath them.
  • Recommendation: one canary login, as the promoting person's account, on the private lane before install.commit(). Standby exists so that two agents never manage the same host resources (mesh, proxy, logins they keep). One short PAM session that is closed at once doesn't break that. Keep everything else blocked.

P2-3. Least privilege after the upgrade (the owner's words in D376). root-inventory.txt lists exactly one root process: /var/lib/infraternal/bin/current agent … run, system unit, no User=. The unit has:

  • the full capability bounding set, including cap_sys_module, cap_sys_boot, cap_sys_time, cap_sys_rawio, cap_mknod, cap_linux_immutable, cap_mac_admin, cap_bpf and cap_checkpoint_restore;
  • NoNewPrivileges=no, ProtectSystem=no, ProtectHome=no and PrivateTmp=no.

Terminals run as the person, which is correct. This is the unchanged root-mode unit that a root SSH Add host also installs, so it is not a regression of this branch and doesn't block it. But it is what "nothing runs as root that it does not need" is about. It needs its own issue: list what the root agent really uses (net_admin for TUN, nftables and WireGuard; D-Bus to systemd for login units; dac_override and chown for /etc SSH files and PTYs; kill; net_bind_service), then bound the unit and add ProtectHome=read-only where the R6 design allows it.

P2-4. Root runs in the account's home directory.

  • Sampler: RELAY uid=0/0 cwd=/home/kayg (31 samples), and every root install step STEP uid=0 cwd=/home/kayg (44 samples).
  • sudo and su keep the caller's cwd, the snippet never runs cd /, and the relay starts steps and the rollback with no current_dir (privilege_relay_host.rs:377, :414).
  • Today's root-mode scripts use absolute paths only ($base=/var/lib/infraternal, HOME=/root, no relative writes found), so I found no exploit. But a future relative path, or a tool that reads config from its cwd, would run as root inside a directory the account controls. A home on NFS with root_squash would also break the steps.
  • Fix (three lines): cd / at the top of the snippet, plus .current_dir("/") in open() and run_rollback().

P3

  • P3-1. Leftovers from the snippet.
    • A kill between mktemp and exec (the 60 s bound, privilege.end), or a relay SIGKILL, leaves /run/infraternal-privilege.* holding an 11 MB root copy (seen in p6). RemoveOnDrop only runs inside a live relay.
    • Root cp reads a path the account chooses ($1 = current_exe in its home). A FIFO there hangs the snippet. A symlink to /dev/zero makes root fill the /run tmpfs until the 60 s kill, and the file stays.
    • Fix: compare the size before copying (head -c <payload size>), and sweep stale infraternal-privilege.* directories at the snippet's start.
  • P3-2. A digest mismatch counts as a failed try although root was reached. Mismatch → settle_try(Failed{typed:true}) (host_promotion.rs:410). Two version-skewed hosts, or two tries on one, give a 15-minute wait with the right password, while PAM counted a success. Settle it as root reached (the count starts again) and tell the person to update the host first.
  • P3-3. finish doesn't check the admin window again. reserve_try checks unlocked and unexpired but not s.fresh (remote_promotion.rs:336); begin does check it. The gap can be up to LIFETIME (15 min). Add the same s.fresh clause.
  • P3-4. The link generation isn't checked again between the last still_bound and the handover. Commit and the relay's finish happen in between (host_promotion.rs:755–781). The handover checks the bound certificate but not the generation, so a link that is replaced in that gap still hands over. Bind the generation in the handover, or check it right before.
  • P3-5. Password memory and characters.
    • The plaintext Zeroizing buffer isn't mlocked, so it could reach swap for ≤60 s. The agent is non-dumpable from the start (harden_startup, main.rs:2443), which blocks core files and same-uid ptrace, good.
    • Passwords that contain tty control characters (VERASE, VKILL, VEOF, ^C, or \n) are changed by the line discipline, because sudo keeps ICANON. The browser should refuse them with a plain message instead of reporting a wrong password.
  • P3-6. Docs.
    • privilege_relay_host.rs:15 says the token is safe "where ptrace is restricted". Yama is 0 on the Alma 10 guest. The real protection is that the agent is non-dumpable. A same-uid process can still open the outer pty slave and steal the token, but it can't inject input, so the relay just exits (a DoS only). Say that.
    • R7's required statement in /docs/privileges, that the password is only as safe as the rootless account, isn't written yet. It is planned for slice 4; keep it tracked.

The prompt's questions, item by item

1. Password handling: holds.

  • The seal is v1, X25519 → HKDF-SHA256 → AES-256-GCM. Host, attempt and method are in both the HKDF info and the AAD, at fixed lengths with the variable field last.
  • agree_ephemeral consumes the key, elevate takes the key out before any check, there is one key per host, and a key lives 2 min. A blob therefore can't be replayed to another host or attempt, or later. The nonce is random under a one-use key, so reuse can't matter.
  • The server only ever handles ciphertext.
  • argv carries the snippet plus four public words (seen in full in the p6 ps output). The environment is cleared. The plaintext lives only in Zeroizing buffers in a non-dumpable process. 0 hits in the guest journal, server log and DB, in both runs.
  • 2 tries then 15 min: parallel requests can't get around it. A unique partial index allows one live row per host. reserve_try is one UPDATE with the lock condition in its WHERE, and the count is per account (B9). A try abandoned by a restart keeps tried=1.
  • Exception: the swap note in P3-5.

2. Privilege boundary: holds, apart from P2-4 and P3-1.

  • No TOCTOU between hash and exec: the copy goes into a fresh root 0700 mktemp -d under /run, the copy is hashed, and the copy is executed.
  • No PATH or env injection: env_clear plus a fixed PATH and the C locale; sudo -k -p <random marker>; non-login su gets the cleared env; root mode checks that the base /var/lib/infraternal is owned by root and private.
  • The relay checks the socket's directory and file owner with lstat, then the peer uid, then a 32-byte token as the first frame. A symlink swap to a root socket fails the peer-uid check before any byte is written.
  • The account's processes: the relay runs in root's logind scope (0::/user.slice/user-0.slice/session-c1.scope), not in the account's cgroup, with ruid 0, so the account can't kill or freeze it.
  • Root never opens a path under the home, apart from the cwd (P2-4) and cp "$1" (P3-1).

3. Handover authority (R1–R3): holds.

  • Every certificate INSERT now writes a role and a uid (enrollment, renew, recovery, rotation, redeem_successor). admit, admit_cleanup, renew and recovery require role='active' AND execution_uid=h.execution_uid.
  • SuccessorAuthority is a separate type. remote_link lets a successor carry only Hello, Ping, Pong and host.facts.read, or UNINSTALL once abandoned. The join endpoint answers uid 0 only through admit_successor.
  • Triggers: certificate role and uid are write-once; promotion states only move forward; the binding is write-once; the mode changes only rootless→rootful with an enrolled row from that uid.
  • The handover is one transaction and revokes by public key.
  • cut(host) cuts every epoch, including ones that register later, until the tombstone. The handover cuts only (host, from_uid).
  • Races:
    • Forget abandons in its own transaction, and the handover requires revoked_at IS NULL and enrolled; SQLite serializes the two, so either order is consistent.
    • An identity rotation or renewal during a promotion either revokes the bound certificate (the upgrade fails closed) or is revoked by key at handover.
    • D368 take-over is fenced by the setup lock and the journal (exit 72). By inspection only.
  • A malicious host agent can't start a promotion (the admin session is checked inside the INSERT), can't redeem another host's token, and can't use the successor certificate as a HostAuthority. It can fake the whole upgrade that an admin starts on it (P2-2, J415 Q4: inherent).

4. Rollback (R4/R5): R4 holds for a lost link or server (J416's P3a–c and the integration tests pass). It does not hold when the relay itself dies after enrollment (P1-1). Retire runs as the account: setpriv --reuid/--regid/--init-groups, clean env, cwd /; the root side never touches the home.

5. What runs as root after an upgrade: the root agent only (P2-3 for its unit). During the upgrade, root runs: the snippet (cp, sha256sum, chcon, exec), the relay, and the server's root install steps (stage, ACTIVATE, START, commit or rollback), each under /bin/sh with a cleared env. All of that needs root, apart from the cwd (P2-4). Not root: password handling, terminals and shims, the retire.

6. J416's two questions (comment 88685):

  • Is the facts-only proof enough? Yes for security: no proof that passes through the rootless conduit can be trusted against a hostile host, so a canary adds no security. No for function: add a one-shot canary as the person before commit (P2-2).
  • Reconnect via ordinary admission? Acceptable, and better than promoting in place. It reuses the single audited admission path (admit + epoch registration + registry insert) with the now-active uid-0 certificate, and keeps R1's type separation. Promoting in place would have to turn a live SuccessorAuthority guard into a HostAuthority, which is exactly the conversion R1 forbids. The cost is one ordinary reconnect gap. admit_observed replaces the stale, cut rootless entry, and that path ran in proof 1b and in J416's P4.

7. Tests and the guest proof: above.

Required before merge

P1-1, with the proofs listed. P2-1 and P2-4 are small and on the boundary; fix them in the same round. P2-2 needs a decision from the orchestrator or the owner. P2-3 should be its own issue.

## Security review of the D376 root upgrade: REQUEST CHANGES **Reviewed:** `root-upgrade-d376` at **`9c360ef188c97786ca7ba27e8f2eba2928abf595`** (J410's host half, J416's R1–R5/R7 and B1–B12, the endpoints). Reviewer: Claude Opus 5.5, independent; nothing pushed, merged or deployed. **Verdict: REQUEST CHANGES.** One P1: if the root relay dies after the successor enrolled, a root agent is left running on the host with no way to remove it. Four P2s and six P3s follow. The seal, the 2-try rule, the hash-then-exec boundary and the R1–R3 authority code held up against everything I tried. ### What I ran (compute01, my own build of `9c360ef18`) - Build: server sha256 `56c9345b…`, payload `4bc9c2ad…` (static x86_64 musl, `--locked`). - `cargo test --workspace --lib -- privilege_ enrollment:: remote_guard remote_ bootstrap host_ promotion`: **TESTS_EXIT=0**. The largest binaries ran 333, 118, 82 and 13 tests, plus the 11 promotion tests; 0 failed anywhere (`tests.log` on compute01). - `cargo test -p infraternal --test privilege_relay_session`: **4/4**. - `verify quick` (pre-check, not the gate): **QUICK_EXIT=0**, every row PASS (scope guard, migration names, Nix vendoring, VT recipe, fmt, doc coverage, shape lints, doc comments on diff, fixed test waits, clippy (workspace), unit tests (changed)). - Guest proofs: J416's rig, copied to my own ports, overlay and dir (`/root/r399-*-guest.sh`, `~claude/.cache/codex-scratch/r399/e2e.sh`). Each run used a fresh AlmaLinux 10.2 overlay, **SELinux Enforcing**, faillock on. I added a guest sampler that records, every 0.2 s, each infraternal and sudo process with its uid, and the relay's cwd and cgroup. - **Proof 1 (1a + 1b) re-run: reproduces.** 1a: refused with a terminal open. 1b: `started` → `handed_over`, the DB says `rootful 0`, retire `done`, AVC 0. The password got 0 hits in the guest journal, the server log and the DB. - **New p6 (relay SIGKILL):** gives the P1 below. - Evidence (attached, `r399-evidence.tgz`): run.txt, the snapshots and diffs, `sample.txt`, `root-inventory.txt`, `guest-leftover.txt`, `server-debug.log`, the runner and the sampler. The private dir with passwords was never in it; a grep of the evidence for each run's passwords found 0 hits. - Cleanup: both guests stopped and their overlays removed. The throwaway server is stopped. --- ### P1 **P1-1. A relay that dies after the successor enrolled leaves a running root agent, and the R5 uninstall outbox can never remove it.** - **Repro** (`e2e.sh p6-installing`): promote. When the status shows `installing` (the row is already `enrolled`), run `pkill -KILL -f __privilege-relay` on the guest. - Server: `step="commit" … exit_status=Some(255)`, and the row ends `sudo|person|abandoned|tried=1|rooted=1|uninstall`. The host stays `rootless 1001` in the DB, which is right. - Guest, still the same **17 minutes later** (`guest-leftover.txt`): - `infraternal-agent.service` is **active (running) as root** and enabled for boot; - `/var/lib/infraternal` exists, with an open journal `.bootstrap-lock/{activating,replacing-*,stopping,transaction,…}`; - `/usr/local/bin/infraternal`, the unit in `/etc/systemd/system` and `/run/infraternal-privilege.tJn0NR2g` (the relay's copy) are all left behind. - The diff against the snapshot taken before the upgrade is 35 lines, both right after the kill and 3 minutes later. - **Why:** the abandoned successor reconnects every ~30–60 s and is admitted for `Uninstall`. `server-debug.log` shows the `admit_successor` query with `rows_returned=1` four times. But the host's own uninstall refuses while `.bootstrap-lock` exists (`crates/plugins/remote/src/uninstall.rs:153`, tested on purpose by `bootstrap_lock_delays_uninstall…`). The answer is a refusal, so `deliver_uninstall` returns and the link drops (`BrokenPipe` in the guest journal). This repeats until the certificate expires, and after that the root agent stays running but unadmitted. R5's design said the successor would "roll back **and** uninstall itself"; only the uninstall half exists, and it can't run with an open journal. J416's P3c passed only because the relay's own rollback ran first, so this outbox path had never run on a real host. - **Realistic triggers:** - a host reboot or power loss during the ~1 min install (after START the unit is enabled, so it comes back at boot into exactly this state); - the OOM killer or a crash of the relay; - an admin killing it. The account can't kill the relay: it runs as ruid 0 in root's logind scope (see "Refuted" below). - **Breaks:** D376 "any failure removes the half-installed root service", and the owner's least-privilege rule: a root daemon with the full capability set keeps running and serves nothing. - **Fix (recommended):** the uninstall outbox must recover the journal first. Two options: - the server, which has the transaction id and host id, sends that transaction's `rollback_script()` to the abandoned successor (one more action allowed on the `Uninstall` lane), and the successor runs it as root under the setup lock before its uninstall; - or the uninstall handler recovers a journal whose transaction matches the promotion, through the existing `previous_attempt.sh` recovery. A transient unit with `ExecStopPost=` would cover a relay crash but not a reboot. - **Proofs to add:** kill the relay at `installing`, at `enrolled`, and after `commit`; reboot the guest mid-install. The host diff must reach 0 without anyone trying again. ### P2 **P2-1. The interim "no open terminals" rule is checked only at `begin`.** - `host_promotion.rs:240` reads the session inventory once. Nothing refuses a terminal open on the rootless link while a promotion is live, and nothing checks again before `install.commit()`, the handover or retire. A terminal opened during the 1–2 minute install is orphaned: retire keeps its shim (`KillMode=process`), but the agent that reached it is gone. That is the exact outcome the interim rule exists to prevent (D376: "stays reachable as before"). - I found this by inspection and did not reproduce it on the guest (that needs a second browser flow during the install). - Fix: check the inventory again right before `install.commit()` (abandon with `terminals` when it isn't empty), and refuse `terminal.open` on a host with a live promotion row. **P2-2. Facts-only proof (J416's question 1): enough for security, not enough for D376's "failure puts the host back".** - Security: neither the facts nor a canary proves anything against a hostile conduit. Every byte passes through the rootless agent, which can report `started`, emulate the relay, swap in its own key digest, redeem the token and answer `executionUid=0` (J415 Q4; it already holds the password). - Function: after the handover there is no way back, because the rootless certificates are revoked. A successor that answers facts but can't start PAM logins (an SELinux denial, logind missing) leaves the person with no terminals, and the rootless install is retired underneath them. - Recommendation: one canary login, as the promoting person's account, on the private lane before `install.commit()`. Standby exists so that two agents never manage the same host resources (mesh, proxy, logins they keep). One short PAM session that is closed at once doesn't break that. Keep everything else blocked. **P2-3. Least privilege after the upgrade (the owner's words in D376).** `root-inventory.txt` lists exactly one root process: `/var/lib/infraternal/bin/current agent … run`, system unit, no `User=`. The unit has: - the **full capability bounding set**, including `cap_sys_module`, `cap_sys_boot`, `cap_sys_time`, `cap_sys_rawio`, `cap_mknod`, `cap_linux_immutable`, `cap_mac_admin`, `cap_bpf` and `cap_checkpoint_restore`; - `NoNewPrivileges=no`, `ProtectSystem=no`, `ProtectHome=no` and `PrivateTmp=no`. Terminals run as the person, which is correct. This is the unchanged root-mode unit that a root SSH Add host also installs, so it is **not a regression of this branch** and doesn't block it. But it is what "nothing runs as root that it does not need" is about. It needs its own issue: list what the root agent really uses (net_admin for TUN, nftables and WireGuard; D-Bus to systemd for login units; dac_override and chown for `/etc` SSH files and PTYs; kill; net_bind_service), then bound the unit and add `ProtectHome=read-only` where the R6 design allows it. **P2-4. Root runs in the account's home directory.** - Sampler: `RELAY uid=0/0 cwd=/home/kayg` (31 samples), and every root install step `STEP uid=0 cwd=/home/kayg` (44 samples). - sudo and su keep the caller's cwd, the snippet never runs `cd /`, and the relay starts steps and the rollback with no `current_dir` (`privilege_relay_host.rs:377`, `:414`). - Today's root-mode scripts use absolute paths only (`$base=/var/lib/infraternal`, `HOME=/root`, no relative writes found), so I found no exploit. But a future relative path, or a tool that reads config from its cwd, would run as root inside a directory the account controls. A home on NFS with `root_squash` would also break the steps. - Fix (three lines): `cd /` at the top of the snippet, plus `.current_dir("/")` in `open()` and `run_rollback()`. ### P3 - **P3-1. Leftovers from the snippet.** - A kill between `mktemp` and `exec` (the 60 s bound, `privilege.end`), or a relay SIGKILL, leaves `/run/infraternal-privilege.*` holding an 11 MB root copy (seen in p6). `RemoveOnDrop` only runs inside a live relay. - Root `cp` reads a path the account chooses (`$1` = `current_exe` in its home). A FIFO there hangs the snippet. A symlink to `/dev/zero` makes root fill the `/run` tmpfs until the 60 s kill, and the file stays. - Fix: compare the size before copying (`head -c <payload size>`), and sweep stale `infraternal-privilege.*` directories at the snippet's start. - **P3-2. A digest mismatch counts as a failed try although root was reached.** `Mismatch` → `settle_try(Failed{typed:true})` (`host_promotion.rs:410`). Two version-skewed hosts, or two tries on one, give a 15-minute wait with the right password, while PAM counted a success. Settle it as root reached (the count starts again) and tell the person to update the host first. - **P3-3. `finish` doesn't check the admin window again.** `reserve_try` checks unlocked and unexpired but not `s.fresh` (`remote_promotion.rs:336`); `begin` does check it. The gap can be up to `LIFETIME` (15 min). Add the same `s.fresh` clause. - **P3-4. The link generation isn't checked again between the last `still_bound` and the `handover`.** Commit and the relay's finish happen in between (`host_promotion.rs:755–781`). The handover checks the bound certificate but not the generation, so a link that is replaced in that gap still hands over. Bind the generation in the handover, or check it right before. - **P3-5. Password memory and characters.** - The plaintext `Zeroizing` buffer isn't mlocked, so it could reach swap for ≤60 s. The agent is non-dumpable from the start (`harden_startup`, `main.rs:2443`), which blocks core files and same-uid ptrace, good. - Passwords that contain tty control characters (VERASE, VKILL, VEOF, ^C, or `\n`) are changed by the line discipline, because sudo keeps ICANON. The browser should refuse them with a plain message instead of reporting a wrong password. - **P3-6. Docs.** - `privilege_relay_host.rs:15` says the token is safe "where ptrace is restricted". Yama is **0** on the Alma 10 guest. The real protection is that the agent is non-dumpable. A same-uid process can still open the outer pty slave and *steal* the token, but it can't inject input, so the relay just exits (a DoS only). Say that. - R7's required statement in `/docs/privileges`, that the password is only as safe as the rootless account, isn't written yet. It is planned for slice 4; keep it tracked. --- ### The prompt's questions, item by item **1. Password handling: holds.** - The seal is v1, X25519 → HKDF-SHA256 → AES-256-GCM. Host, attempt and method are in both the HKDF info and the AAD, at fixed lengths with the variable field last. - `agree_ephemeral` consumes the key, `elevate` takes the key out before any check, there is one key per host, and a key lives 2 min. A blob therefore can't be replayed to another host or attempt, or later. The nonce is random under a one-use key, so reuse can't matter. - The server only ever handles ciphertext. - argv carries the snippet plus four public words (seen in full in the p6 `ps` output). The environment is cleared. The plaintext lives only in `Zeroizing` buffers in a non-dumpable process. 0 hits in the guest journal, server log and DB, in both runs. - 2 tries then 15 min: parallel requests can't get around it. A unique partial index allows one live row per host. `reserve_try` is one UPDATE with the lock condition in its WHERE, and the count is per account (B9). A try abandoned by a restart keeps `tried=1`. - Exception: the swap note in P3-5. **2. Privilege boundary: holds, apart from P2-4 and P3-1.** - No TOCTOU between hash and exec: the copy goes into a fresh root 0700 `mktemp -d` under `/run`, the copy is hashed, and the copy is executed. - No PATH or env injection: `env_clear` plus a fixed PATH and the C locale; `sudo -k -p <random marker>`; non-login su gets the cleared env; root mode checks that the base `/var/lib/infraternal` is owned by root and private. - The relay checks the socket's directory and file owner with lstat, then the peer uid, then a 32-byte token as the first frame. A symlink swap to a root socket fails the peer-uid check before any byte is written. - The account's processes: the relay runs in **root's** logind scope (`0::/user.slice/user-0.slice/session-c1.scope`), not in the account's cgroup, with ruid 0, so the account can't kill or freeze it. - Root never opens a path under the home, apart from the cwd (P2-4) and `cp "$1"` (P3-1). **3. Handover authority (R1–R3): holds.** - Every certificate INSERT now writes a role and a uid (enrollment, renew, recovery, rotation, redeem_successor). `admit`, `admit_cleanup`, `renew` and recovery require `role='active' AND execution_uid=h.execution_uid`. - `SuccessorAuthority` is a separate type. `remote_link` lets a successor carry only Hello, Ping, Pong and `host.facts.read`, or `UNINSTALL` once abandoned. The join endpoint answers uid 0 only through `admit_successor`. - Triggers: certificate role and uid are write-once; promotion states only move forward; the binding is write-once; the mode changes only rootless→rootful with an `enrolled` row from that uid. - The handover is one transaction and revokes by public key. - `cut(host)` cuts every epoch, including ones that register later, until the tombstone. The handover cuts only `(host, from_uid)`. - Races: - Forget abandons in its own transaction, and the handover requires `revoked_at IS NULL` and `enrolled`; SQLite serializes the two, so either order is consistent. - An identity rotation or renewal during a promotion either revokes the bound certificate (the upgrade fails closed) or is revoked by key at handover. - D368 take-over is fenced by the setup lock and the journal (exit 72). By inspection only. - A malicious host agent can't start a promotion (the admin session is checked inside the INSERT), can't redeem another host's token, and can't use the successor certificate as a HostAuthority. It *can* fake the whole upgrade that an admin starts on it (P2-2, J415 Q4: inherent). **4. Rollback (R4/R5):** R4 holds for a lost link or server (J416's P3a–c and the integration tests pass). It does **not** hold when the relay itself dies after enrollment (P1-1). Retire runs as the account: `setpriv --reuid/--regid/--init-groups`, clean env, cwd `/`; the root side never touches the home. **5. What runs as root after an upgrade:** the root agent only (P2-3 for its unit). During the upgrade, root runs: the snippet (`cp`, `sha256sum`, `chcon`, `exec`), the relay, and the server's root install steps (stage, ACTIVATE, START, commit or rollback), each under `/bin/sh` with a cleared env. All of that needs root, apart from the cwd (P2-4). Not root: password handling, terminals and shims, the retire. **6. J416's two questions (comment 88685):** - **Is the facts-only proof enough?** Yes for security: no proof that passes through the rootless conduit can be trusted against a hostile host, so a canary adds no security. No for function: add a one-shot canary as the person before commit (P2-2). - **Reconnect via ordinary admission?** Acceptable, and better than promoting in place. It reuses the single audited admission path (`admit` + epoch registration + registry insert) with the now-`active` uid-0 certificate, and keeps R1's type separation. Promoting in place would have to turn a live `SuccessorAuthority` guard into a `HostAuthority`, which is exactly the conversion R1 forbids. The cost is one ordinary reconnect gap. `admit_observed` replaces the stale, cut rootless entry, and that path ran in proof 1b and in J416's P4. **7. Tests and the guest proof:** above. ### Required before merge P1-1, with the proofs listed. P2-1 and P2-4 are small and on the boundary; fix them in the same round. P2-2 needs a decision from the orchestrator or the owner. P2-3 should be its own issue.
Author
Owner

J416 round 2 finish (J465 review, comment 89390)

Branch root-upgrade-d376, head e8620738b (git ls-remote: e8620738b2ebfad595ea99f36aec5f02d58c7cd5 refs/heads/root-upgrade-d376). It contains origin/dev d7b2e4dc1. Not merged, not deployed.

What changed

Finding Fix Proof
P1 a relay killed after enrollment left a running, enabled root agent Two layers. (a) The promotion records its install transaction (migration remote_promotion_transaction). The uninstall outbox then sends the abandoned successor privilege.abandon {host, transaction}, and the successor starts a transient unit, infraternal --promotion-abandon: it rolls back exactly that transaction with the install's own root rollback, uninstalls if the install had committed, and sweeps /run. Only ids cross the wire (d239fa4e6). (b) A host-side guard, armed by the relay when it receives its Rollback frame: an undo script in /var/lib/infraternal-promotion/, a oneshot unit enabled for boot, and a transient watcher unit outside the relay's process tree that runs undo when the relay's pid ends. Commit or the relay's own rollback disarms it (0045a1534, ac96a3684, 704ea1ebf) guest p6, p6i, p7 below
P2a open terminals checked only at begin Re-checked, together with the bound link generation (P3-4), right before the install commits and right before the handover. A failure abandons, and a committed install is removed by the P1 outbox (d239fa4e6) unit test the_upgrade_stops_while_a_terminal_is_open (01c25a13f). The guest proof was not achieved, see below
P2b root ran in the account's cwd cd / in the snippet, plus current_dir("/") for relay steps and the rollback (8dbfc7a7d) red at 945d50f8c (red.log: 4 of 5 new tests FAILED), green at the head
P2c canary login before commit Not done; follow-up. It needs a PAM login unit as the person on the successor's private lane before commit, which the lane deliberately blocks (no logins in standby). Not cheap. Proposal: one terminal.startup-style canary allowed on the proof lane, then closed —
P3-1 /run leftovers; cp of a FIFO or a /dev/zero link The snippet copies only a regular non-link file (anything else reads as a mismatch) and sweeps infraternal-privilege.* dirs older than 20 min. The guard and the abandon unit remove the relay's own copy (8dbfc7a7d) red/green tests. The red run hung exactly as J465 said: root's cp blocked on the test FIFO
P3-2 a hash mismatch counted as a failed try Settled::Mismatch counts as reaching root, so the count resets a_digest_mismatch_counts_as_reaching_root
P3-3 finish didn't re-check the admin window reserve_try requires s.fresh a_try_needs_the_admin_window_still_open
P3-4 generation not re-checked before the handover covered by the P2a re-check —
P3-6 relay-token doc says non-dumpable, not Yama; a same-uid reader can only cause a DoS —
also the next try sweeps stale privilege-* socket dirs in the account's sessions dir (found by p7, c3089cbce) stale_relay_directories_are_swept

Skipped, with reasons:

  • P3-5 mlock: adds unsafe and memory-limit handling for a ≤60 s buffer in a non-dumpable process.
  • P3-5 control characters: this is a browser-side refusal, and the browser sealer (slice 3) isn't built.
  • P3-6 /docs/privileges statement: stays tracked for slice 4.
  • P2-3 unit hardening: not done here (the orchestrator files it).

A wrong turn, reverted: 8ce56ed91 removed /var/lib/infraternal.bootstrap-cache after a failed upgrade. The widened snapshot showed that cache already existed before the upgrade on the image, so removing it made the host differ. Reverted in 7b93e4e1d.

Guest proofs (AlmaLinux 10.2, Enforcing, fresh overlay per run, faillock on; the snapshot now also covers /var/lib/infraternal.bootstrap-cache)

Run Build What Result
p6 7b93e4e1d relay SIGKILLed once the row is enrolled (after start) promotion abandoned, cleanup done. The abandon unit ran: rollback exit Some(0). Host diff 0 at 30 s, nobody retried. No root unit, /usr/local/bin/infraternal or /run/infraternal-privilege.*. AVC 0
p6i 7b93e4e1d relay SIGKILLed during the installing step (unit written, not started, so no successor can run) the guard was armed (undo, boot unit, watcher). The watcher ran undo. Host diff 0 at 30 s. AVC 0. This is the case the first round-2 run showed failing before the guard (p6-8ce56ed91: 29 lines left)
p7 704ea1ebf guest rebooted (reboot -ff) once enrolled after boot, every root-side object is gone (unit, journal, launcher, guard, /run). One diff line remained: the account's own empty sessions/privilege-<attempt> dir, which the agent dies holding. c3089cbce sweeps it at the next try, so it lasts until then
p5 704ea1ebf a terminal opened mid-install not proven on the guest. Four attempts (evidence/p5-try1..4, p5). (1) A shim started by hand is invisible to the census by design: lifecycle.rs only counts shims the agent opened or found at startup. (2) The test browser couldn't open a second shell mid-install: there was no "New shell" on the ended strip, and the palette in a fresh page showed "No hosts yet". The gate itself is unit-tested above. These runs did reconfirm the success path: handed_over, rootful, retire done, AVC 0
all the password in the guest journal, the server log, the DB and the evidence files 0

Evidence j416-r2-evidence.tgz is attached. Raw files are in ~claude/.cache/codex-scratch/j416/{red.log,green.log,evidence/} on compute01.

macOS (owner's question, 10:50 IST)

What the branch does: a Mac is never turned into a root agent.

  • privilege_agent (elevation, relay, take-over, abandon) compiles only on Linux, so a Mac agent can't elevate at all.
  • To give a clear answer instead of a timeout, probe and begin now read the host's facts and refuse with outcome: "macos": "On a Mac, infraternal doesn't need a root agent. To use the mesh, run infraternal up on the Mac once; it asks for your password one time to install its system service." (d239fa4e6, test a_mac_is_never_upgraded_to_a_root_agent).
  • Chosen: refuse, because it is smaller and adds no new privileged path. Rejected: a browser-driven one-time sudo that installs the mesh service; infraternal up already does exactly that with one prompt.

What on macOS really needs root (from the code):

  1. Mesh tunnel: creating the utun device (a PF_SYSTEM socket on com.apple.net.utun_control, macos_node.rs) and its routes. This runs in the root launchd daemon org.infraternal.mesh (/Library/LaunchDaemons/org.infraternal.mesh.plist, macos_service.rs), installed and started by mesh up / infraternal up. It tries sudo -n first and prompts only when sudoers doesn't already allow it.
  2. Mesh DNS: one managed file per suffix in /etc/resolver, written "only when root" (dns_integration.rs:43), so it happens inside that same service.
  3. That service's binary must be root-owned and not writable by others (macos_service::trusted_executable). Putting it in a root-owned place is part of the same one-time sudo step.
  4. Terminals as a different account: a rootless shim refuses to change user ("rootless shim cannot change user", pty.rs:505). Only a root shim can setgroups/setgid/setuid into another account. Terminals as the agent's own account need no root.

Not root on a Mac:

  • terminals as the person's own account;
  • the agent itself, which stays rootless;
  • desktop capture, which uses ScreenCaptureKit and needs the user's Screen Recording grant (TCC), not root;
  • person login units. There is no Mac equivalent: the systemd PAMName= login path is Linux-only.

So the answer is: with one sudo password at infraternal up, a Mac needs root only for the mesh service (tunnel, routes, /etc/resolver). The single exception is opening terminals as an account other than the agent's own, which would need a root shim.

Gate, verbatim

scripts/verify fast, compute01, fast start 2026-11-10T11:52:25Z head e8620738b base d7b2e4dc1:

FAST_EXIT=0
+---------------------------+--------------+
| Rust gate                 | Result       |
+---------------------------+--------------+
| Nix Cargo vendoring       | PASS         |
| Nix release inputs        | PASS         |
| fmt                       | PASS         |
| migration names           | PASS         |
| migration tooling tests   | PASS         |
| VT native fmt             | PASS         |
| CLI plugin compile-out    | PASS         |
| clippy                    | PASS         |
| SSH runtime test helper   | PASS         |
| real-binary fixture paths | PASS         |
| Traefik component manifest | PASS         |
| release component publication | PASS         |
| test                      | PASS         |
| SSH inode reuse fixture   | PASS         |
| shim process-per-user     | PASS         |
| cargo-deny                | PASS         |
| gate architecture         | PASS         |
| doc coverage ratchet      | PASS         |
| shape lints off (D351)    | PASS         |
| doc comments on diff      | PASS         |
| fixed test waits          | PASS         |
| privileged integration    | PASS         |
| probe release exclusion   | PASS         |
| nightly fuzz reporter     | PASS         |
| nightly browser suite reporter | PASS         |
| static builds             | PASS         |
+---------------------------+--------------+

==> Compiler metadata generator checks
(pass) every shared Mesh action vector passes through the typed client unchanged [47.12ms]
(pass) shared Mesh event vectors drive the typed watch adapter [8.99ms]
 50 pass
 0 fail
Ran 50 tests across 13 files. [6.48s]
FAST_DONE 2026-11-10T13:12:00Z

Static size (report only): x86_64 29 223 608 B, +955 168 B vs baseline.

## J416 round 2 finish (J465 review, comment 89390) **Branch `root-upgrade-d376`, head `e8620738b`** (`git ls-remote`: `e8620738b2ebfad595ea99f36aec5f02d58c7cd5 refs/heads/root-upgrade-d376`). It contains origin/dev `d7b2e4dc1`. Not merged, not deployed. ### What changed | Finding | Fix | Proof | |---|---|---| | **P1** a relay killed after enrollment left a running, enabled root agent | Two layers. (a) The promotion records its install transaction (migration `remote_promotion_transaction`). The uninstall outbox then sends the abandoned successor `privilege.abandon {host, transaction}`, and the successor starts a transient unit, `infraternal --promotion-abandon`: it rolls back exactly that transaction with the install's own root rollback, uninstalls if the install had committed, and sweeps `/run`. Only ids cross the wire (`d239fa4e6`). (b) A host-side guard, armed by the relay when it receives its Rollback frame: an `undo` script in `/var/lib/infraternal-promotion/`, a oneshot unit enabled for boot, and a transient watcher unit outside the relay's process tree that runs `undo` when the relay's pid ends. `Commit` or the relay's own rollback disarms it (`0045a1534`, `ac96a3684`, `704ea1ebf`) | guest p6, p6i, p7 below | | **P2a** open terminals checked only at begin | Re-checked, together with the bound link generation (P3-4), right before the install commits and right before the handover. A failure abandons, and a committed install is removed by the P1 outbox (`d239fa4e6`) | unit test `the_upgrade_stops_while_a_terminal_is_open` (`01c25a13f`). **The guest proof was not achieved**, see below | | **P2b** root ran in the account's cwd | `cd /` in the snippet, plus `current_dir("/")` for relay steps and the rollback (`8dbfc7a7d`) | red at `945d50f8c` (`red.log`: 4 of 5 new tests FAILED), green at the head | | **P2c** canary login before commit | **Not done; follow-up.** It needs a PAM login unit as the person on the successor's private lane before commit, which the lane deliberately blocks (no logins in standby). Not cheap. Proposal: one `terminal.startup`-style canary allowed on the proof lane, then closed | — | | **P3-1** `/run` leftovers; `cp` of a FIFO or a `/dev/zero` link | The snippet copies only a regular non-link file (anything else reads as a mismatch) and sweeps `infraternal-privilege.*` dirs older than 20 min. The guard and the abandon unit remove the relay's own copy (`8dbfc7a7d`) | red/green tests. **The red run hung exactly as J465 said:** root's `cp` blocked on the test FIFO | | **P3-2** a hash mismatch counted as a failed try | `Settled::Mismatch` counts as reaching root, so the count resets | `a_digest_mismatch_counts_as_reaching_root` | | **P3-3** `finish` didn't re-check the admin window | `reserve_try` requires `s.fresh` | `a_try_needs_the_admin_window_still_open` | | **P3-4** generation not re-checked before the handover | covered by the P2a re-check | — | | **P3-6** relay-token doc | says non-dumpable, not Yama; a same-uid reader can only cause a DoS | — | | also | the next try sweeps stale `privilege-*` socket dirs in the account's sessions dir (found by p7, `c3089cbce`) | `stale_relay_directories_are_swept` | **Skipped, with reasons:** - **P3-5 mlock:** adds `unsafe` and memory-limit handling for a ≤60 s buffer in a non-dumpable process. - **P3-5 control characters:** this is a browser-side refusal, and the browser sealer (slice 3) isn't built. - **P3-6 `/docs/privileges` statement:** stays tracked for slice 4. - **P2-3 unit hardening:** not done here (the orchestrator files it). A wrong turn, reverted: `8ce56ed91` removed `/var/lib/infraternal.bootstrap-cache` after a failed upgrade. The widened snapshot showed that cache **already existed before the upgrade** on the image, so removing it made the host differ. Reverted in `7b93e4e1d`. ### Guest proofs (AlmaLinux 10.2, **Enforcing**, fresh overlay per run, faillock on; the snapshot now also covers `/var/lib/infraternal.bootstrap-cache`) | Run | Build | What | Result | |---|---|---|---| | p6 | `7b93e4e1d` | relay SIGKILLed once the row is `enrolled` (after start) | promotion `abandoned`, cleanup `done`. The abandon unit ran: `rollback exit Some(0)`. **Host diff 0 at 30 s**, nobody retried. No root unit, `/usr/local/bin/infraternal` or `/run/infraternal-privilege.*`. AVC 0 | | p6i | `7b93e4e1d` | relay SIGKILLed during the `installing` step (unit written, not started, so no successor can run) | the guard was armed (`undo`, boot unit, watcher). **The watcher ran `undo`. Host diff 0 at 30 s**. AVC 0. This is the case the first round-2 run showed failing before the guard (`p6-8ce56ed91`: 29 lines left) | | p7 | `704ea1ebf` | guest **rebooted** (`reboot -ff`) once `enrolled` | after boot, every root-side object is gone (unit, journal, launcher, guard, `/run`). One diff line remained: the account's own empty `sessions/privilege-<attempt>` dir, which the agent dies holding. `c3089cbce` sweeps it at the next try, so it lasts until then | | p5 | `704ea1ebf` | a terminal opened mid-install | **not proven on the guest.** Four attempts (`evidence/p5-try1..4`, `p5`). (1) A shim started by hand is invisible to the census by design: `lifecycle.rs` only counts shims the agent opened or found at startup. (2) The test browser couldn't open a second shell mid-install: there was no "New shell" on the ended strip, and the palette in a fresh page showed "No hosts yet". The gate itself is unit-tested above. These runs did reconfirm the success path: `handed_over`, rootful, retire `done`, AVC 0 | | all | | the password in the guest journal, the server log, the DB and the evidence files | **0** | Evidence `j416-r2-evidence.tgz` is attached. Raw files are in `~claude/.cache/codex-scratch/j416/{red.log,green.log,evidence/}` on compute01. ### macOS (owner's question, 10:50 IST) **What the branch does:** a Mac is never turned into a root agent. - `privilege_agent` (elevation, relay, take-over, abandon) compiles only on Linux, so a Mac agent can't elevate at all. - To give a clear answer instead of a timeout, probe and begin now read the host's facts and **refuse with `outcome: "macos"`**: "On a Mac, infraternal doesn't need a root agent. To use the mesh, run `infraternal up` on the Mac once; it asks for your password one time to install its system service." (`d239fa4e6`, test `a_mac_is_never_upgraded_to_a_root_agent`). - **Chosen:** refuse, because it is smaller and adds no new privileged path. **Rejected:** a browser-driven one-time sudo that installs the mesh service; `infraternal up` already does exactly that with one prompt. **What on macOS really needs root (from the code):** 1. **Mesh tunnel:** creating the utun device (a `PF_SYSTEM` socket on `com.apple.net.utun_control`, `macos_node.rs`) and its routes. This runs in the root launchd daemon `org.infraternal.mesh` (`/Library/LaunchDaemons/org.infraternal.mesh.plist`, `macos_service.rs`), installed and started by `mesh up` / `infraternal up`. It tries `sudo -n` first and prompts only when sudoers doesn't already allow it. 2. **Mesh DNS:** one managed file per suffix in `/etc/resolver`, written "only when root" (`dns_integration.rs:43`), so it happens inside that same service. 3. **That service's binary must be root-owned and not writable by others** (`macos_service::trusted_executable`). Putting it in a root-owned place is part of the same one-time sudo step. 4. **Terminals as a different account:** a rootless shim refuses to change user ("rootless shim cannot change user", `pty.rs:505`). Only a root shim can `setgroups`/`setgid`/`setuid` into another account. Terminals as the agent's own account need no root. Not root on a Mac: - terminals as the person's own account; - the agent itself, which stays rootless; - desktop capture, which uses ScreenCaptureKit and needs the user's Screen Recording grant (TCC), not root; - person login units. There is no Mac equivalent: the systemd `PAMName=` login path is Linux-only. **So the answer is:** with one sudo password at `infraternal up`, a Mac needs root only for the mesh service (tunnel, routes, `/etc/resolver`). The single exception is opening terminals as an account other than the agent's own, which would need a root shim. ### Gate, verbatim `scripts/verify fast`, compute01, `fast start 2026-11-10T11:52:25Z head e8620738b base d7b2e4dc1`: ``` FAST_EXIT=0 +---------------------------+--------------+ | Rust gate | Result | +---------------------------+--------------+ | Nix Cargo vendoring | PASS | | Nix release inputs | PASS | | fmt | PASS | | migration names | PASS | | migration tooling tests | PASS | | VT native fmt | PASS | | CLI plugin compile-out | PASS | | clippy | PASS | | SSH runtime test helper | PASS | | real-binary fixture paths | PASS | | Traefik component manifest | PASS | | release component publication | PASS | | test | PASS | | SSH inode reuse fixture | PASS | | shim process-per-user | PASS | | cargo-deny | PASS | | gate architecture | PASS | | doc coverage ratchet | PASS | | shape lints off (D351) | PASS | | doc comments on diff | PASS | | fixed test waits | PASS | | privileged integration | PASS | | probe release exclusion | PASS | | nightly fuzz reporter | PASS | | nightly browser suite reporter | PASS | | static builds | PASS | +---------------------------+--------------+ ==> Compiler metadata generator checks (pass) every shared Mesh action vector passes through the typed client unchanged [47.12ms] (pass) shared Mesh event vectors drive the typed watch adapter [8.99ms] 50 pass 0 fail Ran 50 tests across 13 files. [6.48s] FAST_DONE 2026-11-10T13:12:00Z ``` Static size (report only): x86_64 29 223 608 B, +955 168 B vs baseline.
Author
Owner

J477 review started. Branch root-upgrade-d376 at e8620738b2, based on origin/dev d7b2e4dc1b. Plan (estimate 2 hours): audit guard files/units and cleanup, privilege.abandon authorization/replay handling, the terminal re-check path, and the +955,168-byte static delta; obtain a fresh Enforcing AlmaLinux guest and rerun p6/p6i/p7; attempt the mid-install terminal proof through the infraternal agent path; attach raw evidence and post a verdict. No branch edits or push.

J477 review started. Branch root-upgrade-d376 at e8620738b2ebfad595ea99f36aec5f02d58c7cd5, based on origin/dev d7b2e4dc1bf767787b05e4ce287d885841f7aa5f. Plan (estimate 2 hours): audit guard files/units and cleanup, privilege.abandon authorization/replay handling, the terminal re-check path, and the +955,168-byte static delta; obtain a fresh Enforcing AlmaLinux guest and rerun p6/p6i/p7; attempt the mid-install terminal proof through the infraternal agent path; attach raw evidence and post a verdict. No branch edits or push.
Author
Owner

J477 finding P1 — host guard setup is best-effort, but install is not blocked when it fails. At e8620738b, privilege_relay_host.rs:438-488 writes/truncates the undo file and unit in place, returns silently if the undo write fails, ignores directory/symlink/daemon-reload/watcher-start failures, and documents arming as best effort. serve() at :302-306 ignores arm_guard’s result and proceeds to accept install steps. Thus a staging failure such as inability to write/enable either guard layer can leave no durable boot undo and no live watcher while install continues; if the relay then dies before successor enrollment (p6i), the abandon outbox has no successor to run. The unit is also written with std::fs::write at :462 (no O_NOFOLLOW/atomic replace), and the undo file is create+truncate rather than temp+fsync+rename. Evidence is the target source at e8620738b; I am checking this against fresh p6/p6i/p7 guest runs.

J477 finding P1 — host guard setup is best-effort, but install is not blocked when it fails. At e8620738b, privilege_relay_host.rs:438-488 writes/truncates the undo file and unit in place, returns silently if the undo write fails, ignores directory/symlink/daemon-reload/watcher-start failures, and documents arming as best effort. serve() at :302-306 ignores arm_guard’s result and proceeds to accept install steps. Thus a staging failure such as inability to write/enable either guard layer can leave no durable boot undo and no live watcher while install continues; if the relay then dies before successor enrollment (p6i), the abandon outbox has no successor to run. The unit is also written with std::fs::write at :462 (no O_NOFOLLOW/atomic replace), and the undo file is create+truncate rather than temp+fsync+rename. Evidence is the target source at e8620738b; I am checking this against fresh p6/p6i/p7 guest runs.
Author
Owner

J477 finding P1 — abandon outbox records unit start, not cleanup completion

At the reviewed SHA e8620738b2ebfad595ea99f36aec5f02d58c7cd5, privilege_take_over::abandon returns {"accepted":true} as soon as systemd-run successfully starts the transient unit (crates/infraternal-agent/src/privilege_take_over.rs:124-148). remote_successor::deliver_uninstall treats that reply as completion and changes the durable outbox to cleanup='done', revoking the successor certificate (crates/infraternal-server/src/remote_successor.rs:173-203; crates/infraternal-server/src/remote_promotion.rs:759-789). The actual cleanup runs later in --promotion-abandon; it prints but ignores the rollback exit status, and prints uninstall failed without returning an error (crates/infraternal/src/remote.rs:1284-1332).

The relay removes the guard on its Commit frame before the server commits the database handover (crates/infraternal-agent/src/privilege_relay_host.rs:344-363; crates/infraternal-server/src/host_promotion.rs:793-835). If handover then fails and the transient rollback/uninstall fails after its start acknowledgement, the outbox is permanently done, the successor certificate is revoked, and there is no remaining retry path. The enabled root service can be left installed but unable to reconnect. The normal p6 success path does not exercise a post-start cleanup failure.

J477 finding P1 — abandon outbox records unit start, not cleanup completion At the reviewed SHA `e8620738b2ebfad595ea99f36aec5f02d58c7cd5`, `privilege_take_over::abandon` returns `{"accepted":true}` as soon as `systemd-run` successfully starts the transient unit (`crates/infraternal-agent/src/privilege_take_over.rs:124-148`). `remote_successor::deliver_uninstall` treats that reply as completion and changes the durable outbox to `cleanup='done'`, revoking the successor certificate (`crates/infraternal-server/src/remote_successor.rs:173-203`; `crates/infraternal-server/src/remote_promotion.rs:759-789`). The actual cleanup runs later in `--promotion-abandon`; it prints but ignores the rollback exit status, and prints `uninstall failed` without returning an error (`crates/infraternal/src/remote.rs:1284-1332`). The relay removes the guard on its `Commit` frame before the server commits the database handover (`crates/infraternal-agent/src/privilege_relay_host.rs:344-363`; `crates/infraternal-server/src/host_promotion.rs:793-835`). If handover then fails and the transient rollback/uninstall fails after its start acknowledgement, the outbox is permanently done, the successor certificate is revoked, and there is no remaining retry path. The enabled root service can be left installed but unable to reconnect. The normal p6 success path does not exercise a post-start cleanup failure.
Author
Owner

J477 finding P2 — the guard watcher can act on a later transaction

At e8620738b2ebfad595ea99f36aec5f02d58c7cd5, every relay writes its rollback into the same /var/lib/infraternal-promotion/undo (crates/infraternal-agent/src/privilege_relay_host.rs:400-415, 438-456). The watcher contains only the relay PID and, after kill -0 stops succeeding, executes whatever script currently exists at that shared path (:471-485). It does not bind the script to the PID's start time or the install transaction. If watcher A is delayed (including PID reuse) until promotion B has overwritten undo, A can execute B's rollback while B is still installing. That can tear down a different, valid promotion. The unit and directory names are also global, so a later arm can overwrite them.

This is a transaction-isolation race by inspection; the single-attempt p6/p6i/p7 proofs cannot exercise it. The normal account cannot write the root-owned paths directly, but a retry after a relay exits can overlap a delayed watcher. Each attempt needs an immutable guard path and a watcher bound to that attempt, with stale cleanup unable to read a newer script.

J477 finding P2 — the guard watcher can act on a later transaction At `e8620738b2ebfad595ea99f36aec5f02d58c7cd5`, every relay writes its rollback into the same `/var/lib/infraternal-promotion/undo` (`crates/infraternal-agent/src/privilege_relay_host.rs:400-415, 438-456`). The watcher contains only the relay PID and, after `kill -0` stops succeeding, executes whatever script currently exists at that shared path (`:471-485`). It does not bind the script to the PID's start time or the install transaction. If watcher A is delayed (including PID reuse) until promotion B has overwritten `undo`, A can execute B's rollback while B is still installing. That can tear down a different, valid promotion. The unit and directory names are also global, so a later arm can overwrite them. This is a transaction-isolation race by inspection; the single-attempt p6/p6i/p7 proofs cannot exercise it. The normal account cannot write the root-owned paths directly, but a retry after a relay exits can overlap a delayed watcher. Each attempt needs an immutable guard path and a watcher bound to that attempt, with stale cleanup unable to read a newer script.
Author
Owner

J477 review started. Target: branch root-upgrade-d376 at e8620738b2; review base: origin/dev merge-base d7b2e4dc1b. Plan (estimate 90 minutes): inspect the root guard and abandon authorization/lifecycle; rerun p6, p6i and p7 on a fresh Enforcing AlmaLinux guest; attempt the terminal-open-mid-install proof through the product path; attribute the +955 KiB; post an evidence-backed verdict. I canceled the stale queued p6 runner before provisioning; it had not started a guest.

J477 review started. Target: branch root-upgrade-d376 at e8620738b2ebfad595ea99f36aec5f02d58c7cd5; review base: origin/dev merge-base d7b2e4dc1bf767787b05e4ce287d885841f7aa5f. Plan (estimate 90 minutes): inspect the root guard and abandon authorization/lifecycle; rerun p6, p6i and p7 on a fresh Enforcing AlmaLinux guest; attempt the terminal-open-mid-install proof through the product path; attribute the +955 KiB; post an evidence-backed verdict. I canceled the stale queued p6 runner before provisioning; it had not started a guest.
Author
Owner

J477 additional P1 — delayed abandon can uninstall a later promotion's root agent

At e8620738b2, EnrollmentStore::begin_promotion inserts for a rootless host and only excludes live states; the unique index covers open/elevating/installing/enrolled, not abandoned. It does not reject a prior row with cleanup='uninstall' (remote_promotion.rs:254-282; migration 20261109081614_remote_host_promotions.sql:72-75).

For an abandoned A, admit_successor still admits A's successor with Uninstall purpose while cleanup is pending (remote_promotion.rs:560-588), and deliver_uninstall sends privilege.abandon {host, transaction_A} (remote_successor.rs:162-203). The agent acknowledges as soon as systemd-run --service-type=exec has executed the cleanup binary, before cleanup finishes (privilege_take_over.rs:116-148); the server then records cleanup done and revokes A's certificate (remote_promotion.rs:759-783).

The host command rolls back transaction A, then uninstalls by host owner alone when .bootstrap-owner matches and .bootstrap-lock is absent; that uninstall plan is not bound to transaction A (remote.rs:1284-1327). Since B can begin while A's outbox is pending, A's already-started cleanup can wait on B's install lock, then, after B commits and releases it, no-op A's rollback and uninstall B's current root install. This is an internal delayed-outbox race, not a forged network message.

Block a new promotion while any earlier successor cleanup is pending, and make the host uninstall conditional on the exact still-owned install transaction. Also keep the outbox pending until cleanup reports completion.

J477 additional P1 — delayed abandon can uninstall a later promotion's root agent At e8620738b2ebfad595ea99f36aec5f02d58c7cd5, EnrollmentStore::begin_promotion inserts for a rootless host and only excludes live states; the unique index covers open/elevating/installing/enrolled, not abandoned. It does not reject a prior row with cleanup='uninstall' (remote_promotion.rs:254-282; migration 20261109081614_remote_host_promotions.sql:72-75). For an abandoned A, admit_successor still admits A's successor with Uninstall purpose while cleanup is pending (remote_promotion.rs:560-588), and deliver_uninstall sends privilege.abandon {host, transaction_A} (remote_successor.rs:162-203). The agent acknowledges as soon as systemd-run --service-type=exec has executed the cleanup binary, before cleanup finishes (privilege_take_over.rs:116-148); the server then records cleanup done and revokes A's certificate (remote_promotion.rs:759-783). The host command rolls back transaction A, then uninstalls by host owner alone when .bootstrap-owner matches and .bootstrap-lock is absent; that uninstall plan is not bound to transaction A (remote.rs:1284-1327). Since B can begin while A's outbox is pending, A's already-started cleanup can wait on B's install lock, then, after B commits and releases it, no-op A's rollback and uninstall B's current root install. This is an internal delayed-outbox race, not a forged network message. Block a new promotion while any earlier successor cleanup is pending, and make the host uninstall conditional on the exact still-owned install transaction. Also keep the outbox pending until cleanup reports completion.
Sign in to join this conversation.
No labels
launch-qa
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
kayg/infraternal#399
No description provided.