swarm: refuse hive names that CONTAIN a word identifiers are composed from

Hive-scoped identifiers are built by joining a hive name with fixed words —
`hive-<name>`, `hive-<name>-agent` — so a hive called `foo-agent` produces
exactly the id hive `foo`'s agent containers produce. The queue's responder
resolves it as the agents, and that hive silently receives an agent grant
instead of its own; a NATS denial reaches a client as a timeout, so nothing
names the cause. Equality cannot see this: the two NAMES differ, only the
composed strings collide.

Deliberately a second file rather than three more entries in
`reserved-names.nix`. That list is matched by equality against a whole name and
every entry is a value some component emits; applying containment to it would
refuse `forgeworks` and `operator-hq` for failures that do not exist. The two
lists differ in subject (hive-only vs both), matcher (substring vs equality)
and admission rule, and each file's header says so.

No Rust change: `HIVE_RESERVED_NAMES` answers "may an AGENT be called this",
which stays an equality question — an agent name is never composed into these
identifiers.

Measured against the predicate rather than assumed:

  pr1ma=ok alpha=ok forgeworks=ok operator-hq=ok
  foo-agent=REFUSED beehive=REFUSED swarm=REFUSED myswarmx=REFUSED

The four `ok` cases are the control: `forgeworks` and `operator-hq` are exactly
what a merged list would have refused.
This commit is contained in:
atlas 2026-08-31 18:24:19 +02:00 committed by mara
commit cdaf5824ed
3 changed files with 94 additions and 0 deletions

View file

@ -72,6 +72,14 @@ let
# would be an eval error rather than a silently missing guard. # would be an eval error rather than a silently missing guard.
reservedOwners = import ../reserved-names.nix; reservedOwners = import ../reserved-names.nix;
# The second, narrower blacklist: words no hive name may CONTAIN, because
# they are fixed segments of identifiers built FROM a hive name
# (`hive-<name>`, `hive-<name>-agent`). Substring rather than equality is
# the whole point — a hive called `foo-agent` collides in the composed
# string while colliding with nothing in the list above. That file explains
# why it is not merged into this one.
reservedFragments = import ../reserved-hive-fragments.nix;
# A published target is declared as ONE url, because that url is also the # A published target is declared as ONE url, because that url is also the
# audience its token is minted for — but prometheus wants the same fact in # audience its token is minted for — but prometheus wants the same fact in
# three fields. Split it here rather than asking a service to state it # three fields. Split it here rather than asking a service to state it
@ -685,6 +693,44 @@ in
Rename the hive. Reserved: ${lib.concatMapStringsSep ", " (n: "'${n}'") reservedOwners}. Rename the hive. Reserved: ${lib.concatMapStringsSep ", " (n: "'${n}'") reservedOwners}.
''; '';
} }
{
# The substring half, and a genuinely different failure from the one
# above: these words are fixed SEGMENTS of identifiers composed from a
# hive name, so the collision is between two composed strings while
# the names themselves differ. `hive-foo-agent` is hive `foo-agent`'s
# own client id and hive `foo`'s AGENT client id at the same time; the
# queue's responder resolves it as the agents, so `foo-agent` silently
# receives an agent grant instead of its own — and a NATS denial
# reaches a client as a timeout, so nothing names the cause.
#
# Lives here rather than in `swarm-authelia.nix` (which composes those
# ids) because this module already owns the hive-roster check and a
# second site for "is this hive name legal" is how the two drift.
assertion =
!lib.any (h: lib.any (frag: lib.hasInfix frag h) reservedFragments) (
lib.attrNames hyperhiveCfg.swarm.hives
);
message = ''
services.hyperhive.swarm.hives contains ${
lib.concatMapStringsSep ", " (h: "'${h}'") (
lib.filter (h: lib.any (frag: lib.hasInfix frag h) reservedFragments) (
lib.attrNames hyperhiveCfg.swarm.hives
)
)
}, which CONTAIN a word the swarm composes identifiers from.
Hive-scoped identifiers are built by joining a hive name with these
words `hive-<name>` for a hive, `hive-<name>-agent` for its agent
containers so a name containing one produces an identifier that is
also somebody else's. The failure is a wrong grant, not an error:
the client authenticates and receives permissions meant for another
principal.
Rename the hive. Forbidden as substrings: ${
lib.concatMapStringsSep ", " (n: "'${n}'") reservedFragments
}.
'';
}
{ {
# A published target that is not an `https://host/path` url. Without # A published target that is not an `https://host/path` url. Without
# this the split returns null and the failure surfaces as # this the split returns null and the failure surfaces as

View file

@ -0,0 +1,45 @@
# Words no HIVE name may CONTAIN — a substring blacklist, and the second half
# of `./reserved-names.nix`.
#
# Two files rather than one list, because the two differ in every dimension
# that matters and folding them would silently widen one of them:
#
# | | `reserved-names.nix` | this file |
# |---|---|---|
# | subject | agent AND hive names | hive names only |
# | match | equality | substring |
# | why | the name IS a value some component emits | the word is a fixed SEGMENT of a derived identifier |
#
# ⚠️ That last row is the whole argument. `hive-<name>` and `hive-<name>-agent`
# are built by composing a hive name with these words, so a hive called
# `foo-agent` produces an id another hive's AGENTS also produce — the composed
# strings collide even though the names do not. Equality cannot see that; only
# containment can.
#
# ⇒ And it is why the sender words (`operator`, `system`, `forge`, …) are NOT
# here: those are compared against a whole name, never embedded in one, so
# applying containment to them would refuse `forgeworks` for no failure that
# exists. `reserved-names.nix`'s own rule — an entry must name a real failure,
# not a word that looked risky — governs this file too.
#
# Read by `host-modules/swarm-otel.nix`, which owns the hive-roster assertion.
# Deliberately NOT exported to the Rust side: `HIVE_RESERVED_NAMES` answers
# "may an AGENT be called this", which stays an equality question.
[
# `hive-<name>` — every hive's own OIDC client id (`swarm-authelia.nix`'s
# `hiveClientPrefix`), and the string the queue's auth-callout responder
# strips to decide which hive a connection is.
"hive"
# `hive-<name>-agent` — the client its agent containers present
# (`agentClientSuffix`). A hive named `foo-agent` mints exactly the id hive
# `foo`'s agents do, and the responder resolves it as the agents: that hive
# silently receives an agent grant instead of its own, and a NATS denial
# arrives as a timeout, so nothing names the cause.
"agent"
# Already in `reserved-names.nix` as an exact match, listed again here for
# the wider rule: the swarm collector names components `<kind>/<owner>` with
# the hive name as owner, and a name merely CONTAINING it is not a collision
# today — but `swarm` is the one word this swarm composes identifiers from
# everywhere, so the operator's rule covers it and the two files agree.
"swarm"
]

View file

@ -25,6 +25,9 @@
# `from`/`to`, or a component name the collector builds pipelines from — not a # `from`/`to`, or a component name the collector builds pipelines from — not a
# word that merely looked risky. A name in here that nothing emits is a refusal # word that merely looked risky. A name in here that nothing emits is a refusal
# with no failure behind it. # with no failure behind it.
#
# ⚠️ Matched by EQUALITY. Words forbidden *inside* a hive name live in
# `./reserved-hive-fragments.nix` — read its header before merging the two.
[ [
# ---- message-layer senders ------------------------------------------- # ---- message-layer senders -------------------------------------------
# The human at the dashboard: a broker recipient (the T4LK box sends # The human at the dashboard: a broker recipient (the T4LK box sends