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

@ -158,10 +158,9 @@ show the agent nor send it anything, so a harness without one stops:
wait each time, and logs a warning for each attempt that fails. After the wait each time, and logs a warning for each attempt that fails. After the
seventh, about five minutes in, it logs an error and exits. seventh, about five minutes in, it logs an error and exits.
Either way the `hive-agent` unit fails, and systemd starts it again 30 seconds Either way the harness exits and comes back until the queue accepts it. Once
later, with no start limit, until the queue accepts it. Once connected, the connected, the client reconnects by itself after a dropped connection, reading
client reconnects by itself after a dropped connection, reading the credential the credential from the store again for each attempt.
from the store again for each attempt.
The hive's shared queue client is still delivered into the harness unit's The hive's shared queue client is still delivered into the harness unit's
credentials directory, and `swarm.statusPublish.tokenEndpoint` still reaches credentials directory, and `swarm.statusPublish.tokenEndpoint` still reaches
@ -244,12 +243,12 @@ The queue also limits what an agent may **subscribe** to, because agents are the
least trusted clients on the queue. An agent's own queue credential, which its least trusted clients on the queue. An agent's own queue credential, which its
subagent daemon also presents, may subscribe to its reply inbox subagent daemon also presents, may subscribe to its reply inbox
`_INBOX.agent.<agent>.>` and its operator-input subject `$SWARM.input.<agent>`, `_INBOX.agent.<agent>.>` and its operator-input subject `$SWARM.input.<agent>`,
and nothing else. The hive's shared agent client may subscribe to its reply and nothing else. The harness and the subagent daemon connect with
inbox `_INBOX.client.<client-id>.>` only. The harness and the subagent daemon `_INBOX.agent.<agent>` as their inbox prefix, so the replies to the icon write
connect with `_INBOX.agent.<agent>` as their inbox prefix, so the replies to and the schedule read arrive. An agent can't read another agent's terminal,
the icon write and the schedule read arrive. Neither credential can read state, input or replies. The queue refuses the hive's shared agent client
another agent's terminal, state, input or replies. Hives and the swarm outright. Hives and the swarm controller may still subscribe to any subject in
controller may still subscribe to any subject in the account. the account.
</details> </details>

View file

@ -78,8 +78,8 @@ or within a minute if the controller's first attempt to pass it on failed.
- **The agent isn't connected to the swarm queue.** It reads its prompts with - **The agent isn't connected to the swarm queue.** It reads its prompts with
its own swarm-queue credential. A harness that can't connect retries for its own swarm-queue credential. A harness that can't connect retries for
about five minutes, then exits and restarts 30 seconds later; it reads no about five minutes, then exits and comes back; it reads no prompts until a
prompts until a connect succeeds. See connect succeeds. See
[Giving the agents the queue too](README.md#giving-the-agents-the-queue-too). [Giving the agents the queue too](README.md#giving-the-agents-the-queue-too).
- **The agent hasn't read its prompts since it started.** It fires from the - **The agent hasn't read its prompts since it started.** It fires from the
last list it read; until the first read succeeds it fires nothing. A read last list it read; until the first read succeeds it fires nothing. A read

View file

@ -226,8 +226,6 @@ uid 0 and have the setuid bit set."
(`nix/agent-modules/agent-service.nix:535`); `packages.hive-agent` is (`nix/agent-modules/agent-service.nix:535`); `packages.hive-agent` is
wired to the flake's `hyperhive.packages.<system>.hive-agent` output wired to the flake's `hyperhive.packages.<system>.hive-agent` output
(`nix/agent-modules/packages.nix:8-22`) — same binary for every agent. (`nix/agent-modules/packages.nix:8-22`) — same binary for every agent.
- `Restart = on-failure`, `RestartSec = 2` — keeps the harness
resilient across transient crashes without thundering retries.
- `RuntimeDirectory = "hive-config"` → `/run/hive-config/` owned by - `RuntimeDirectory = "hive-config"` → `/run/hive-config/` owned by
`User=`, autocleared on stop. The harness writes regenerated `User=`, autocleared on stop. The harness writes regenerated
`claude-{mcp-config,settings,system-prompt}` files there `claude-{mcp-config,settings,system-prompt}` files there

View file

@ -812,26 +812,15 @@ in
"--hive-client-prefix ${lib.escapeShellArg autheliaCfg.hiveClientPrefix}" "--hive-client-prefix ${lib.escapeShellArg autheliaCfg.hiveClientPrefix}"
"--agent-client-suffix ${lib.escapeShellArg autheliaCfg.agentClientSuffix}" "--agent-client-suffix ${lib.escapeShellArg autheliaCfg.agentClientSuffix}"
"--reader-client ${lib.escapeShellArg controllerCfg.queueClientId}" "--reader-client ${lib.escapeShellArg controllerCfg.queueClientId}"
# What an agent may publish to, `{hive}` standing for the # What an agent may publish to, keyed on its own name: its
# hive its client id names. Without it the responder has no # terminal rows and its turn-state header
# agent grant to hand out and refuses every agent at CONNECT, # (`hive-agent::swarm_agent_state`), two subject families so a
# so the terminal stream below depends on this line existing. # subscriber following one agent's terminal is not also handed
# every header. The hive's shared agent client gets nothing.
# #
# `$$`, not `$`: systemd substitutes `$NAME` in `ExecStart` # `$$`, not `$`: systemd substitutes `$NAME` in `ExecStart`
# whether or not it is quoted, so a single dollar reaches the # whether or not it is quoted, so a single dollar reaches the
# responder as the empty expansion of an unset `SWARM` and the # responder as the empty expansion of an unset `SWARM`.
# grant silently becomes `.term.{hive}.>`.
"--agent-publish-subject ${lib.escapeShellArg "\$\$SWARM.term.{hive}.>"}"
# The turn-state header the swarm's agent view renders from
# (`hive-agent::swarm_agent_state`). Its own subject family
# rather than a leaf under `.term.`: a subscriber following
# one agent's terminal should not also be handed every
# header, and the two have opposite shapes — a terminal is
# an append-only row stream, a header is one current value
# republished on change.
"--agent-publish-subject ${lib.escapeShellArg "\$\$SWARM.agent-state.{hive}.>"}"
# What an agent that proved its own credential may publish to:
# the same two streams, keyed on the agent alone.
"--agent-token-publish-subject ${lib.escapeShellArg "\$\$SWARM.term.{agent}"}" "--agent-token-publish-subject ${lib.escapeShellArg "\$\$SWARM.term.{agent}"}"
"--agent-token-publish-subject ${lib.escapeShellArg "\$\$SWARM.agent-state.{agent}"}" "--agent-token-publish-subject ${lib.escapeShellArg "\$\$SWARM.agent-state.{agent}"}"
# Its own key in the `agent-icons` KV bucket, and nothing else # Its own key in the `agent-icons` KV bucket, and nothing else
@ -840,9 +829,6 @@ in
# one subject is the whole write. The harness publishes to the # one subject is the whole write. The harness publishes to the
# subject directly instead of opening the bucket, so it needs # subject directly instead of opening the bucket, so it needs
# no `$JS.API.*` subject; swarm-controller creates the bucket. # no `$JS.API.*` subject; swarm-controller creates the bucket.
# Not granted to the hive's shared agent client: every agent
# on a hive presents that one, so it cannot be scoped to one
# agent's key.
"--agent-token-publish-subject ${lib.escapeShellArg "\$\$KV.agent-icons.{agent}"}" "--agent-token-publish-subject ${lib.escapeShellArg "\$\$KV.agent-icons.{agent}"}"
# Its subagents' terminal rows # Its subagents' terminal rows
# (`swarm_queue_client::subagent_term`), publish only and no # (`swarm_queue_client::subagent_term`), publish only and no

View file

@ -103,45 +103,21 @@ let
ok = natsOldPath.containers.swarm-nats.config.services.nats.settings.max_payload == 8388608; ok = natsOldPath.containers.swarm-nats.config.services.nats.settings.max_payload == 8388608;
} }
{ {
# Reads the RENDERED unit text, not the module's source, because the # The responder refuses the hive's shared agent client and takes no flag
# failure this defends against renders perfectly: systemd substitutes # granting it subjects: an `--agent-publish-subject` fails the unit at
# `$NAME` in `ExecStart` regardless of quoting, so a single dollar # start.
# here hands the responder `.term.{hive}.>` — a grant that parses, is name = "the responder grants the hive's shared agent client nothing";
# accepted, and matches nothing an agent ever publishes to. Asserting
# the doubled dollar is the only way to tell the two apart before
# deploy. The flag's presence is asserted separately so that dropping
# the grant entirely fails as its own arm rather than as an escaping
# complaint.
name = "the responder grants agents their hive's terminal subject, and the dollar survives systemd";
ok = ok =
let let
exec = exec =
natsOldPath.containers.swarm-nats.config.systemd.services.swarm-nats-auth.serviceConfig.ExecStart; natsOldPath.containers.swarm-nats.config.systemd.services.swarm-nats-auth.serviceConfig.ExecStart;
in in
lib.hasInfix "--agent-publish-subject " exec && lib.hasInfix "$$SWARM.term.{hive}.>" exec; !(lib.hasInfix "--agent-publish-subject " exec);
} }
{ {
# Second grant, same escaping trap, asserted separately: the two # The per-agent grant: the terminal and agent-state streams keyed on the
# subject families are independent features (terminal rows and the # agent alone, with the doubled dollar, and the role the store writes for
# turn-state header) and dropping either should fail as its own arm # it.
# rather than being masked by the other still being present.
#
# Flag and argument are matched as one infix rather than as two
# independent `hasInfix` calls: the responder takes the flag
# repeatedly, so the thing worth pinning is that THIS subject is the
# argument of one of them, which two separate presence checks would
# both pass on while the subject sat under some other flag entirely.
name = "the responder grants agents their hive's agent-state subject too";
ok =
let
exec =
natsOldPath.containers.swarm-nats.config.systemd.services.swarm-nats-auth.serviceConfig.ExecStart;
in
lib.hasInfix "--agent-publish-subject '$$SWARM.agent-state.{hive}.>'" exec;
}
{
# The per-agent grant: the same two streams keyed on the agent alone,
# with the same doubled dollar, and the role the store writes for it.
name = "the responder grants a verified agent its own hive-free subjects"; name = "the responder grants a verified agent its own hive-free subjects";
ok = ok =
let let

View file

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

View file

@ -91,7 +91,8 @@ struct Args {
/// config change plus a reload. /// config change plus a reload.
/// ///
/// So this identity says which hive an agent belongs to and never which /// 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 /// 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 /// `agent-<name>` reads as *the agent called `<name>`* — the one thing
@ -118,18 +119,6 @@ struct Args {
#[arg(long = "hive-publish-subject")] #[arg(long = "hive-publish-subject")]
hive_publish_subjects: Vec<String>, 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, /// Subjects an agent that presented its own credential may publish to,
/// with `{agent}` standing for its name. Repeatable, empty by default, /// with `{agent}` standing for its name. Repeatable, empty by default,
/// which denies every agent token. /// which denies every agent token.
@ -190,7 +179,6 @@ async fn main() -> anyhow::Result<()> {
swarm_queue_client::status::BUCKET.to_owned(), swarm_queue_client::status::BUCKET.to_owned(),
args.reader_clients.clone(), args.reader_clients.clone(),
args.hive_publish_subjects.clone(), args.hive_publish_subjects.clone(),
args.agent_publish_subjects.clone(),
)? )?
.with_agent_token_subjects(args.agent_token_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()); let store = agent_token::Store::from_env(args.store_cert_role.clone());

View file

@ -27,7 +27,7 @@ pub struct Permissions {
/// account. /// account.
/// ///
/// `Some` only for agents, the least trusted clients: their own reply /// `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 /// Hives and readers get `None`. Never `Some(vec![])`: NATS reads an empty
/// allow-list as no restriction at all. /// allow-list as no restriction at all.
pub subscribe: Option<Vec<String>>, pub subscribe: Option<Vec<String>>,
@ -45,12 +45,10 @@ pub struct Policy {
bucket: String, bucket: String,
readers: Vec<String>, readers: Vec<String>,
extra_hive_subjects: Vec<String>, extra_hive_subjects: Vec<String>,
extra_agent_subjects: Vec<String>,
agent_token_subjects: Vec<String>, agent_token_subjects: Vec<String>,
} }
/// Placeholder replaced with the hive's own name in `extra_hive_subjects` and /// Placeholder replaced with the hive's own name in `extra_hive_subjects`.
/// `extra_agent_subjects`.
const HIVE_PLACEHOLDER: &str = "{hive}"; const HIVE_PLACEHOLDER: &str = "{hive}";
/// Placeholder replaced with the agent's own name in `agent_token_subjects`. /// 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 — /// `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 /// `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 /// report status in, `readers` the client ids allowed to read every hive's
/// key, and `extra_hive_subjects` / `extra_agent_subjects` additional /// key, and `extra_hive_subjects` additional subjects a hive may publish to
/// subjects each may publish to (with `{hive}` standing for the hive's own /// (with `{hive}` standing for the hive's own name).
/// name).
/// ///
/// The agent id is deliberately the hive's id extended, not a second /// The agent id is deliberately the hive's id extended, not a second
/// top-level prefix: `agent-<name>` reads as *the agent called `<name>`*, /// top-level prefix: `agent-<name>` reads as *the agent called `<name>`*,
@ -88,7 +85,6 @@ impl Policy {
bucket: String, bucket: String,
readers: Vec<String>, readers: Vec<String>,
extra_hive_subjects: Vec<String>, extra_hive_subjects: Vec<String>,
extra_agent_subjects: Vec<String>,
) -> anyhow::Result<Self> { ) -> anyhow::Result<Self> {
if let Some(bad) = extra_hive_subjects if let Some(bad) = extra_hive_subjects
.iter() .iter()
@ -100,32 +96,16 @@ impl Policy {
a per-hive namespace" 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 // Checked here rather than trusted from nix, because these are two
// flags and nothing downstream compares them. An agent id is a hive id // 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 // plus this suffix, so an empty one makes `strip_suffix` succeed on
// every hive id and the two principals become the same string. What // every hive id and the two principals become the same string. The
// that costs is not a parse error but a **grant**: whichever arm runs // agent arm runs first and refuses, so every hive would be refused as
// first answers for both, and a client that authenticates fine is // its own agents' client.
// handed more than it should have — the one failure this module must
// not have.
if agent_suffix.is_empty() { if agent_suffix.is_empty() {
anyhow::bail!( anyhow::bail!(
"--agent-client-suffix is empty: an agent id is a hive id plus this suffix, \ "--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 \ so every hive would also parse as its own agents' client and be refused"
silently receive the other's grant"
); );
} }
Ok(Self { Ok(Self {
@ -134,7 +114,6 @@ impl Policy {
bucket, bucket,
readers, readers,
extra_hive_subjects, extra_hive_subjects,
extra_agent_subjects,
agent_token_subjects: Vec::new(), agent_token_subjects: Vec::new(),
}) })
} }
@ -193,24 +172,12 @@ impl Policy {
// should. Most specific wins, so the more specific test is the one // should. Most specific wins, so the more specific test is the one
// that has to be asked first. // that has to be asked first.
// //
// An agent's identity names its hive, never the agent — see // A hive's shared agent client is refused outright. Agents connect with
// `Self::agent_hive`. The grant is whatever the deployment configured // their own credential (`Self::agent_token_permissions`); this identity
// for agents, expanded for that hive, and **nothing when it configured // names the hive and never the agent (see `Self::agent_hive`), so any
// none**: an empty publish list would be a denial wearing a grant's // grant to it would let one agent publish as its siblings.
// shape, so this returns `None` and the client is refused outright. if self.agent_hive(client_id).is_some() {
// return None;
// 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)
)]),
});
} }
if let Some(hive) = self.hive_name(client_id) { if let Some(hive) = self.hive_name(client_id) {
return Some(Permissions { return Some(Permissions {
@ -459,19 +426,6 @@ impl Policy {
subjects 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> { fn reader_subjects(&self) -> Vec<String> {
let stream = self.stream(); let stream = self.stream();
let mut subjects = Self::jetstream_minimum().to_vec(); let mut subjects = Self::jetstream_minimum().to_vec();
@ -583,26 +537,10 @@ mod tests {
"hive-status".to_owned(), "hive-status".to_owned(),
vec!["swarm-controller".to_owned()], vec!["swarm-controller".to_owned()],
Vec::new(), Vec::new(),
Vec::new(),
) )
.expect("the default policy is valid") .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] #[test]
fn the_reader_can_open_and_list_the_agent_status_bucket() { fn the_reader_can_open_and_list_the_agent_status_bucket() {
let subjects = policy().reader_subjects(); let subjects = policy().reader_subjects();
@ -701,7 +639,6 @@ mod tests {
"hive-status".to_owned(), "hive-status".to_owned(),
Vec::new(), Vec::new(),
vec!["$SWARM.events.all".to_owned()], vec!["$SWARM.events.all".to_owned()],
Vec::new(),
) )
.expect_err("a subject shared by every hive must not be accepted"); .expect_err("a subject shared by every hive must not be accepted");
let msg = format!("{err}"); let msg = format!("{err}");
@ -721,7 +658,6 @@ mod tests {
"hive-status".to_owned(), "hive-status".to_owned(),
Vec::new(), Vec::new(),
vec!["$SWARM.events.{hive}.>".to_owned()], vec!["$SWARM.events.{hive}.>".to_owned()],
Vec::new(),
) )
.expect("a per-hive template is the shape this option is for"); .expect("a per-hive template is the shape this option is for");
} }
@ -818,30 +754,27 @@ mod tests {
} }
#[test] #[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 // 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 // it. A hive able to publish here could speak as the operator to any
// to any agent in the swarm. // agent in the swarm.
let policy = policy_with_agent_subject(); let p = policy().permissions("hive-alpha").expect("admitted");
for client in ["hive-alpha", "hive-alpha-agent"] {
let p = policy.permissions(client).expect("admitted");
for forbidden in [ for forbidden in [
swarm_queue_client::agent_input_subject("alpha"), swarm_queue_client::agent_input_subject("alpha"),
swarm_queue_client::AGENT_INPUT_SUBJECT_WILDCARD.to_owned(), swarm_queue_client::AGENT_INPUT_SUBJECT_WILDCARD.to_owned(),
] { ] {
assert!( assert!(
!p.publish.contains(&forbidden), !p.publish.contains(&forbidden),
"{client} must not publish {forbidden}: {:?}", "a hive must not publish {forbidden}: {:?}",
p.publish p.publish
); );
} }
assert!( assert!(
!p.publish.iter().any(|s| s.starts_with("$SWARM.input")), !p.publish.iter().any(|s| s.starts_with("$SWARM.input")),
"{client}: {:?}", "{:?}",
p.publish p.publish
); );
} }
}
#[test] #[test]
fn a_hive_grant_never_includes_the_jetstream_wildcard() { fn a_hive_grant_never_includes_the_jetstream_wildcard() {
@ -981,9 +914,9 @@ mod tests {
#[test] #[test]
fn an_agent_token_may_subscribe_to_its_own_inbox_and_input_only() { 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()]) .with_agent_token_subjects(vec!["$SWARM.term.{agent}".to_owned()])
.expect("a per-agent template is valid"); .expect("a per-agent template is valid");
let atlas = p.agent_token_permissions("atlas").expect("configured"); let atlas = p.agent_token_permissions("atlas").expect("configured");
@ -997,7 +930,6 @@ mod tests {
"$SWARM.input.*".to_owned(), "$SWARM.input.*".to_owned(),
agent_input_subject("argus"), agent_input_subject("argus"),
reply(&agent_inbox_prefix("argus")), reply(&agent_inbox_prefix("argus")),
reply(&client_inbox_prefix("hive-alpha-agent")),
"_INBOX.nuid123.token456".to_owned(), "_INBOX.nuid123.token456".to_owned(),
] { ] {
assert!( 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] #[test]
fn hives_and_readers_keep_an_unrestricted_subscribe() { fn hives_and_readers_keep_an_unrestricted_subscribe() {
let p = policy(); let p = policy();
@ -1252,7 +1158,6 @@ mod tests {
"hive-status".to_owned(), "hive-status".to_owned(),
Vec::new(), Vec::new(),
vec!["$SWARM.events.{hive}.>".to_owned()], vec!["$SWARM.events.{hive}.>".to_owned()],
Vec::new(),
) )
.expect("a per-hive template is valid"); .expect("a per-hive template is valid");
let g = p.permissions("hive-alpha").expect("a hive is admitted"); let g = p.permissions("hive-alpha").expect("a hive is admitted");
@ -1261,106 +1166,40 @@ mod tests {
} }
#[test] #[test]
fn an_agent_with_no_configured_subject_is_refused() { fn the_hives_shared_agent_client_is_refused() {
// The shipped default: the identity exists, what it may say does not. // Agents connect with their own credential. The shared client names
// A grant with an empty publish list would let it connect and then // the hive and never the agent, so any grant to it would let one agent
// fail on every publish, which reads as a broken queue rather than as // publish as its siblings.
// 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.
// //
// 🩸 This became a REGRESSION test when the id moved from `agent-<hive>` // 🩸 The agent id satisfies the HIVE parse too (`hive-alpha-agent`
// to `hive-<hive>-agent`: the agent id now satisfies the HIVE parse too // strips to a hive named `alpha-agent`), so a hive-first arm order
// (`hive-alpha-agent` strips to a hive named `alpha-agent`), so a // would hand this client its hive's grant, status key included, and
// hive-first arm order hands every agent its hive's grant, and nothing // nothing would report it. `is_none` here is what holds the order.
// reports it — the client authenticates and is merely able to do more assert!(policy().permissions("hive-alpha-agent").is_none());
// than it should. Together with the `assert_eq` in // Control: the hive itself is admitted, so the refusal above is the
// `an_agent_publishes_inside_its_own_hives_namespace` (which pins the // agent arm answering rather than the prefix failing to match.
// subject to `alpha`, not `alpha-agent`) this is what holds the assert!(policy().permissions("hive-alpha").is_some());
// 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"
);
} }
#[test] #[test]
fn an_empty_agent_suffix_is_refused() { fn an_empty_agent_suffix_is_refused() {
// 🩸 An agent id is a hive id plus the suffix, so an empty suffix makes // 🩸 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 // `strip_suffix` succeed on every hive id: the two principals become
// one string, and whichever arm runs first answers for both. That is a // one string, and the agent arm, which runs first and refuses, would
// GRANT, not a parse error, which is why it has to fail at // refuse every hive. That fails at construction rather than at connect.
// construction.
Policy::new( Policy::new(
"hive-".to_owned(), "hive-".to_owned(),
String::new(), String::new(),
"hive-status".to_owned(), "hive-status".to_owned(),
Vec::new(), Vec::new(),
Vec::new(), Vec::new(),
Vec::new(),
) )
.expect_err("an empty agent suffix must not construct"); .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] #[test]
fn an_agent_token_grant_is_the_agents_own_subjects_and_nothing_else() { 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![ .with_agent_token_subjects(vec![
"$SWARM.term.{agent}".to_owned(), "$SWARM.term.{agent}".to_owned(),
"$SWARM.agent-state.{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 /// A verified agent may write its own icon key and no other agent's.
/// hive's shared agent client, which every agent on the hive presents, may
/// write none of the bucket.
#[test] #[test]
fn an_agent_may_write_only_its_own_icon_key() { 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()]) .with_agent_token_subjects(vec!["$KV.agent-icons.{agent}".to_owned()])
.expect("a per-agent template is valid"); .expect("a per-agent template is valid");
let atlas = p.agent_token_permissions("atlas").expect("configured"); let atlas = p.agent_token_permissions("atlas").expect("configured");
@ -1393,15 +1230,6 @@ mod tests {
// lacking it is atlas's grant being scoped. // lacking it is atlas's grant being scoped.
let argus = p.agent_token_permissions("argus").expect("configured"); let argus = p.agent_token_permissions("argus").expect("configured");
assert_eq!(argus.publish, vec![subject("argus")]); 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 /// 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() { fn an_agents_subagent_grant_is_publish_on_its_own_family_only() {
use swarm_queue_client::subagent_term::{stream_name, stream_subjects, subject}; 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()]) .with_agent_token_subjects(vec!["$SWARM.term.{agent}.sub.>".to_owned()])
.expect("a per-agent template is valid"); .expect("a per-agent template is valid");
let g = p.agent_token_permissions("atlas").expect("configured"); 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 /// 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, /// 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 /// and no way to open, watch or write the bucket.
/// client gets none of it.
#[test] #[test]
fn an_agent_may_read_only_its_own_schedule_key() { fn an_agent_may_read_only_its_own_schedule_key() {
use swarm_queue_client::agent_schedules::{ use swarm_queue_client::agent_schedules::{STREAM, direct_get_subject, key_subject};
BUCKET, STREAM, direct_get_subject, key_subject,
};
let p = policy_with_agent_subject() let p = policy()
.with_agent_token_subjects(vec![ .with_agent_token_subjects(vec![
"$JS.API.DIRECT.GET.KV_agent-schedules.$KV.agent-schedules.{agent}".to_owned(), "$JS.API.DIRECT.GET.KV_agent-schedules.$KV.agent-schedules.{agent}".to_owned(),
]) ])
@ -1483,12 +1308,6 @@ mod tests {
// vacuous. // vacuous.
assert!(covers(&atlas.publish[0], &direct_get_subject("atlas"))); assert!(covers(&atlas.publish[0], &direct_get_subject("atlas")));
assert!(covers("$KV.*.>", &key_subject("argus"))); 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] #[test]

View file

@ -16,9 +16,6 @@
//! //!
//! **Each agent writes its own key** with its own queue credential. The auth //! **Each agent writes its own key** with its own queue credential. The auth
//! callout grants that credential `$KV.agent-icons.<agent>` and no other key. //! callout grants that credential `$KV.agent-icons.<agent>` and no other key.
//! The hive's shared agent client gets nothing in this bucket: every agent on
//! a hive presents that same client, so its grant cannot be narrowed to one
//! agent's key.
//! //!
//! The writer publishes to [`crate::agent_icon::subject`] directly instead of //! The writer publishes to [`crate::agent_icon::subject`] directly instead of
//! opening the bucket, so that one subject is its whole grant. Creating the //! opening the bucket, so that one subject is its whole grant. Creating the

View file

@ -260,10 +260,9 @@ pub struct DeployRequest {
/// on. That agent's harness subscribes to its own and turns each message into /// on. That agent's harness subscribes to its own and turns each message into
/// an inbox turn. /// an inbox turn.
/// ///
/// Its own family rather than a leaf under `$SWARM.term.`: every agent on a /// Its own family rather than a leaf under `$SWARM.term.`, so no agent's
/// hive's shared credential may publish under `$SWARM.term.<hive>.>`, and the /// terminal grant covers it: the harness trusts [`AgentInput::from`] only
/// harness trusts [`AgentInput::from`] only because the controller is the one /// because the controller is the one client granted publish here.
/// client granted publish here.
#[must_use] #[must_use]
pub fn agent_input_subject(agent: &str) -> String { pub fn agent_input_subject(agent: &str) -> String {
format!("{AGENT_INPUT_SUBJECT_PREFIX}.{agent}") format!("{AGENT_INPUT_SUBJECT_PREFIX}.{agent}")
@ -283,21 +282,13 @@ const AGENT_INPUT_SUBJECT_PREFIX: &str = "$SWARM.input";
/// The queue grants that credential subscribe on this prefix's `.>` and its /// The queue grants that credential subscribe on this prefix's `.>` and its
/// [`agent_input_subject`], nothing else, so a request made under the /// [`agent_input_subject`], nothing else, so a request made under the
/// library's default `_INBOX.<nuid>` is refused its reply and times out. The /// library's default `_INBOX.<nuid>` is refused its reply and times out. The
/// `agent` token keeps it disjoint from [`client_inbox_prefix`] and from the /// `agent` token keeps it disjoint from the default, whose second token is a
/// default, whose second token is a 22-character nuid. /// 22-character nuid.
#[must_use] #[must_use]
pub fn agent_inbox_prefix(agent: &str) -> String { pub fn agent_inbox_prefix(agent: &str) -> String {
format!("_INBOX.agent.{agent}") format!("_INBOX.agent.{agent}")
} }
/// The reply-inbox prefix for a client authenticating as the OIDC client
/// `client_id`, for a client whose grant limits subscribe to this prefix's
/// `.>` — see [`agent_inbox_prefix`].
#[must_use]
pub fn client_inbox_prefix(client_id: &str) -> String {
format!("_INBOX.client.{client_id}")
}
/// What an [`agent_input_subject`] message carries. /// What an [`agent_input_subject`] message carries.
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)] #[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub struct AgentInput { pub struct AgentInput {