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
No Branch/Tag specified
main
j470b-server-mesh-rejoin
mesh-compat-382
j444b-d381-key-wins-flood
j461b-macos-utun-reader-thread
j475b-remove-host-motion
fix-reconnect-new-link
j481-d380
dev
j479-mesh-soak
fix/j476-host-facts
j475-motion-pill-split
j470-mac-node-join
root-upgrade-d376
j474-review-merge
latency-targets-120hz
nightly-suite-shards
gotatun-pin-j469
macos-utun-reader-thread
fix-flood-echo-stall
wip/j416
motion-fast-ease-out
mesh-addhost-j259
takeover-close-terminals-d368
kbd-instant-followups
fix-trust-key-double-click
nightly-suite-partial-report
appwin-icons-j207
rusqlite-slice1
swipe-content-first-d374
mesh-mtu-381
addhost-instant-open
glass-contrast-d365-mock
glass-contrast-d365
signout-followups-442
fix-startup-toast-red
research-gotatun-matrix-j317
themes-sync-j304
fix-load-flakes
fix-nix-zig-patch
fix-compute01-browser-baseline
ratchet-size-baseline-419
predict-auto-d130
revoke-coverage-d367
nightly-suite-queue-timeout
nightly-browser-suite
fix-rustsec-2026-0459
fonts-ioskeley-obel
log-retention-d375
fix-kbd-no-anim-427
fix-dev-red-specs-2
takeover-proof-305-j205
wip/j410-server-pump
ui-consistency-d372
fix-osc52-clipboard
libghostty-vt-1.0
forget-retired-host-d369
j404-merge-426
fix-setup-lock-race
fix-touch-select-426
revoke-access-report-ui
fix-predictive-echo-specs
fix-selinux-agent-exec
wip/j395-checkpoint2
wip/j395
j391-review-scratch
ui-consistency-j195-deferred-rebased
fix-rustc-paths-421
decisions-wording-341
fix-install-suite-red
fix-dev-rtt-specs
fix-pending-removal-align
fix-privileged-skip
fix-install-suite-404
review-j368
mesh-dns-j254
revoke-access-report-d356b
revoke-privilege-round5
fix-removal-confirm
review-j360
gotatun-rotation-367
deps-refresh-340
review-j354
review-J351
review-j350
addhost-takeover-d359
revoke-report-items-347
revoke-end-sessions-d362
review-j348
size-mesh-ssh-346
size-trim-337
revoke-access-diff-d356
j259-pre-rebase-b06c5a6
j305-ended-at-start-spec
addhost-sudo-consent
fix-layout-real-specs-411
fix-shell-open-411
fix-console-hosts-373
fix-shell-exit
fuzz-host-name-353
fix-host-details-410
macos-filter-j197
research-gotatun-tricks
revoke-p3-j216l
stale-state-pill
revoke-followups-j216k
revoke-progress-j216e
backup/macos01-20261106/lume/j197
backup/macos01-20261106/lume/appwin-icons-j207-stash0
backup/macos01-20261106/claude/stash-0
backup/macos01-20261106/claude/stash-1
backup/macos01-20261106/claude/J80-macos-s1
backup/macos01-20261106/claude/J80
backup/macos01-20261106/claude/J80-slice1-20261102
backup/macos01-20261106/claude/J16b-appwin-on-dev
backup/macos01-20261106/claude/appwin-launch-idle-146
backup/macos01-20261106/claude/J80-s1-run
wip/j259
trust-285-p3c
runtime-lock-j216f
mesh-ssh-p3-j175
tun-proof-p3b
trust-285-p3
terminal-polish-j271
tun-proof-p3
ssh-install-flakes-j216i
fuzz-fixes-j264
trust-285-a
trust-285-b
restart-oversized-fixture-j198
backup/macos01-20261106/lume/appwin-icons-j207
restart-bound-j216h
up-preflight-p3-j198
backup/macos01-20261106/lume/j251
up-preflight-j198
wip/up-preflight-j198-j245-p2
wip/up-preflight-j198-j245-p1
agent-fsync-offloop-j196
wip/up-preflight-j198-j245
settings-offscreen-j231
wip/up-preflight-j198-j226-inspection
wip/j103f
themes-j206
wip/up-preflight-j198-j226
flakes-j216c
backup/macos01-20261106/claude/j197
flakes-j216b
backup/macos01-20261106/claude/j197-red
wip/up-preflight-j198-review
flakes-j216
backup/macos01-20261106/claude/appwin-icons-j207
engineering-j208b
appwin-mock-j209
toast-stack-j165
engineering-j208
decisions-d351
decisions-d350
decisions-d349
appearance-presets-mock-j129
decisions-d348
docs-audit-spec
doc-coverage-baseline-raise
decisions-d347
decisions-d346b
decisions-d346
j88h-profile
ui-consistency-j195-deferred
ui-consistency-j195
engineering-practices-j194
preferred-login-ui-j166
signin-pools-263-j174
macos-node-265-s1
decisions-d345
decisions-d344
proxy-cgroup-j164
verify-quick-fuzz-329
proxy-start-io-j168
timing-flakes-j163
up-263-s1
mcp-events-j147
verify-quick-fuzz-j158
proxy-p3-j159
agent-resource-j157-detect
wip/j103d-final
remote-cancel-hang-j155
proxy-traefik-281-j62d
j149-verify-quick
proxy-traefik-281-j62b
j150-drop-unnamed
wip/j79d-red-r4
wip/j103d-d332
rootless-toast-j142
wip/j103d-head
wip/j103d
j125c-mcp
macos-build-j136
backup/macos01-20261106/claude/j136
wip/j79d-red-cli
wip/j79d-red-server
j121-link-liveness
j118c-tracer-failures
wip/j79c-head
release-desktop-j131
j124c-signed-macos
wip/j79c-red-p3
wip/j79c-red-p23
wip/j79c-red-p22
merge-j128-check
perf-deferred-j128
sidebar-toggle-260
j127-shortcut-changed-dot
j125-mcp
j118-tracer-failures
j126-account-summary
settings-sections-mock-j92
j120-pinch-lock
sidebar-292
backup/macos01-20261106/claude/j120
taste-2026-11-03
row-actions-242-215
backup/macos01-20261106/claude/stash-0-untracked
backup/macos01-20261106/claude/J80-265-s1-run
backup/macos01-20261106/claude/J80-macos-s1-20261102
j112-canvas-commands
ui-no-overshoot-scroll
fix-recovery-restart
backup/macos01-20261106/claude/stash-1-untracked
taste-2026-11-02
size-baseline-ratchet
machine-cards-structure-j108
j109-boot-copy-mockups
fix-dev-red
wip/up-263-s1
j95b-settings-rows
proxy-traefik-281
j95-option-rows
docs-human-j91
docs-keys-palette
fix-flood-1013-270-j38d
mesh-ssh-s1
mesh-ssh-s0
addhost-d291-tunnel
test-stability-load
mesh-relay-220
repro-296
shim-thp-off-24
mesh-pins-258-v2
mesh-dns
signin-d311-merged
docs-pages
ui-size-283
fix-ui-batch-specs
wip/j12b-edge-update
gate-on-arm
fix-rename-sync-293
trust-285-j60b
fix-focus-ring-clip
integ-ui-1101
edge-update-v2
signin-d311
taste-2026-11-01
layout-per-tab-250
ui-approved-splitopt-224
ui-approved-splitopt-224-on-dev
icon-text-centre-282
ui-approved-189-212-216
research-reverse-proxy
transfer-budget-320
review-mesh-pins-258-r2
sidebar-pill-259
review-mesh-relay-220
wip/j12-edge-update-v2
nix-package
status-crash-loop-237
fix-dev-test-failures
fix-fixture-socket-path
wip/mesh-relay-220-pre-dev-advance-rebase
fix-canvas-side-clip
research-head-to-head
frosted-real-256
review-mesh-pins-258
wip/mesh-relay-220-pre-final-rebase
review-layout-per-tab-250-r2
broad-suite-triage
nix-devshell
review-status-crash-loop-237-r3
wip/nix-devshell-pre-p3
taste-frosted-real
wip/code-serve-demo-2026-10-30
wip/code-serve-glass-2026-10-30
wip/code-serve-clusters-2026-10-30
wip/code-merge-release94-dev-2026-10-30
wip/rproxy-delta-scratch-2026-10-30
wip/stale-note-257-staged-2026-10-30
wip/d241-size-staged-2026-10-30
review-broad-suite-207
wip/mesh-pins-258-pre-split
wip/mesh-relay-220-pre-rebase
mesh-measure-220
plan-211-update
review-edge-autoupdate-r3
perf24-reconnect
review-stale-note-r2
release-prod-live
d241-shell-marker
review-mesh-pins-server-258
review-revocation-r3
review-setup-token-r2
review-sync-journal-r3
wip/review-mesh-pins-258-prune-review-1995fee46
frosted-scroll-256
sync-journal-r3
revocation-per-account-wip
review-tokio-asyncfd-0386
review-sync-p3s
wip/stash-8by64-stash0-2026-10-30
mesh-drag-r2
setup-token-data-wip
release-9-4
auth-busy-1013
mesh-view-overlap
sync-p3s
review-auth-busy-wake
review-readd-r8-wip
mesh-drag-bounds
audit-auth-93
app-windows-stream-start
review-authority-cap-r3
app-windows-r7-measure
readd-minimal-r6
readd-minimal-r5
app-windows-r7-soak
readd-minimal-r4
app-windows-r7
app-windows-r6
readd-minimal-r3
readd-minimal-r2
readd-minimal
merge-release8-dev
review-busy-page-liveness-r2
review-busy-page-liveness
mesh-view-trim
review-authority-read-retry-r2
review-remove-uninstall-r2
release-retry-reattach
release-retry
review-remove-uninstall
review-authority-read-retry
app-windows-ship
mesh-autoswitch-p2
mesh-ssh
mesh-relay
mesh-macos-node
screen-and-control
session-labels
review-real-logins-r5
review-real-logins-r4
review-real-logins-r3
shim-memory-r2
review-real-logins-r2
shim-memory
shadow-addhost-instant
shadow-real-logins
shadow-same-session-flood
shadow-prod-perf-fix
hosts-from-server
app-windows-real
app-windows-transport
release-prod-6w
mesh-policy-verify
quiet-close-toast
dev-merge-r4
review-mesh-agent-transport
review-app-windows-transport
settings-roundtrip
review-release-pipeline-r4
mesh-agent-transport
ended-strip-fix
review-audit-ca-update-d244
settings-rows
phone-width
signout-clear
fix-restart-tests
dev-merge-rp
signer-hardening
review-audit-ca-update
terminal-fidelity
review-audit-ca-update-wip
mesh-integrate
review-mesh-agent-transport-515d3712
review-mesh-dataplane-feec1988
mesh-dataplane
signout
research-canvas-node
wip/review-renderer-cost
wip/review-terminal-fidelity
padlock-glyph
review-release-pipeline-2e95b689
phone-polish
sidebar-polish
inbox-generic
review-mesh-dataplane-378d4055
prod-batch
ui-inbox
ui-ca-unlock
ui-machine-sections
review-mesh-dataplane-a5f744fb
tls-session-prod
session-bounds-prod
fix-dead-session
session-bounds
tls-session
palette-polish
shortcuts-d222
research-appmeta
phone-gutter
ui-centre-button
wip/ui-centre-button
wip/tls-session
review-addhost-breakit
ui-platform
renderer-cost
remote-agents
mesh-api
ssh-bootstrap
prod-hotfix2
prod-fixes
review-remote-agents
review-mesh-dataplane
session-life
research-browser
review-mesh-api
research-capture
review-mesh-api-round3
review-mesh-api-round1
gate-stability
review-gate-stability
review-relay-direct
review-mesh-api-findings
proto-sessions
font-picker
proto-addhost
proto-windows
mesh-core
latency-gate
research-bsd
research-discovery
keytray-mods
mock-core
ui-tweaks
fix-motion-sel
fix-motion
integrate2
fix-robust
t-web
t-server
vt-ghostty
fix-snap-echo
t-web-auth
review-vt2
t-agent
lane-protocol
lane-shim
lane-auth
s1-base
s1-contracts
s1-workspace
No results found.
No labels
launch-qa
Milestone
Clear milestone
No items
No milestone
Projects
Clear projects
No items
No project
Assignees
Clear assignees
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
Loading…
Reference in a new issue
No description provided.
Delete branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
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:apps/tracer/src/canvas/windows/access-level.on-demand.ts, #215, D286): consent, then password, then progress.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 noAdapters.privilege, so the row is not drawn.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.
J410 start (Claude Opus 5.5): D376 root upgrade.
root-upgrade-d376, based on origin/dev0414b7b2f.J410 plan: upgrading a host to the root infraternal agent (D376, D274, D139, D18)
Branch
root-upgrade-d376, base origin/dev0414b7b2f. Plan first, no product code yet. The slice estimates are at the end.What exists today (surveyed on
0414b7b2f)apps/tracer/src/canvas/windows/access-level.on-demand.ts). It runs consent, then the password, then progress, and it talks only toPrivilegeService(canvas/sessions/privilege.ts). The only implementation is the mock. The pane allows unlimited wrong tries, and production supplies noAdapters.privilege. The palette entry "Allow full access on " already exists.$base/.bootstrap-lock/(install.rsACTIVATE/START/ROLLBACK_TRANSACTION/FRESH_CLEANUP) andprevious_attempt.sh's recovery library (including D359's set-aside and put-back). It runs every step through theSshSessiontrait (run(script, input)).remote_hosts.modeis enforced in the schema asrootful ⇔ execution_uid = 0, and the code treats it as immutable.remote.rs:204). Server→agent RPC isFrame::Call/Result, andremote.host.uninstallis 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.ring0.17 andopenssl(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 inauth.ts), and X25519 ECDH is available in current Safari, Chromium and Firefox. No new library is needed.inbox-host.ts). The account switcher has no UI:preferred_host_accountsand thessh-accounts.get/setWS 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) plusremote_hosts.owner_id, so "host admin" means that until roles arrive.1. Endpoint and protocol behind
PrivilegeServiceHTTP, in the same style as host removal (
remote_removal_http.rs): exact Origin, unlocked session,bootstrap_owner, the host'sowner_id, the D333 admin window rechecked inside the writer transaction, a 32 KiBdeny_unknown_fieldsbody, 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?}.sealedis 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 pluginCallpath, not the 5 s Control allowlist:privilege.proberunssudo -n true, thensudo -n -l("a password is required" means sudo with a password, otherwisedenied), thendoas -n true, then falls back tosu. All binaries are given by absolute path.privilege.seal_key {attempt}returns the public half of a freshringEphemeralPrivateKey. 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
SshSessionfor this relay (RelaySession). It then runs the unchanged root-modeInstallation(stage, join, ACTIVATE, START, commit, rollback) over it. The promotion reuses the Add host transaction, its journal,previous_attempt.shrecovery and D359 put-back, instead of a second installer.2. Sealing format (v1)
The browser makes an ephemeral X25519 key pair
eand computesdh = X25519(e, seal_key)(WebCryptoderiveBits).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;nonceis 12 random bytes.0x01 ‖ e_pub(32) ‖ nonce(12) ‖ ciphertext‖tag, base64, with the password capped at 1 KiB.ring::agreement::agree_ephemeral, which consumes the private key, so each seal key decrypts at most once. The plaintext goes into aZeroizing<Vec<u8>>, is written once to the elevation PTY, then wiped. It never reaches argv, the environment, disk or a log.3. The way in, and wrong passwords
/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 withenv -i, its own process group and a 60 s ceiling.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.lockedandretry_afterfor 15 minutes. That is pam_faillock's defaultfail_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
/bin/shwith absolute paths)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.execinfraternal __privilege-relay, the digest-verified copy)privilege.runframes, exits on commit or rollback, has a 10 min hard ceiling, and dies with its parent (PR_SET_PDEATHSIG).PAMName=units, PTY masters for other accounts, the proxy's switch toinfraternal-proxy, and/etcSSH client files. These are the D274/D18 model with no separate helper.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>.sockand accepts exactly one peer, withSO_PEERCREDuid 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
remote_host_promotionsrow (host, attempt, person, method, from_uid, state, started_at) is written. Only one promotion per host can be open.CHECK_SCRIPTandprevious_attempt.shinspection the root add runs, so an interrupted earlier promotion is recovered or refused by the existing code.Installationwith 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.mode='rootful', execution_uid=0and revokes the rootless certificate. The rootless link is closed by the server.COMMIT_RETIREruns 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).Installation::rollbackruns over the relay (ROLLBACK_TRANSACTION/FRESH_CLEANUP), removing the half-installed root unit, binary, state and launcher and runningdaemon-reload. The rootless infraternal agent was never touched, so the host is as it was. The row endsrolled-back, with an activity entry and an Inbox entry.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."findwith mode, uid and SELinux label over/etc/systemd,/var/lib/infraternal,/usr/local/bin,/run/infraternal*and the account's~/.config/systemd/userand~/.local/share/infraternal, plussystemctl 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.adopt {uid, dir}from the relay (root-owned file/var/lib/infraternal/adopt/<uid>, written by the install step, never from a request).infraternal-login-locks/<person>-<id>.terminalfiles, 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).8. Accounts (slice 3), and what already exists
preferred_accountsandpreferred_host_accounts(migration 0032), resolved bypreferred_accounts::read. The order is: per-host choice → (rootless only) the SSH user → global choice → the infraternal username.ssh-accounts.get/setWS actions exist with no caller. D283's "Logs in as" card is mock-only.packages/uimenu): an "Open as" list showing the person's accounts on that machine plus "Other…". It writesssh-accounts.setper host.9. Audit
There is no host activity log today, so one is added: a migration made with
scripts/new-migrationforhost_activity(host, at, person, kind, method, outcome, detail)with fixed enum kinds and no free text from the host.GET /remote/hosts/activity?kind=privilege) is read by a new tracer Inbox source (slice 3).10. Test plan
RelaySessionframe codec, with caps and peer-uid refusals./root/j311b-vm/disk.qcow2per run (AlmaLinux 10.2, SELinux enforcing), the server on loopback with the J381 TLS cert, and Add host as thekaygaccount (rootless) throughflow.ts:lockednames sudo; then right → promoted;/proc/*/cmdlinesamples taken during elevation, from the guest's journal and audit logs, and from the server's DB and logs;topin an open terminal survives promotion with the same PID.doasinstalled, if the image is quick to make; otherwise its transcript tests only, stated as such.verify quickwhile iterating, thenverify faston 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
privilege.*actions, sealing, PTY elevation, relay andRelaySession; root install, handover admission and rollback; refusal while terminals are open; the guest proofs 1–5. About 12–16 h.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)./docs/privileges(D273). About 3 h.Questions (safe default chosen, work continues)
fail_interval). The alternative is 10 min (unlock_time). Default 15.J410 progress (slice 1). Branch
root-upgrade-d376, head862fab814, rebased onto origin/dev4754b14a7. Each commit is pushed.Done (code; the unit tests are queued on dev-02, position 7, log
~claude/j410-ev/seal.log):ef6be5324remote: the one-use seal opener (ring X25519 ephemeral, HKDF-SHA256, AES-256-GCM, bound to host, attempt and method). The KAT comes from Pythoncryptography.00e069844remote: 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.a72e950ecagent: 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.31ce8f774agent: the root relay__privilege-relay. It accepts only its owner's socket, runs scripts with an empty environment, and is bounded at 15 min.862fab814agent:privilege.probe/key/elevate/pump/endon the control stream, routed besideterminal.startup. The plugin worker can't be used: one call at a time and 8 KiB.Found while building:
Callpath 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 reusesprocess::executeunchanged. The helper plays the rolesshplays today.Next: the server side (relay hub plus helper, promotion transaction over the unchanged root
Installation, handover admission, thehost_activitytable with lockout, HTTP endpoints), then the guest proofs.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_uidin one transaction") collides with an invariant the code documents and enforces at seven independent layers.remote_hosts.modeandexecution_uidare write-once ("Immutable enrolled privilege mode",remote_authority.rs:11;plugins/remote/src/enrollment.rs:6). No productionUPDATE remote_hoststouches them.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.enrollment.rs:278-302) copies the token's mode from the host row (WHERE … mode = ?).enrollment.rs:407-441) requiresh.mode = token.mode. It also neither changesremote_hostsnor revokes old certificates.AuthorityGuard(remote_guard.rs:401-441) requires every operation's freshHostAuthority(mode and uid) to equal the admitted one, so flipping the row under a live link fails all of its operations.admit_observed/admit_primed(remote_runtime.rs:517, 616, 639):host.uid != uid→ PermissionDenied while the uid-1001 actor is live.embedded.rs:399, 947).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.
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.expected_key_digest, unexpired. Redemption inserts the rootful certificate and marks the promotionenrolled. It does not flip the host.enrolledpromotion recorded. It is admitted as a standby successor: it doesn't replace the live uid-1001 actor.host.facts.readexecutionUid=0, plus a terminal canary, ascomplete_setupdoes today). It setsmode='rootful', execution_uid=0, revokes the rootless certificates, and marks the promotiondone. 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).Installation::rollbackruns over the relay. The host row was never touched, so the host is exactly as before.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.
J415 (independent security and correctness review of the D376 handover design and the host-half commits) starting. Branch
root-upgrade-d376, head93695fe8e9f1517e8bc67769543bf49b8435700d. Read-only; verdict to follow here.J415 review: D376 handover design + host-half commits
Reviewed
root-upgrade-d376at93695fe8e(the 9 commits fromef6be5324), J410's design note above, and the code it cites:remote_authority.rs, serverenrollment.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_certificateshas 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 ordinaryadmit()accepts it as rootless, uid 1001.admit_observedthen findshost.uid == uidand replaces the live rootless link with the root process (remote_runtime.rs:514-528). Every terminal, plugin call andprivilege.pumpis then routed to the successor before any handover. Pumps break at once: the root process answers{"full":true}.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).
Revocationsis keyed by host id only, andcut()stays set untiltombstoned()(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 sameArc<Revocation>throughregister(). It then refuses every new admission for the host until the cut is released. The result is no live authority, by construction.embedded.rs,admit_observed). The successor can't sit beside it.Crash at each step, after R1 to R7:
openenrolled, successor cert role=successoradmit())abandonedin one transaction that also bars handover; a durable outbox tells the successor to roll back and uninstall itself (R5); its cert is revoked on receipthanded_over, rootless keys revokedadmit_operationrechecks)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_RETIREover the relay after the rootless certificates are revoked, but the relay's bytes travel throughprivilege.pumpon 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).
~/.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.infraternal-login-locks/*.terminalfiles 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.4. Can an attacker with only the rootless agent's key start or forge a promotion?
issue_authorized_indoes. No message from the infraternal agent may create or advance a promotion row.admit_observedlets the newest link take over), the key holder answersprivilege.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 reportexecutionUid=0about itself. D274 already accepts that the rootless agent is the trusted conduit; sealing does not change that./docs/privilegesmust say that the password is only as safe as the rootless agent's key.5. Does the one-use seal resist replay? Yes.
agree_ephemeralconsumes the private key, andelevateremoves the key from the map before any validation (privilege_agent.rs:222). A second elevate of the same attempt finds no key.6. Were the rejected alternatives rightly rejected? Yes, all three.
admit, the guard and the registry all rely on.Required design changes (build brief)
R1. Certificates carry a role.
remote_certificates.role(active/successor) andexecution_uidthroughscripts/new-migration, backfillingactivewith the host's uid.admit()must requirec.role='active' AND c.execution_uid=h.execution_uid, and renewal copies the predecessor's role and uid.admit_successor(fp)that returns a distinctSuccessorAuthoritytype, whichAuthorityGuard::admit/admit_observedcannot accept. It is valid only while a promotion row in stateenrollednames this exact fingerprint (and its public key), andexpires_at > now.R2. The standby actor is private, and revocation is per epoch.
deliver_login_cleanup: its ownEmbeddedAgent::remote(0), never inhosts, no plugins, noattach, no node channel. It accepts only proof calls:host.facts.read, and a canary as the person.Revocationsby(host, execution_uid). Host removal cuts every epoch of the host.(host, from_uid), thentombstoned((host, from_uid)). The successor then reconnects, or is promoted in place, through ordinaryadmit()as rootful.admit_operation's recheck.R3. One transaction, enforced by the database. The handover transaction does all of the following:
enrolled, unexpired, and bound to this successor fingerprint, and that the rootless fingerprint is still live;UPDATE remote_hosts SET mode='rootful', execution_uid=0 WHERE id=? AND mode='rootless' AND execution_uid=?from;public_keyis not the successor's (by key, so a renewal that commits during the promotion is covered);role='active'and the promotion tohanded_over.Add a
BEFORE UPDATE OF mode, execution_uid ON remote_hoststrigger that raises unless the new values arerootful/0, the old values arerootless, and anenrolledpromotion row for that host exists. This keeps write-once true for every other path.R4. The host rolls itself back.
Rollbackscript (sent first, kept in its root-only directory) and an explicitCommitframe.Err,LIFETIME, orClosewithoutCommit, it kills the running steps and runs the rollback under the bootstrap lock before it exits.relay_session.rsmarks the session lost on the firstErr) leaves a half-installed root unit, which breaks D376 ("failure puts the host back").R5. Durable outboxes, not the relay, finish both endings.
abandoned(and handover requiresenrolled, 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 (theadmit_cleanuppattern) until its receipt.R6. Adoption runs as the person, and attribution comes from the server.
R7. Bind the promotion to the rootless identity.
Findings on the built commits
P1
31ce8f774,privilege_relay_host.rs:136-187): on socket EOF,LIFETIMEorClose, the relay kills its steps and exits; no rollback runs. Combined withrelay_session.rspump_loop, where one pumpErrmarks the session lost and every later step returns 255 (soInstallation::rollbackcan't run either), any lost link mid-install leaves root files or a running root unit behind. That breaks D376. Fix: R4.P2
23f464ea6,host_activity.rs):retry_afteris 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 exampleINSERT … SELECT … WHEREwith no in-flight attempt and fewer than 2 wrong tries in the window, with in-flight attempts counted.Startedafter the password was typed (Timeout,Failed) must count as a try, because PAM may already have counted it.a72e950ec,privilege_elevate.rs:342-348): forsu(Linux-PAMmisc_conv), the prompt is printed before echo is turned off withTCSAFLUSH. The password can then be echoed back intoTranscript.tail(a plainVec, not wiped) and/or discarded, which ends as aTimeout. sudo and OpenDoas switch echo off before the prompt, so they are fine. Fix: polltcgetattr(master)untilECHOis clear before writing, and maketailZeroizing.31ce8f774+862fab814), a stall:stdin.send(bytes).await(privilege_relay_host.rs:159) blocks the whole frame loop when a script stops reading its stdin, soKillandCloseare never processed.pumpdoeswrite_allwhile holding the globalStatemutex with no timeout (privilege_agent.rs:293-296).privilege.*calls share the 4-slotrenewalslane (daemon.rs). One stuck relay can block certificate renewal andterminal.startupfor up to 15 minutes.try_send/drop or a per-channel buffer in the relay, boundwrite_allwith a timeout, and give the privilege actions their own lane of 1 to 2 slots.31ce8f774,a72e950ec), channel authentication:connect,:87-100). The account ownssessions/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.STARTED. The snippet reads it withread -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./docs/privileges.P3
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.cryptographycan 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.key()allows 2 keys, and re-keying the same attempt silently replaces the key. Make it one, and refuse a re-key.person-accountorroot).elevateholds the globalStatemutex for up to 80 s, soprivilege.endcan't cancel an elevation in flight.RelaySessiondoesn't requireReadyto come first, and it doesn't capStderrframes itself (the relay caps them, but the rootless pipe could inject more).su root -cruns root's login shell, which is oftennologinor a non-POSIX shell. Detect that and report it plainly instead of reportingFailed.What holds up well:
-kand a random-pmarker;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.
J416 start (Claude Opus 5.5): building J415's R1–R7 and fixing B1–B12 on D376.
root-upgrade-d376. Base: J410's host half atc8b23430c(rustfmt'd from93695fe8e), on origin/dev4754b14a7. Worktree~/Developer/Worktrees/infraternal-j416on code. The VM worktree will be~claude/worktrees/j416on compute01.Plan, in order, red tests first, each step its own atomic commit(s):
remote_certificates.role+execution_uid, backfilled toactivewith the host's uid.admit()requiresrole='active'and a matching uid. Renewal copies role and uid.admit_successor()returns a distinctSuccessorAuthoritythat the ordinary guard can't take.Revocationskeyed by (host, uid), so host removal cuts every epoch. A private standby actor, admitted like login cleanup, never inhosts, which allows only proof calls.remote_host_promotionstable, the single handover transaction, and aBEFORE UPDATE OF mode, execution_uidtrigger.→ post on #399 for the R1–R3 re-review.
RollbackandCommitframes. EOF, an error, the lifetime limit, or a close without commit kills the steps and runs the rollback under the bootstrap lock.9983b7f8a.verify quickandverify faston compute01.Estimate about 25 h in total. I'll post progress at each R milestone.
J410 progress. Branch
root-upgrade-d376, heada54e2797d. Nine atomic commits, rebased onto origin/dev4754b14a7.New since the last note:
4db0d67ecrelay: 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.81ee54d0eserver: thehost_activitytable, whose schema refuses free text, and the two-tries/15-min wait computed from it.a54e2797dssh:RelaySession, anSshSessionover the relay, through a per-step helperinfraternal __privilege-channel. The helper reusesprocess::executeand the OpenSSH failure mapping unchanged.privilege_callwith a 2 MiB cap, plusAgentPump) is parked onwip/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
ringHKDFPrkwas 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:~claude/j410-ev/seal.log.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.qcow2on 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.J416 coordination.
root-upgrade-d376moved twice while I worked:a54e2797d, then6a8a9d758(J410's compile and fmt fixups). I see~/.cache/codex-scratch/j410/gate.shstill holding a compute01 slot, so it looks like J410 is still active on this branch.6a8a9d758. It lives onwip/j416and gets rebased into atomic commits onroot-upgrade-d376at the end.remote_authority,remote_guard,remote_runtime,remote_link,enrollmentandremote_enrollment, plus the newremote_promotionandremote_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.wip/j416a5edb7016), 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 progress.
wip/j416is ate799ffdb0, stacked onroot-upgrade-d3766a8a9d758. 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:
remote_certificate_role:roleplusexecution_uidon certificates, both write-once (triggers).admit(),admit_cleanup(),renew(), recovery and rotation all requirerole='active'and the host's own uid, and they copy role/uid from the predecessor.admit_successor()returns aSuccessorAuthority, which ordinary admission can't produce or take.Revocationsis keyed by(host, uid).cut(host)cuts every epoch, including ones that register later, andcut_epochcuts exactly one. The successor gets a private actor throughremote_successor::deliver, the same pattern asdeliver_login_cleanup: never inhosts, no plugins.remote_linkallows it onlyhost.facts.read(proof), or onlyUNINSTALLonce abandoned.remote_host_promotions. Its triggers allow only forward state moves and a write-once binding, andremote_hosts_mode_write_oncerefuses every mode/uid change except rootless→rootful with anenrolledpromotion from that uid.handover()is one transaction: flip, revoke by public key, successor→active, drop the rootless token,handed_overwith cleanup=retire. After it commits comecut_epoch(host, from_uid)andtombstoned_epoch.Token,RollbackandCommit. The relay requires the rollback before anyOpen, and runs it on EOF, error, lifetime, orClosewithoutCommit, 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.abandon_promotion/sweep_promotions(the sweep also runs at server start) queueuninstallfor a joined successor, which is admitted only forUNINSTALL; its receipt revokes it. Retirement is queued at handover, and the new root agent runsprivilege.take_overon 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).triedcounts until root is reached. B3 wait for ECHO off, plus aZeroizingtail. B4 its own 2-slot privilege lane indaemon.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 Pythoncryptography). B8 one key per host, no re-key. B9 lockout per account. B10 elevation off the global lock and cancellable byprivilege.end; the server requires Ready first and caps stderr per channel. B11 root shell check. B6/B12 module docs.host_promotion::Promotionsdrives probe → begin → finish → relay → stage/join/start → successor proof → install commit + relayCommit+ relay close → handover. The routes arePOST /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.shforp1/p2/p3-<step>/p4, plus~/infraternal-ops/j416-private/flow.ts, which seals in the page with WebCrypto.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.qcow2on compute01: AlmaLinux 10.2,getenforce= Enforcing, ssh on 127.0.0.1:22410 only. The runner is/root/j410-vm/guest-proof.sh(attached).privilege-elevate-proof, sha256fc938a22…6e53b, built on dev-02 fromd22dddc1c. It plays the server's part against the realprivilege.*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).kaygin 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.started/etc/shadowreadable, stdin through, exit 3wrongwrongstartedwrongstarteddeniedpam_unix(sudo:auth): authentication failurein the journal went 0 → 2 after two wrong tries, never sudo's 3 per invocation.secret-search.txt).Finding (fixed in
066682bae). Case 7's probe said{"way":"sudo","denied":false}foroutsider, who has no sudo rule. sudo asks for a password before it checks its rules, sosudo -n -lanswers "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 guest re-run with the probe fix. Binary sha256
9099fc9a…067c8, built from066682bae. Same runner, fresh overlay, Enforcing. Evidence attached (/root/j410-vm/evidence-0932on compute01).sudo→ su, deniedsudo→ su, deniedsudo→ su, deniedThe 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 proof on the final head
36b89a6aa. Binary sha2563888bc55…03304, a static x86_64 musl build made by the dev-02verify quickrun on that head. Fresh Enforcing overlay. Evidence attached (/root/j410-vm/evidence-0957on compute01).This run includes the PTY change: the master now goes through the shim's audited
readiness::Registration, since tokio 1.54 deprecatesAsyncFd::newas unsound.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 quickon dev-02 at36b89a6aa: PASS. All rows pass, including clippy (workspace) and unit tests (changed).verify fastis queued on compute01 (7th of 8); it will be quoted when it finishes.J416 → J410:
root-upgrade-d376is nowb159a30ef, a fast-forward of your36b89a6aawith my 4 commits on top (R1–R5, R7, B1–B12). I merged your newer fixes into mine:Decoder::frame, the shimRegistration, the probe'sFacts/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: R1–R3 are ready for re-review
Branch
root-upgrade-d376, headb159a30ef. The authority change is one commit,3c6f1d7bb("server: a root upgrade's authority…"), on top of J410's36b89a6aa. 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:
migrations/20261109081612_remote_certificate_role.sqladds role and uid, both write-once by trigger.remote_authority.rs:admit,admit_cleanupandrenewrequirerole='active' AND c.execution_uid=h.execution_uid, and renew copies both. Recovery, identity rotation andredeemwrite role and uid explicitly.remote_promotion.rs:admit_successorreturnsSuccessorAuthority, which is not aHostAuthority.remote_guard.rs:Revocationsis keyed by(host, uid).cut(host)cuts every epoch, and every epoch that registers before the tombstone starts cut.cut_epoch/tombstoned_epochhandle a single epoch.AuthorityGuardcarriessuccessor.admit_operationre-checks it throughadmit_successor, andacknowledgerefuses it.remote_runtime::admit_observedroutes a successor toremote_successor::deliverbefore any registry code. That is a privateEmbeddedAgent::remote(0), never inhosts.remote_link::write_framelets it carry onlyhost.facts.read(or onlyUNINSTALLonce abandoned).migrations/20261109081614_remote_host_promotions.sqlhas these triggers:remote_host_promotions_forward,remote_host_promotions_boundandremote_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 bypublic_key <>the successor's; set the successor active; move the row tohanded_overwith cleanupretire; drop the rootless join token.cut_epoch(host, from_uid), thentombstoned_epoch.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
b159a30efin my queued build):cargo clippy --all-targets -D warnings: clean;enrollment::promotion::tests: 11/11, includinga_successor_guard_is_private_and_ends_with_its_promotion(realAuthorityGuard::admit_transportwith a successor certificate) andthe_handover_moves_authority_in_one_transaction(a renewal landing mid-promotion is revoked by key);remote_guard::cut_tests(epochs) andlogin_revocation_tests: pass;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:
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?admit(), rather than being promoted in place.Not done: R6 (adoption). A safe design: root asks the person's own login worker over
login.sockto connect to its own shim socket and pass the connected fd back withSCM_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,beginrefuses while the rootless host reports open terminals (outcome: "terminals"), so nothing is orphaned.J416 progress.
root-upgrade-d376is atb159a30ef(pushed).Run on compute01 at 12:15Z, on exactly
b159a30ef(log~claude/.cache/codex-scratch/j416/build-1.log):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 11enrollment::promotiontests, 118 remote-plugin tests and the agent's privilege tests (tests.log);cargo test -p infraternal --test privilege_relay_session: 4/4, includinga_lost_link_mid_install_rolls_back_on_the_hostanda_committed_upgrade_is_not_rolled_backagainst the real relay loop (tests3.log).My build script stopped after the tests: an empty
grepunderpipefail, 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, plusflow.ts, which seals in the page with WebCrypto;authselect with-faillockis enabled per run, so P2 tests the real 3-failure lockout.verify quickon 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 inverify fast.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. Head592190081(git ls-remote:59219008123325465799f53344a9690e374b9349 refs/heads/root-upgrade-d376). Ten commits on origin/dev54a9da2a8; it merges cleanly with today's dev1a0be9343(git merge-tree, no shared files). Not merged, not deployed.wip/j410-server-pump9983b7f8a.What's in the branch
188a583c7cryptographyc3a7c96737fefdfb99setsid --ctty; sudo-k -p <marker>, doas, su); one PAM failure per try; root runs only the digest-checking snippet7b00f4668__privilege-relay(owner-checked socket, empty env, 15 min bound, removes its copy)d505251b0privilege.probe/key/elevate/pump/endon the control stream, besideterminal.startupb808aa97fringdev-dependency lock line555edd06bhost_activitytable (schema refuses free text) and the 2-tries/15-min wait computed from it002488f4be391c6b3aRelaySession, theSshSessionover the relay, through the__privilege-channelhelper (process::executeand the OpenSSH failure mapping, unchanged)592190081Slice 1 proof table (real root, AlmaLinux 10.2 Enforcing guest, binary from
36b89a6aa, same tree as the head; comment 88631)Plus:
d22dddc1c, log~claude/j410-ev/seal.log), and the probe-order test inverify quick.privilege_relay_sessionintegration test, through the real helper binary, on head592190081(--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, head36b89a6aa: QUICK_EXIT=0, every row PASS (clippy (workspace), unit tests (changed)).verify quick+verify fast, compute01, head36b89a6aa(the head minus theCargo.lockringline that this gate run itself generated):Static sizes (report only): x86_64
28345008bytes, aarch6425454144bytes, 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
fastfailures, with evidence:test:agent_daemon::real_binary_rootless_cleanup_survives_sender_disconnect_before_readiness. Itsterminal.login.revokeansweredresource_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 newprivilege.*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 touchinfraternal-shim.cargo-deny:RUSTSEC-2026-0459(C SQLite CVE vialibsqlite3-sys0.30.1). This branch leaves that lock entry alone (same 0.30.1 on origin/dev1a0be9343); it's a new upstream advisory and will fail on dev too.For the orchestrator / owner
remote_hosts.modeinvariant. It needs a reviewer's or the owner's yes before code. Rejected alternatives are listed there.RelaySession, reusingwip/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/.J416: J410's 12:27Z force-push (
592190081, which only adds theringlock line) dropped my four commits. They are back on top as a fast-forward:root-upgrade-d376=b585b8997. The tree is byte-identical to theb159a30efthat passed clippy and the tests above (git diff b159a30ef b585b8997is empty).6569fd13cserver: 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.1205631ebagent: the root agent retires the rootless install as the account (R5)e2179b190host half: relay self-rollback, token, lane, one key, echo off (R4, B1, B3–B8, B10–B12)b585b8997server: the end-to-end driver and endpointsJ410 has finished (comment 88808), so nobody else should push this branch from now on.
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).kayg(rootless), and kayg is in wheel.authselect with-faillockis enabled, so the common deny=3 lockout is live.57364e8e…, payloada990d1e5…, built from14bec64b7(= the branch head minus nothing; built on dev-02, cross).elevated/installingran on the earlier24cc1d48bbuild. Their paths are unchanged since.~claude/.cache/codex-scratch/j416/e2e.sh, flow~/infraternal-ops/j416-private/flow.ts.run.txt, the snapshots and diffs, a screenshot and the KAT generator.begin→{"outcome":"terminals"}; host snapshot diff 0 lines (R6 adoption is not built, see below)started→ stepselevated,installing,handover,done; promotionhanded_over, cleanupdone; the DB saysrootful 0infraternal-agent.serviceand/var/lib/infraternalinstalled. The rootless install retired as the account: the user unit, launcher,bin,stateand.bootstrap-ownerare gone, and thesessionsdirs are kept. Processes:kayg infraternal→root infraternal. Diff:p1/snap-after.diffwrong→wrong(retryAfter:900) →begin=locked,retryAfter:900.faillock --user kaygshows exactly 2 failures (deny=3). The right password still works for sudo (SUDO_STILL_WORKS), so the account is not locked. Host diff 0elevated)installing)enrolled, J415 row 2)abandoned, cleanupuninstall(the successor is already gone, so the entry stays inert)handed_overwith cleanupretire. After restart, the root agent's ordinary admission delivers the retire outbox →done, the host is rootful, the rootless install retiredausearch -m AVC -ts today)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 finish
Branch
root-upgrade-d376, head9c360ef18(git ls-remote:9c360ef188c97786ca7ba27e8f2eba2928abf595 refs/heads/root-upgrade-d376). It contains current origin/dev (14bec64b7merges5eb968744). Not merged, not deployed.J415 status
6569fd13c, plus the join fixbbbba556c6569fd13c6569fd13ce2179b190,24cc1d48b6569fd13c,1205631eb6569fd13c,b585b899724cc1d48b)rootshellProof 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 atelevated/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, head14bec64b7:The one failing row was
doc comments on diff:crates/infraternal-server/src/remote_http.rs:659: join needs a doc comment (D345). Commit9c360ef18adds only that doc comment. Its check,verify quickon dev-02:(An earlier quick run on dev-02 failed 34
daemon/lifecycletests withNotFound. That was the known J155 gotcha: the<worktree>/targetsymlink was missing. With the symlink they pass, and in compute01'sfasttestrow 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
6569fd13c, plusbbbba556c(join answers a successor as uid 0).SCM_RIGHTS), with attribution from server session records.~claude/.cache/codex-scratch/j416/evidenceon compute01.Security review start (Claude Opus 5.5): independent break-it review of the D376 root upgrade.
root-upgrade-d376at9c360ef18(detached worktree on code; no pushes to this branch).verify quickand one guest proof re-run on compute01.Security review of the D376 root upgrade: REQUEST CHANGES
Reviewed:
root-upgrade-d376at9c360ef188c97786ca7ba27e8f2eba2928abf595(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)56c9345b…, payload4bc9c2ad…(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.logon 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))./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.started→handed_over, the DB saysrootful 0, retiredone, AVC 0. The password got 0 hits in the guest journal, the server log and the DB.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.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 showsinstalling(the row is alreadyenrolled), runpkill -KILL -f __privilege-relayon the guest.step="commit" … exit_status=Some(255), and the row endssudo|person|abandoned|tried=1|rooted=1|uninstall. The host staysrootless 1001in the DB, which is right.guest-leftover.txt):infraternal-agent.serviceis active (running) as root and enabled for boot;/var/lib/infraternalexists, with an open journal.bootstrap-lock/{activating,replacing-*,stopping,transaction,…};/usr/local/bin/infraternal, the unit in/etc/systemd/systemand/run/infraternal-privilege.tJn0NR2g(the relay's copy) are all left behind.Why: the abandoned successor reconnects every ~30–60 s and is admitted for
Uninstall.server-debug.logshows theadmit_successorquery withrows_returned=1four times. But the host's own uninstall refuses while.bootstrap-lockexists (crates/plugins/remote/src/uninstall.rs:153, tested on purpose bybootstrap_lock_delays_uninstall…). The answer is a refusal, sodeliver_uninstallreturns and the link drops (BrokenPipein 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:
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:
rollback_script()to the abandoned successor (one more action allowed on theUninstalllane), and the successor runs it as root under the setup lock before its uninstall;previous_attempt.shrecovery.A transient unit with
ExecStopPost=would cover a relay crash but not a reboot.Proofs to add: kill the relay at
installing, atenrolled, and aftercommit; 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:240reads the session inventory once. Nothing refuses a terminal open on the rootless link while a promotion is live, and nothing checks again beforeinstall.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").install.commit()(abandon withterminalswhen it isn't empty), and refuseterminal.openon 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".
started, emulate the relay, swap in its own key digest, redeem the token and answerexecutionUid=0(J415 Q4; it already holds the password).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.txtlists exactly one root process:/var/lib/infraternal/bin/current agent … run, system unit, noUser=. The unit has:cap_sys_module,cap_sys_boot,cap_sys_time,cap_sys_rawio,cap_mknod,cap_linux_immutable,cap_mac_admin,cap_bpfandcap_checkpoint_restore;NoNewPrivileges=no,ProtectSystem=no,ProtectHome=noandPrivateTmp=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
/etcSSH files and PTYs; kill; net_bind_service), then bound the unit and addProtectHome=read-onlywhere the R6 design allows it.P2-4. Root runs in the account's home directory.
RELAY uid=0/0 cwd=/home/kayg(31 samples), and every root install stepSTEP uid=0 cwd=/home/kayg(44 samples).cd /, and the relay starts steps and the rollback with nocurrent_dir(privilege_relay_host.rs:377,:414).$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 withroot_squashwould also break the steps.cd /at the top of the snippet, plus.current_dir("/")inopen()andrun_rollback().P3
mktempandexec(the 60 s bound,privilege.end), or a relay SIGKILL, leaves/run/infraternal-privilege.*holding an 11 MB root copy (seen in p6).RemoveOnDroponly runs inside a live relay.cpreads a path the account chooses ($1=current_exein its home). A FIFO there hangs the snippet. A symlink to/dev/zeromakes root fill the/runtmpfs until the 60 s kill, and the file stays.head -c <payload size>), and sweep staleinfraternal-privilege.*directories at the snippet's start.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.finishdoesn't check the admin window again.reserve_trychecks unlocked and unexpired but nots.fresh(remote_promotion.rs:336);begindoes check it. The gap can be up toLIFETIME(15 min). Add the sames.freshclause.still_boundand thehandover. 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.Zeroizingbuffer 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.\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.privilege_relay_host.rs:15says 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./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.
agree_ephemeralconsumes the key,elevatetakes 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.psoutput). The environment is cleared. The plaintext lives only inZeroizingbuffers in a non-dumpable process. 0 hits in the guest journal, server log and DB, in both runs.reserve_tryis one UPDATE with the lock condition in its WHERE, and the count is per account (B9). A try abandoned by a restart keepstried=1.2. Privilege boundary: holds, apart from P2-4 and P3-1.
mktemp -dunder/run, the copy is hashed, and the copy is executed.env_clearplus 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/infraternalis owned by root and private.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.cp "$1"(P3-1).3. Handover authority (R1–R3): holds.
admit,admit_cleanup,renewand recovery requirerole='active' AND execution_uid=h.execution_uid.SuccessorAuthorityis a separate type.remote_linklets a successor carry only Hello, Ping, Pong andhost.facts.read, orUNINSTALLonce abandoned. The join endpoint answers uid 0 only throughadmit_successor.enrolledrow from that uid.cut(host)cuts every epoch, including ones that register later, until the tombstone. The handover cuts only(host, from_uid).revoked_at IS NULLandenrolled; SQLite serializes the two, so either order is consistent.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/shwith 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):
admit+ epoch registration + registry insert) with the now-activeuid-0 certificate, and keeps R1's type separation. Promoting in place would have to turn a liveSuccessorAuthorityguard into aHostAuthority, which is exactly the conversion R1 forbids. The cost is one ordinary reconnect gap.admit_observedreplaces 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.
J416 round 2 finish (J465 review, comment 89390)
Branch
root-upgrade-d376, heade8620738b(git ls-remote:e8620738b2ebfad595ea99f36aec5f02d58c7cd5 refs/heads/root-upgrade-d376). It contains origin/devd7b2e4dc1. Not merged, not deployed.What changed
remote_promotion_transaction). The uninstall outbox then sends the abandoned successorprivilege.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: anundoscript in/var/lib/infraternal-promotion/, a oneshot unit enabled for boot, and a transient watcher unit outside the relay's process tree that runsundowhen the relay's pid ends.Commitor the relay's own rollback disarms it (0045a1534,ac96a3684,704ea1ebf)d239fa4e6)the_upgrade_stops_while_a_terminal_is_open(01c25a13f). The guest proof was not achieved, see belowcd /in the snippet, pluscurrent_dir("/")for relay steps and the rollback (8dbfc7a7d)945d50f8c(red.log: 4 of 5 new tests FAILED), green at the headterminal.startup-style canary allowed on the proof lane, then closed/runleftovers;cpof a FIFO or a/dev/zerolinkinfraternal-privilege.*dirs older than 20 min. The guard and the abandon unit remove the relay's own copy (8dbfc7a7d)cpblocked on the test FIFOSettled::Mismatchcounts as reaching root, so the count resetsa_digest_mismatch_counts_as_reaching_rootfinishdidn't re-check the admin windowreserve_tryrequiress.fresha_try_needs_the_admin_window_still_openprivilege-*socket dirs in the account's sessions dir (found by p7,c3089cbce)stale_relay_directories_are_sweptSkipped, with reasons:
unsafeand memory-limit handling for a ≤60 s buffer in a non-dumpable process./docs/privilegesstatement: stays tracked for slice 4.A wrong turn, reverted:
8ce56ed91removed/var/lib/infraternal.bootstrap-cacheafter 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 in7b93e4e1d.Guest proofs (AlmaLinux 10.2, Enforcing, fresh overlay per run, faillock on; the snapshot now also covers
/var/lib/infraternal.bootstrap-cache)7b93e4e1denrolled(after start)abandoned, cleanupdone. The abandon unit ran:rollback exit Some(0). Host diff 0 at 30 s, nobody retried. No root unit,/usr/local/bin/infraternalor/run/infraternal-privilege.*. AVC 07b93e4e1dinstallingstep (unit written, not started, so no successor can run)undo, boot unit, watcher). The watcher ranundo. 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)704ea1ebfreboot -ff) onceenrolled/run). One diff line remained: the account's own emptysessions/privilege-<attempt>dir, which the agent dies holding.c3089cbcesweeps it at the next try, so it lasts until then704ea1ebfevidence/p5-try1..4,p5). (1) A shim started by hand is invisible to the census by design:lifecycle.rsonly 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, retiredone, AVC 0Evidence
j416-r2-evidence.tgzis 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.outcome: "macos": "On a Mac, infraternal doesn't need a root agent. To use the mesh, runinfraternal upon the Mac once; it asks for your password one time to install its system service." (d239fa4e6, testa_mac_is_never_upgraded_to_a_root_agent).infraternal upalready does exactly that with one prompt.What on macOS really needs root (from the code):
PF_SYSTEMsocket oncom.apple.net.utun_control,macos_node.rs) and its routes. This runs in the root launchd daemonorg.infraternal.mesh(/Library/LaunchDaemons/org.infraternal.mesh.plist,macos_service.rs), installed and started bymesh up/infraternal up. It triessudo -nfirst and prompts only when sudoers doesn't already allow it./etc/resolver, written "only when root" (dns_integration.rs:43), so it happens inside that same service.macos_service::trusted_executable). Putting it in a root-owned place is part of the same one-time sudo step.pty.rs:505). Only a root shim cansetgroups/setgid/setuidinto another account. Terminals as the agent's own account need no root.Not root on a Mac:
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:Static size (report only): x86_64 29 223 608 B, +955 168 B vs baseline.
J477 review started. Branch root-upgrade-d376 at
e8620738b2, based on origin/devd7b2e4dc1b. 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 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 ate8620738b; I am checking this against fresh p6/p6i/p7 guest runs.J477 finding P1 — abandon outbox records unit start, not cleanup completion
At the reviewed SHA
e8620738b2ebfad595ea99f36aec5f02d58c7cd5,privilege_take_over::abandonreturns{"accepted":true}as soon assystemd-runsuccessfully starts the transient unit (crates/infraternal-agent/src/privilege_take_over.rs:124-148).remote_successor::deliver_uninstalltreats that reply as completion and changes the durable outbox tocleanup='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 printsuninstall failedwithout returning an error (crates/infraternal/src/remote.rs:1284-1332).The relay removes the guard on its
Commitframe 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 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, afterkill -0stops 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 overwrittenundo, 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 review started. Target: branch root-upgrade-d376 at
e8620738b2; review base: origin/dev merge-based7b2e4dc1b. 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 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.