Watch
0
0
Fork
You've already forked hyperhive
0

agent capabilities live in a swarm KV record, set from swarmctl

An agent's capabilities move from the hive's meta repo
(`meta/capabilities.json`, injected as `HIVE_CAPABILITIES`) into the
`capabilities` field of its key in a new `agent-permissions` KV bucket,
which swarm-controller alone writes.

- swarm-queue-client: `agent_permissions` (bucket, key, `AgentPermissions`,
  direct-get read, the import subject and its wire types).
- swarm-controller: creates the bucket at start; `GET|PUT|DELETE
  /api/agents/{name}/permissions` (deny_unknown_fields and unknown
  capability ⇒ 422, duplicate ⇒ 400, agent must be declared by a hive);
  answers each hive's one-shot import, writing a key only where there is
  none and only for agents that hive's wanted state declares.
- swarmctl: `agent permissions get|set|clear`.
- swarm-nats-auth: a hive reads every key of the bucket and sends its
  import on its own subject; the controller subscribes to the imports and
  may answer them (`responses`).
- hive-c0re: reads each of its agents' keys every 30s into
  /var/lib/hyperhive/swarm-agent-permissions.json and restarts an agent,
  gracefully, when its capabilities change. Unset is the compiled-in
  default (ruth: manage_root_agent; everyone else: none). A failed read
  keeps the stored value. On first boot it seeds that file from
  meta/capabilities.json, sends the grants to the controller, and deletes
  the file once the controller answers.

Deleted: the capabilities.json reader/writer, `HIVE_CAPABILITIES` in the
rendered meta flake, ruth's capability seed, the capabilities half of the
permissions rebuild job (`PermPayload::Capabilities`/`Combined`), the
dashboard's capabilities table and `capabilities_changed` event, and the
now-unused `Capability::description`.

Part of #5170.
This commit is contained in:
atlas 2026-10-15 00:24:41 +02:00 • committed by mara
commit f862354e43
47 changed files with 1943 additions and 1146 deletions

View file

@ -229,7 +229,9 @@ impl Policy {
return Some(Permissions {
publish: self.reader_subjects(),
subscribe: Some(Self::reader_subscribe(client_id)),
responses: None,
// The answer to a hive's permissions import. NATS lets it
// answer only a message it received, once.
responses: Some(swarm_queue_client::agent_permissions::IMPORT_REPLY_DEADLINE),
});
}
if self.review_classifiers.iter().any(|c| c == client_id) {
@ -307,7 +309,7 @@ impl Policy {
/// What a reader may subscribe to: every agent's terminal, its
/// subagents' terminals and its turn state, which the controller relays
/// to swarm-ui, and its own reply inbox.
/// to swarm-ui, every hive's permissions import, and its own reply inbox.
///
/// The three families are the publishers' (`hive-agent`'s `swarm_term`
/// and `swarm_agent_state`, `swarm_queue_client::subagent_term`), spelled
@ -318,6 +320,7 @@ impl Policy {
swarm_queue_client::term_subject_wildcard(),
swarm_queue_client::subagent_term::subject_wildcard(),
swarm_queue_client::agent_state_subject_wildcard(),
swarm_queue_client::agent_permissions::import_subject_wildcard(),
Self::inbox(client_id),
]
}
@ -561,6 +564,17 @@ impl Policy {
"$JS.API.DIRECT.GET.{}.>",
swarm_queue_client::agent_limits::STREAM
));
// The capabilities the swarm grants those agents
// (`swarm_queue_client::agent_permissions`), read the same way and
// every key for the same reason.
subjects.push(format!(
"$JS.API.DIRECT.GET.{}.>",
swarm_queue_client::agent_permissions::STREAM
));
// The one-shot import of the grants this hive's meta repo held, sent
// as a request on this hive's own subject: the controller takes the
// hive from the subject, so one hive cannot import for another.
subjects.push(swarm_queue_client::agent_permissions::import_subject(hive));
subjects.extend(
self.extra_hive_subjects
.iter()
@ -837,6 +851,78 @@ mod tests {
}
}
/// A hive reads any agent's permissions and can change none of them; an
/// agent, even one granted direct gets on its own keys elsewhere, reads
/// none of them.
#[test]
fn a_hive_may_read_every_agents_permissions_and_an_agent_none() {
use swarm_queue_client::agent_permissions::{STREAM, direct_get_subject, key_subject};
let p = policy()
.with_agent_token_subjects(vec![
"$JS.API.DIRECT.GET.KV_agent-inference.$KV.agent-inference.{agent}".to_owned(),
])
.expect("a per-agent template is valid");
let hive = p.permissions("hive-alpha").expect("a hive is admitted");
let hive_may = |subject: &str| hive.publish.iter().any(|g| covers(g, subject));
for agent in ["atlas", "ruth"] {
assert!(hive_may(&direct_get_subject(agent)), "cannot read {agent}");
}
for forbidden in [
key_subject("atlas"),
format!("$JS.API.STREAM.CREATE.{STREAM}"),
format!("$JS.API.STREAM.DELETE.{STREAM}"),
format!("$JS.API.STREAM.PURGE.{STREAM}"),
format!("$JS.API.CONSUMER.CREATE.{STREAM}"),
] {
assert!(!hive_may(&forbidden), "hive alpha may publish {forbidden}");
}
let agent = p.agent_token_permissions("atlas").expect("configured");
let agent_may = |subject: &str| agent.publish.iter().any(|g| covers(g, subject));
assert!(
!agent_may(&direct_get_subject("atlas")),
"an agent may read its own permissions: {:?}",
agent.publish
);
// Control: the agent's direct-get grant is real, just not this bucket.
assert!(agent_may(
&swarm_queue_client::agent_inference::direct_get_subject("atlas")
));
}
/// A hive sends its import on its own subject only; the reader hears every
/// hive's and may answer, and no other role may send one.
#[test]
fn a_hive_imports_only_under_its_own_name_and_the_reader_answers() {
use swarm_queue_client::agent_permissions::{
IMPORT_REPLY_DEADLINE, import_subject, import_subject_wildcard,
};
let p = policy();
let hive = p.permissions("hive-alpha").expect("a hive is admitted");
let hive_may = |subject: &str| hive.publish.iter().any(|g| covers(g, subject));
assert!(hive_may(&import_subject("alpha")));
assert!(!hive_may(&import_subject("beta")));
let reader = p.permissions("swarm-controller").expect("the reader");
assert!(
reader
.subscribe
.as_ref()
.expect("scoped")
.contains(&import_subject_wildcard())
);
assert_eq!(reader.responses, Some(IMPORT_REPLY_DEADLINE));
assert!(
!reader
.publish
.iter()
.any(|g| covers(g, &import_subject("alpha"))),
"the reader may send an import as a hive"
);
}
#[test]
fn an_extra_subject_without_the_placeholder_is_refused() {
// 🩸 The option exists to put a second stream inside ONE hive's
@ -1356,7 +1442,6 @@ mod tests {
.expect("a per-agent template is valid");
let reader = p.permissions("swarm-controller").expect("the reader");
assert!(reader.publish.iter().any(|g| covers(g, SUBJECT)));
assert_eq!(reader.responses, None);
for (who, grant) in [
("a hive", p.permissions("hive-alpha").expect("a hive")),
(