Third batch of hyperhive#4042's Passive pass (see #4098/#4099 for the
first two and the read-every-hit discipline this pass uses). 23 hits
across boundary.md (10) and security.md (13).
boundary.md, 2 of 10 rewritten:
- "Operator-authority actions ... are served by the core daemon and
only reachable via the gateway" -> "The core daemon serves
operator-authority actions ..., reachable only via the gateway" --
the actor was already named in the sentence, and the parallel
"Agent" bullet right below it is already active voice ("speaks only
for itself"), so the Operator bullet was the inconsistent one.
- "ownership set afterwards is reverted the next time any agent
changes" -> "and reverts any ownership set afterwards the next time
any agent changes" -- continues the same subject ("the `d`
re-applies ... and reverts ...") already established one clause
earlier, avoiding a subject switch entirely.
security.md, 6 of 13 rewritten (across 3 edits touching 6 flagged
locations):
- "A compromised/confused agent's reach ... is bounded by its own
account's scope" -> "Its own account's scope bounds a
compromised/confused agent's reach" -- matches the section's own
header ("Scoped tokens bound the blast radius"), which is already
active voice.
- "Two allow-listed root prefixes are accepted; all other paths are
rejected" -> "It accepts two allow-listed root prefixes and rejects
all other paths" -- continues "it" from the endpoint named one
sentence earlier.
- The container-allowlist paragraph (3 flagged locations: "is
validated", "are accepted", "are rejected") rewritten as one
consistent-subject passage with `hive-priv` as the actor throughout,
matching the neighboring bullets in the same subsection
("**Socket-activated** -- systemd starts hive-priv...", already
active voice) -- the passive version was the odd one out among
siblings, not the house style.
15 of 23 hits left alone. The recurring legitimate shapes, same
categories as #4098: predicate-adjective copulas that only look like
passives ("an agent is trusted code", "the agent is privileged" --
"trusted"/"privileged" modify the noun, there's no actor to name),
quoted rhetorical contrasts where the passive is doing real work (a
"wrong-framing" quote left passive on purpose, paired with an active
"right-framing" quote right after it), the "is tracked as/in X"
idiom (twice, same as #4098's precedent), and "X can't be Yed" /
negative-capability invariant statements (matches #4098's "No X is Y"
security-guarantee idiom). One deliberately left despite a nameable
antecedent ("hive-gateway's access is scoped [by ReloadGatewayNginx]
instead") -- lower-confidence rewrite than the others, left rather
than force it.
Verified: vale docs/trust-boundary before/after -- 23 -> 15
write-good.Passive hits, exactly the 8 rewritten, no other rule's hit
count moved (the TooWordy/Microsoft.Avoid/alex hits vale also reports
on these two files are pre-existing and out of scope for a
Passive-only pass; TooWordy's hits specifically already have a fix
queued in #4097, not duplicated here).
157 lines
8 KiB
Markdown
157 lines
8 KiB
Markdown
# The operator/agent boundary
|
|
|
|
Design rationale for hyperhive's two-principal trust model. The
|
|
_implementation_ work — container network isolation, the unifying
|
|
gateway, core-daemon privsep — is tracked as `area:ops` issues on
|
|
the forge.
|
|
|
|
The operator/agent boundary is technically enforced, not just a
|
|
convention: containers run in private netns (network isolation is
|
|
always on), the gateway proxies all operator-facing traffic, and
|
|
`hive-c0re` runs as the unprivileged `hive-core` user.
|
|
|
|
## Two principals, two paths
|
|
|
|
- **Operator** — reaches every UI (the dashboard + every
|
|
per-agent page) through the gateway, on one origin.
|
|
The core daemon serves operator-authority actions (approve / deny,
|
|
answer-as-operator, lifecycle POSTs), reachable only via the
|
|
gateway.
|
|
- **Agent** — speaks only for itself, only over its per-agent
|
|
unix socket. The socket's identity _is_ the agent (see
|
|
`docs/process/conventions.md`, "identity = socket"). An agent must not
|
|
be able to reach the core daemon's HTTP surface, another
|
|
agent's socket, or another agent's web UI.
|
|
|
|
## Design rule
|
|
|
|
**Operator-authority actions never get a per-agent-socket entry
|
|
point.** They live on the core backend.
|
|
|
|
Worked example — destroying or rebuilding a container is a
|
|
`POST /api/{destroy,rebuild}/{name}` on the core dashboard, _never_ a
|
|
per-agent-socket `Request` variant. If it were a per-agent-socket
|
|
request, a compromised agent could `curl` its own socket and destroy
|
|
or rebuild itself (or, if the variant took an arbitrary target, another
|
|
agent) without ever touching the core's own authenticated surface.
|
|
|
|
## Why network isolation is the load-bearing step
|
|
|
|
Without network isolation, containers share the host network namespace
|
|
and can reach `localhost:<core-port>`, the dashboard, and every other
|
|
agent's web port — the operator/agent split is on the honour system and
|
|
every boundary claim above is aspirational. Network isolation is what
|
|
makes the boundary _real_; the gateway and privsep are ergonomics and
|
|
defence-in-depth layered on top.
|
|
|
|
Network isolation is complete and always on: every agent container
|
|
runs in a private netns behind the hive bridge, and there is no
|
|
shared-netns mode. See `docs/networking/network.md`.
|
|
|
|
Concretely, the core daemon's dashboard `/api` carries **no
|
|
application-layer authentication** — operator-authority routes are served
|
|
unauthenticated at the HTTP layer. Their protection is entirely (a) the
|
|
gateway, which fronts all operator traffic and is where operator auth lives,
|
|
and (b) network isolation, which keeps agents — and `hive-ci`'s untrusted PR
|
|
builds — off host-loopback so nothing can reach `127.0.0.1:<dashboard_port>`
|
|
directly. This is deliberate given the load-bearing role of network isolation
|
|
above, but it's a standing invariant: the `/api` must never be bound to a
|
|
non-loopback address or exposed outside the gateway, and every new
|
|
operator-authority route inherits that assumption. `hive-ci` is treated like an
|
|
agent for this purpose — it runs untrusted PR code and is netns-isolated for
|
|
the same reason.
|
|
|
|
The boundary rests on three layers:
|
|
|
|
1. **Gateway** — fronts all surfaces (dashboard + every per-agent UI)
|
|
on one origin. An nginx nixos-container proxies per-agent UIs under
|
|
`/agent/<name>/`, which is what lets each agent page's inbox panel
|
|
POST `mark-all-read` to the core dashboard's
|
|
`/api/agent/{name}/mark-all-read` go same-origin instead of needing
|
|
a cross-origin CORS shim. Pure ergonomics — no behavioural risk on
|
|
its own.
|
|
2. **Network isolation** — the load-bearing layer: every agent
|
|
container runs in a private netns behind the hive bridge, always
|
|
on and unconditional. This is what turns the operator/agent split
|
|
from an honour-system convention into an enforced boundary.
|
|
3. **Privsep** — defence in depth on the core process; `hive-c0re`
|
|
runs as the unprivileged `hive-core` user and delegates root
|
|
operations to `hive-priv`, a narrow socket-activated helper. See
|
|
[`docs/trust-boundary/security.md`](security.md) for the privilege boundary table.
|
|
|
|
### hive-priv socket activation
|
|
|
|
`hive-priv` is **always** socket-activated by the `hive-priv.socket`
|
|
systemd unit. The unit binds `/run/hive/priv.sock` with
|
|
`SocketGroup=hive-core` and mode `0660` and passes the ready listener
|
|
to the helper as fd 3 (`LISTEN_FDS`). The helper requires this and
|
|
bails if it isn't socket-activated.
|
|
|
|
⚠️ Intentionally, no self-bind fallback exists: if `hive-priv` bound
|
|
the socket itself, it would create the file owned by root's primary
|
|
group rather than `hive-core`, and a `hive-core` client couldn't
|
|
connect the way the socket unit's `SocketGroup` grant intends.
|
|
Requiring socket activation everywhere keeps dev and prod on the
|
|
exact same path, so the group grant always holds.
|
|
|
|
### the per-agent socket dir
|
|
|
|
`/run/hive-agent/<name>/` is shared by **three principals that share no
|
|
group**, which is why its mode is what it's:
|
|
|
|
| principal | reaches | needs |
|
|
| ------------------- | ---------------------------------------- | ------------ |
|
|
| the agent's harness | binds + unlinks `agent.sock`, `web.sock` | owner, `rwx` |
|
|
| `hive-c0re` | dials `agent.sock` (todo wakes) | traverse |
|
|
| the gateway's nginx | dials `web.sock` | traverse |
|
|
|
|
The last two land in "other," so the dir is **`0751`, owned by the
|
|
agent's container uid/gid** — `o=--x` is traverse without listing, and
|
|
both sockets are `0666`, which is all a dialer needs.
|
|
|
|
**Ownership is declared, not repaired.** The tmpfiles.d entry written by
|
|
`SyncAgentTmpfiles` names the uid/gid directly. Don't add a chown
|
|
alongside it: `d` re-applies on every boot _and_ every agent
|
|
spawn/destroy, and reverts any ownership set afterwards the next time
|
|
any agent changes — which is exactly how this dir spent a long time at
|
|
`0777 root root` while a privileged chown appeared to be fixing it.
|
|
|
|
The mode is load-bearing, not cosmetic. Write permission on a
|
|
_directory_ is what confers the right to unlink its entries, whoever owns
|
|
them, and the sticky bit is the only thing that would restrain that (it
|
|
isn't set here). A world-writable socket dir therefore lets anything
|
|
able to reach the path delete an agent's socket and bind its own — and
|
|
nginx reaches all of `/run/hive-agent` as a plain host path. Dropping
|
|
`o=w` removes that permission rather than qualifying it.
|
|
|
|
⚠️ **The gateway's nginx and dnsmasq are host services, next to
|
|
`hive-c0re`** (see `docs/networking/gateway.md`) — there is no namespace between
|
|
them and the rest of the host. That costs no network isolation: nginx
|
|
binds the host's `:80`/`:443` and reaches `localhost` upstreams, which a
|
|
netns would have to be opened up for anyway.
|
|
🔑 It does mean nothing _implicitly_ scopes the privileged reload verb —
|
|
see [`docs/trust-boundary/security.md`](security.md#hive-c0re-privilege-separation) for
|
|
how `PrivRequest::ReloadGatewayNginx`'s containment works.
|
|
|
|
⚠️ Contrast `/shared`, which _is_ sticky world-writable (`1777`): it has
|
|
many legitimate writers, so sticky is the best available answer there.
|
|
This dir has exactly one writer, so it needs no world write at all.
|
|
|
|
### host admin socket access (`hivectl`)
|
|
|
|
`hivectl` drives the whole hive — spawn / kill / destroy / rebuild /
|
|
deploy — over the **host admin socket** `/run/hyperhive/host.sock`,
|
|
socket-activated by the `hive-c0re.socket` unit. That socket _is_ the
|
|
full-control surface, so who can connect to it's a real trust
|
|
boundary.
|
|
|
|
By default the socket is `0660` group-owned by **`hive-admin`**, an
|
|
empty group — so it's effectively **root-only** until an operator is
|
|
explicitly granted access. Grant sudoless `hivectl` by listing login
|
|
users in `services.hyperhive.c0re.adminUsers`; each is added to
|
|
`hive-admin`, and members connect without `sudo`. The runtime dir
|
|
`/run/hyperhive` is `0751` (traverse-only, no listing) so the group can
|
|
reach the socket path; the socket's own `0660 hive-admin` mode gates
|
|
the connection, and the per-agent subdirs under it keep their own
|
|
restrictive perms. Keep `adminUsers` to trusted operators — membership
|
|
is equivalent to root over the hive.
|