Watch
0
0
Fork
You've already forked hyperhive
0

docs: drop harness systemd settings; swarm-nats-auth: refuse the hive's shared agent client

Docs (review on #4948): the harness's restart settings are not useful to
a reader, so the `Restart`/`RestartSec` bullet in docs/turn-loop and the
"30 seconds later, no start limit" wording in docs/swarm are removed.
What stays is the behaviour: the harness exits and comes back until the
queue accepts it.

Grant: with the harness's shared-client fallback gone, nothing presents
the hive's shared agent client (`hive-<hive>-agent`) to the queue;
swarm-logs uses that OIDC client over HTTP only. swarm-nats-auth now
refuses it outright instead of granting the configured
`$SWARM.term.{hive}.>` / `$SWARM.agent-state.{hive}.>` publish and its
`_INBOX.client.<id>.>` subscribe, so an agent holding it can no longer
publish as a sibling. The agent arm still runs before the hive arm, so
that id never falls through to its hive's grant.

Removed with it: the `--agent-publish-subject` flag and its two uses in
swarm-nats.nix, `Policy`'s agent subject list, and
swarm-queue-client's `client_inbox_prefix`. The module-eval now asserts
the flag is absent; the policy tests assert the refusal and that the
hive itself is still admitted.
This commit is contained in:
atlas 2026-10-04 20:46:49 +02:00 • committed by mara
commit 91fdf96a67
10 changed files with 85 additions and 333 deletions

View file

@ -227,7 +227,6 @@ mod tests {
"hive-status".to_owned(),
vec!["swarm-controller".to_owned()],
vec![],
vec!["$SWARM.term.{hive}.>".to_owned()],
)
.expect("valid")
.with_agent_token_subjects(vec![
@ -355,7 +354,6 @@ mod tests {
"hive-status".to_owned(),
vec![],
vec![],
vec![],
)
.expect("valid");
let Presented::Agent(token) = classify("swarm-agent.atlas.Ab9_-zSECRET") else {

View file

@ -91,7 +91,8 @@ struct Args {
/// config change plus a reload.
///
/// So this identity says which hive an agent belongs to and never which
/// agent: two agents on one hive are indistinguishable under it.
/// agent: two agents on one hive are indistinguishable under it. The
/// responder refuses it; the suffix is what tells it apart from a hive.
///
/// A suffix on the hive's id rather than a prefix of its own, because
/// `agent-<name>` reads as *the agent called `<name>`* — the one thing
@ -118,18 +119,6 @@ struct Args {
#[arg(long = "hive-publish-subject")]
hive_publish_subjects: Vec<String>,
/// Subjects an agent may publish to, with `{hive}` standing for the hive
/// its identity names — `hive-alpha-agent` expands it to `alpha`.
/// Repeatable, empty by default.
///
/// Empty means agents get **no grant at all** rather than a grant that can
/// do nothing — the identity exists, and what it may say is a deployment's
/// decision, not this responder's. The first consumer is the agent
/// terminal-event stream; until one is configured, an agent that connects
/// is refused, which is loud rather than silently over-broad.
#[arg(long = "agent-publish-subject")]
agent_publish_subjects: Vec<String>,
/// Subjects an agent that presented its own credential may publish to,
/// with `{agent}` standing for its name. Repeatable, empty by default,
/// which denies every agent token.
@ -190,7 +179,6 @@ async fn main() -> anyhow::Result<()> {
swarm_queue_client::status::BUCKET.to_owned(),
args.reader_clients.clone(),
args.hive_publish_subjects.clone(),
args.agent_publish_subjects.clone(),
)?
.with_agent_token_subjects(args.agent_token_publish_subjects.clone())?;
let store = agent_token::Store::from_env(args.store_cert_role.clone());

View file

@ -27,7 +27,7 @@ pub struct Permissions {
/// account.
///
/// `Some` only for agents, the least trusted clients: their own reply
/// inbox, plus their own input subject for an agent's own credential.
/// inbox and their own input subject.
/// Hives and readers get `None`. Never `Some(vec![])`: NATS reads an empty
/// allow-list as no restriction at all.
pub subscribe: Option<Vec<String>>,
@ -45,12 +45,10 @@ pub struct Policy {
bucket: String,
readers: Vec<String>,
extra_hive_subjects: Vec<String>,
extra_agent_subjects: Vec<String>,
agent_token_subjects: Vec<String>,
}
/// Placeholder replaced with the hive's own name in `extra_hive_subjects` and
/// `extra_agent_subjects`.
/// Placeholder replaced with the hive's own name in `extra_hive_subjects`.
const HIVE_PLACEHOLDER: &str = "{hive}";
/// Placeholder replaced with the agent's own name in `agent_token_subjects`.
@ -61,9 +59,8 @@ impl Policy {
/// `agent_suffix` what a hive's agent containers carry **on top of** it —
/// `hive-<name>` and `hive-<name>-agent`. `bucket` is the KV bucket hives
/// report status in, `readers` the client ids allowed to read every hive's
/// key, and `extra_hive_subjects` / `extra_agent_subjects` additional
/// subjects each may publish to (with `{hive}` standing for the hive's own
/// name).
/// key, and `extra_hive_subjects` additional subjects a hive may publish to
/// (with `{hive}` standing for the hive's own name).
///
/// The agent id is deliberately the hive's id extended, not a second
/// top-level prefix: `agent-<name>` reads as *the agent called `<name>`*,
@ -88,7 +85,6 @@ impl Policy {
bucket: String,
readers: Vec<String>,
extra_hive_subjects: Vec<String>,
extra_agent_subjects: Vec<String>,
) -> anyhow::Result<Self> {
if let Some(bad) = extra_hive_subjects
.iter()
@ -100,32 +96,16 @@ impl Policy {
a per-hive namespace"
);
}
// Same rule, same reason: an agent's identity is its *hive's*, so a
// template with no placeholder is one subject shared by every agent in
// the swarm rather than a per-hive one.
if let Some(bad) = extra_agent_subjects
.iter()
.find(|s| !s.contains(HIVE_PLACEHOLDER))
{
anyhow::bail!(
"--agent-publish-subject {bad:?} contains no {HIVE_PLACEHOLDER}: every agent \
in the swarm would be granted that exact subject, so it is a shared channel \
rather than a per-hive namespace"
);
}
// Checked here rather than trusted from nix, because these are two
// flags and nothing downstream compares them. An agent id is a hive id
// plus this suffix, so an empty one makes `strip_suffix` succeed on
// every hive id and the two principals become the same string. What
// that costs is not a parse error but a **grant**: whichever arm runs
// first answers for both, and a client that authenticates fine is
// handed more than it should have — the one failure this module must
// not have.
// every hive id and the two principals become the same string. The
// agent arm runs first and refuses, so every hive would be refused as
// its own agents' client.
if agent_suffix.is_empty() {
anyhow::bail!(
"--agent-client-suffix is empty: an agent id is a hive id plus this suffix, \
so every hive would also parse as its own agents and one of the two would \
silently receive the other's grant"
so every hive would also parse as its own agents' client and be refused"
);
}
Ok(Self {
@ -134,7 +114,6 @@ impl Policy {
bucket,
readers,
extra_hive_subjects,
extra_agent_subjects,
agent_token_subjects: Vec::new(),
})
}
@ -193,24 +172,12 @@ impl Policy {
// should. Most specific wins, so the more specific test is the one
// that has to be asked first.
//
// An agent's identity names its hive, never the agent — see
// `Self::agent_hive`. The grant is whatever the deployment configured
// for agents, expanded for that hive, and **nothing when it configured
// none**: an empty publish list would be a denial wearing a grant's
// shape, so this returns `None` and the client is refused outright.
//
// Subscribe is its own reply inbox alone. The identity names no agent,
// so it cannot be granted one agent's input subject without all of
// them.
if let Some(hive) = self.agent_hive(client_id) {
let publish = self.agent_subjects(hive);
return (!publish.is_empty()).then(|| Permissions {
publish,
subscribe: Some(vec![format!(
"{}.>",
swarm_queue_client::client_inbox_prefix(client_id)
)]),
});
// A hive's shared agent client is refused outright. Agents connect with
// their own credential (`Self::agent_token_permissions`); this identity
// names the hive and never the agent (see `Self::agent_hive`), so any
// grant to it would let one agent publish as its siblings.
if self.agent_hive(client_id).is_some() {
return None;
}
if let Some(hive) = self.hive_name(client_id) {
return Some(Permissions {
@ -459,19 +426,6 @@ impl Policy {
subjects
}
/// Subjects an agent of `hive` may publish to.
///
/// No `JetStream` minimum, unlike [`Self::hive_subjects`]: a hive needs it
/// because it writes the status bucket, and an agent publishes to plain
/// subjects. Granting it anyway would be reasoning by analogy about a
/// permission set the module documents as minimal by removal.
fn agent_subjects(&self, hive: &str) -> Vec<String> {
self.extra_agent_subjects
.iter()
.map(|s| s.replace(HIVE_PLACEHOLDER, hive))
.collect()
}
fn reader_subjects(&self) -> Vec<String> {
let stream = self.stream();
let mut subjects = Self::jetstream_minimum().to_vec();
@ -583,26 +537,10 @@ mod tests {
"hive-status".to_owned(),
vec!["swarm-controller".to_owned()],
Vec::new(),
Vec::new(),
)
.expect("the default policy is valid")
}
/// The same policy with agents given something to publish, since the
/// default deployment configures no agent subject and an agent is then
/// refused outright.
fn policy_with_agent_subject() -> Policy {
Policy::new(
"hive-".to_owned(),
"-agent".to_owned(),
"hive-status".to_owned(),
vec!["swarm-controller".to_owned()],
Vec::new(),
vec!["$SWARM.term.{hive}.>".to_owned()],
)
.expect("a per-hive agent template is valid")
}
#[test]
fn the_reader_can_open_and_list_the_agent_status_bucket() {
let subjects = policy().reader_subjects();
@ -701,7 +639,6 @@ mod tests {
"hive-status".to_owned(),
Vec::new(),
vec!["$SWARM.events.all".to_owned()],
Vec::new(),
)
.expect_err("a subject shared by every hive must not be accepted");
let msg = format!("{err}");
@ -721,7 +658,6 @@ mod tests {
"hive-status".to_owned(),
Vec::new(),
vec!["$SWARM.events.{hive}.>".to_owned()],
Vec::new(),
)
.expect("a per-hive template is the shape this option is for");
}
@ -818,29 +754,26 @@ mod tests {
}
#[test]
fn neither_a_hive_nor_its_agents_may_publish_input_to_an_agent() {
fn a_hive_may_not_publish_input_to_an_agent() {
// The harness turns this message into a turn from the operator named in
// it. A hive or agent able to publish here could speak as the operator
// to any agent in the swarm.
let policy = policy_with_agent_subject();
for client in ["hive-alpha", "hive-alpha-agent"] {
let p = policy.permissions(client).expect("admitted");
for forbidden in [
swarm_queue_client::agent_input_subject("alpha"),
swarm_queue_client::AGENT_INPUT_SUBJECT_WILDCARD.to_owned(),
] {
assert!(
!p.publish.contains(&forbidden),
"{client} must not publish {forbidden}: {:?}",
p.publish
);
}
// it. A hive able to publish here could speak as the operator to any
// agent in the swarm.
let p = policy().permissions("hive-alpha").expect("admitted");
for forbidden in [
swarm_queue_client::agent_input_subject("alpha"),
swarm_queue_client::AGENT_INPUT_SUBJECT_WILDCARD.to_owned(),
] {
assert!(
!p.publish.iter().any(|s| s.starts_with("$SWARM.input")),
"{client}: {:?}",
!p.publish.contains(&forbidden),
"a hive must not publish {forbidden}: {:?}",
p.publish
);
}
assert!(
!p.publish.iter().any(|s| s.starts_with("$SWARM.input")),
"{:?}",
p.publish
);
}
#[test]
@ -981,9 +914,9 @@ mod tests {
#[test]
fn an_agent_token_may_subscribe_to_its_own_inbox_and_input_only() {
use swarm_queue_client::{agent_inbox_prefix, agent_input_subject, client_inbox_prefix};
use swarm_queue_client::{agent_inbox_prefix, agent_input_subject};
let p = policy_with_agent_subject()
let p = policy()
.with_agent_token_subjects(vec!["$SWARM.term.{agent}".to_owned()])
.expect("a per-agent template is valid");
let atlas = p.agent_token_permissions("atlas").expect("configured");
@ -997,7 +930,6 @@ mod tests {
"$SWARM.input.*".to_owned(),
agent_input_subject("argus"),
reply(&agent_inbox_prefix("argus")),
reply(&client_inbox_prefix("hive-alpha-agent")),
"_INBOX.nuid123.token456".to_owned(),
] {
assert!(
@ -1007,32 +939,6 @@ mod tests {
}
}
#[test]
fn the_hives_shared_agent_client_may_subscribe_to_its_own_inbox_only() {
use swarm_queue_client::{agent_inbox_prefix, agent_input_subject, client_inbox_prefix};
let p = policy_with_agent_subject();
let alpha = p.permissions("hive-alpha-agent").expect("alpha's agents");
assert!(may_subscribe(
&alpha,
&reply(&client_inbox_prefix("hive-alpha-agent"))
));
for refused in [
">".to_owned(),
"$SWARM.term.>".to_owned(),
"$SWARM.term.alpha.atlas".to_owned(),
agent_input_subject("atlas"),
reply(&client_inbox_prefix("hive-beta-agent")),
reply(&agent_inbox_prefix("atlas")),
"_INBOX.nuid123.token456".to_owned(),
] {
assert!(
!may_subscribe(&alpha, &refused),
"alpha's agents may subscribe to {refused}"
);
}
}
#[test]
fn hives_and_readers_keep_an_unrestricted_subscribe() {
let p = policy();
@ -1252,7 +1158,6 @@ mod tests {
"hive-status".to_owned(),
Vec::new(),
vec!["$SWARM.events.{hive}.>".to_owned()],
Vec::new(),
)
.expect("a per-hive template is valid");
let g = p.permissions("hive-alpha").expect("a hive is admitted");
@ -1261,106 +1166,40 @@ mod tests {
}
#[test]
fn an_agent_with_no_configured_subject_is_refused() {
// The shipped default: the identity exists, what it may say does not.
// A grant with an empty publish list would let it connect and then
// fail on every publish, which reads as a broken queue rather than as
// a deployment that configured nothing.
assert!(policy().permissions("hive-alpha-agent").is_none());
}
#[test]
fn an_agent_publishes_inside_its_own_hives_namespace() {
// The presence control for the test above - without it, "refused"
// would also be the answer if the agent arm never matched at all.
let g = policy_with_agent_subject()
.permissions("hive-alpha-agent")
.expect("an agent with a configured subject is admitted");
assert_eq!(g.publish, vec!["$SWARM.term.alpha.>".to_owned()]);
}
#[test]
fn an_agent_does_not_get_its_hives_grant() {
// An agent holds a credential that sits in a container; a hive's grant
// includes writing that hive's status key, which an agent must not be
// able to forge.
fn the_hives_shared_agent_client_is_refused() {
// Agents connect with their own credential. The shared client names
// the hive and never the agent, so any grant to it would let one agent
// publish as its siblings.
//
// 🩸 This became a REGRESSION test when the id moved from `agent-<hive>`
// to `hive-<hive>-agent`: the agent id now satisfies the HIVE parse too
// (`hive-alpha-agent` strips to a hive named `alpha-agent`), so a
// hive-first arm order hands every agent its hive's grant, and nothing
// reports it — the client authenticates and is merely able to do more
// than it should. Together with the `assert_eq` in
// `an_agent_publishes_inside_its_own_hives_namespace` (which pins the
// subject to `alpha`, not `alpha-agent`) this is what holds the
// ordering in place.
let g = policy_with_agent_subject()
.permissions("hive-alpha-agent")
.expect("an agent is admitted");
assert!(!g.publish.iter().any(|s| s.starts_with("$KV.")));
assert!(!g.publish.iter().any(|s| s.starts_with("$JS.API.")));
}
#[test]
fn an_agent_subject_without_the_placeholder_is_refused() {
let err = Policy::new(
"hive-".to_owned(),
"-agent".to_owned(),
"hive-status".to_owned(),
Vec::new(),
Vec::new(),
vec!["$SWARM.term.all".to_owned()],
)
.expect_err("a subject shared by every agent in the swarm is not per-hive");
assert!(
format!("{err}").contains("$SWARM.term.all"),
"the error must name the offending template"
);
// 🩸 The agent id satisfies the HIVE parse too (`hive-alpha-agent`
// strips to a hive named `alpha-agent`), so a hive-first arm order
// would hand this client its hive's grant, status key included, and
// nothing would report it. `is_none` here is what holds the order.
assert!(policy().permissions("hive-alpha-agent").is_none());
// Control: the hive itself is admitted, so the refusal above is the
// agent arm answering rather than the prefix failing to match.
assert!(policy().permissions("hive-alpha").is_some());
}
#[test]
fn an_empty_agent_suffix_is_refused() {
// 🩸 An agent id is a hive id plus the suffix, so an empty suffix makes
// `strip_suffix` succeed on every hive id: the two principals become
// one string, and whichever arm runs first answers for both. That is a
// GRANT, not a parse error, which is why it has to fail at
// construction.
// one string, and the agent arm, which runs first and refuses, would
// refuse every hive. That fails at construction rather than at connect.
Policy::new(
"hive-".to_owned(),
String::new(),
"hive-status".to_owned(),
Vec::new(),
Vec::new(),
Vec::new(),
)
.expect_err("an empty agent suffix must not construct");
}
#[test]
fn the_agent_suffix_alone_names_no_hive() {
// `hive--agent` is the prefix and the suffix with nothing between them.
// The agent arm must refuse it rather than expand `{hive}` to the empty
// string and hand out `$SWARM.term..>`.
//
// 🩸 Written first as `is_none()`, by analogy with
// `the_prefix_alone_names_no_hive`, and it FAILED — correctly. The
// analogy does not hold: under the hive parse the same id is a hive
// named `-agent`, which is a non-empty name this responder cannot rule
// out, because it has no roster. `swarm-authelia.nix` refuses such a
// name at eval, where the roster is known. So the assertion here is
// narrowed to the only part this module owns.
let g = policy_with_agent_subject().permissions("hive--agent");
assert!(
!g.iter()
.flat_map(|p| &p.publish)
.any(|s| s.contains("$SWARM.term..")),
"an empty hive name must never be expanded into a subject: {g:?}"
);
}
#[test]
fn an_agent_token_grant_is_the_agents_own_subjects_and_nothing_else() {
let p = policy_with_agent_subject()
let p = policy()
.with_agent_token_subjects(vec![
"$SWARM.term.{agent}".to_owned(),
"$SWARM.agent-state.{agent}".to_owned(),
@ -1376,14 +1215,12 @@ mod tests {
);
}
/// A verified agent may write its own icon key and no other agent's. The
/// hive's shared agent client, which every agent on the hive presents, may
/// write none of the bucket.
/// A verified agent may write its own icon key and no other agent's.
#[test]
fn an_agent_may_write_only_its_own_icon_key() {
use swarm_queue_client::agent_icon::{BUCKET, subject};
use swarm_queue_client::agent_icon::subject;
let p = policy_with_agent_subject()
let p = policy()
.with_agent_token_subjects(vec!["$KV.agent-icons.{agent}".to_owned()])
.expect("a per-agent template is valid");
let atlas = p.agent_token_permissions("atlas").expect("configured");
@ -1393,15 +1230,6 @@ mod tests {
// lacking it is atlas's grant being scoped.
let argus = p.agent_token_permissions("argus").expect("configured");
assert_eq!(argus.publish, vec![subject("argus")]);
let shared = p.permissions("hive-alpha-agent").expect("alpha's agents");
assert!(
!shared
.publish
.iter()
.any(|s| s.starts_with(&format!("$KV.{BUCKET}."))),
"the hive's shared agent client got an icon key: {shared:?}"
);
}
/// The subagent template as `swarm-nats.nix` spells it expands to the
@ -1411,7 +1239,7 @@ mod tests {
fn an_agents_subagent_grant_is_publish_on_its_own_family_only() {
use swarm_queue_client::subagent_term::{stream_name, stream_subjects, subject};
let p = policy_with_agent_subject()
let p = policy()
.with_agent_token_subjects(vec!["$SWARM.term.{agent}.sub.>".to_owned()])
.expect("a per-agent template is valid");
let g = p.agent_token_permissions("atlas").expect("configured");
@ -1435,15 +1263,12 @@ mod tests {
/// The schedule template as `swarm-nats.nix` spells it reads one key: the
/// agent's own. It covers no other agent's key, no other stream or bucket,
/// and no way to open, watch or write the bucket. The hive's shared agent
/// client gets none of it.
/// and no way to open, watch or write the bucket.
#[test]
fn an_agent_may_read_only_its_own_schedule_key() {
use swarm_queue_client::agent_schedules::{
BUCKET, STREAM, direct_get_subject, key_subject,
};
use swarm_queue_client::agent_schedules::{STREAM, direct_get_subject, key_subject};
let p = policy_with_agent_subject()
let p = policy()
.with_agent_token_subjects(vec![
"$JS.API.DIRECT.GET.KV_agent-schedules.$KV.agent-schedules.{agent}".to_owned(),
])
@ -1483,12 +1308,6 @@ mod tests {
// vacuous.
assert!(covers(&atlas.publish[0], &direct_get_subject("atlas")));
assert!(covers("$KV.*.>", &key_subject("argus")));
let shared = p.permissions("hive-alpha-agent").expect("alpha's agents");
assert!(
!shared.publish.iter().any(|s| s.contains(BUCKET)),
"the hive's shared agent client got a schedule subject: {shared:?}"
);
}
#[test]