diff --git a/docs/swarm/README.md b/docs/swarm/README.md index 298c7408..2d320a8d 100644 --- a/docs/swarm/README.md +++ b/docs/swarm/README.md @@ -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 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 -later, with no start limit, until the queue accepts it. Once connected, the -client reconnects by itself after a dropped connection, reading the credential -from the store again for each attempt. +Either way the harness exits and comes back until the queue accepts it. Once +connected, the client reconnects by itself after a dropped connection, reading +the credential from the store again for each attempt. The hive's shared queue client is still delivered into the harness unit's 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 subagent daemon also presents, may subscribe to its reply inbox `_INBOX.agent..>` and its operator-input subject `$SWARM.input.`, -and nothing else. The hive's shared agent client may subscribe to its reply -inbox `_INBOX.client..>` only. The harness and the subagent daemon -connect with `_INBOX.agent.` as their inbox prefix, so the replies to -the icon write and the schedule read arrive. Neither credential can read -another agent's terminal, state, input or replies. Hives and the swarm -controller may still subscribe to any subject in the account. +and nothing else. The harness and the subagent daemon connect with +`_INBOX.agent.` as their inbox prefix, so the replies to the icon write +and the schedule read arrive. An agent can't read another agent's terminal, +state, input or replies. The queue refuses the hive's shared agent client +outright. Hives and the swarm controller may still subscribe to any subject in +the account. diff --git a/docs/swarm/schedules.md b/docs/swarm/schedules.md index 30dba13b..742d25f5 100644 --- a/docs/swarm/schedules.md +++ b/docs/swarm/schedules.md @@ -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 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 - prompts until a connect succeeds. See + about five minutes, then exits and comes back; it reads no prompts until a + connect succeeds. See [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 last list it read; until the first read succeeds it fires nothing. A read diff --git a/docs/turn-loop/README.md b/docs/turn-loop/README.md index fe763aa4..961f3a6d 100644 --- a/docs/turn-loop/README.md +++ b/docs/turn-loop/README.md @@ -226,8 +226,6 @@ uid 0 and have the setuid bit set." (`nix/agent-modules/agent-service.nix:535`); `packages.hive-agent` is wired to the flake's `hyperhive.packages..hive-agent` output (`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 `User=`, autocleared on stop. The harness writes regenerated `claude-{mcp-config,settings,system-prompt}` files there diff --git a/nix/host-modules/swarm-nats.nix b/nix/host-modules/swarm-nats.nix index abf63aaf..9d3bc941 100644 --- a/nix/host-modules/swarm-nats.nix +++ b/nix/host-modules/swarm-nats.nix @@ -812,26 +812,15 @@ in "--hive-client-prefix ${lib.escapeShellArg autheliaCfg.hiveClientPrefix}" "--agent-client-suffix ${lib.escapeShellArg autheliaCfg.agentClientSuffix}" "--reader-client ${lib.escapeShellArg controllerCfg.queueClientId}" - # What an agent may publish to, `{hive}` standing for the - # hive its client id names. Without it the responder has no - # agent grant to hand out and refuses every agent at CONNECT, - # so the terminal stream below depends on this line existing. + # What an agent may publish to, keyed on its own name: its + # terminal rows and its turn-state header + # (`hive-agent::swarm_agent_state`), two subject families so a + # 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` # whether or not it is quoted, so a single dollar reaches the - # responder as the empty expansion of an unset `SWARM` and the - # 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. + # responder as the empty expansion of an unset `SWARM`. "--agent-token-publish-subject ${lib.escapeShellArg "\$\$SWARM.term.{agent}"}" "--agent-token-publish-subject ${lib.escapeShellArg "\$\$SWARM.agent-state.{agent}"}" # 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 # subject directly instead of opening the bucket, so it needs # 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}"}" # Its subagents' terminal rows # (`swarm_queue_client::subagent_term`), publish only and no diff --git a/nix/module-eval/nats-authelia.nix b/nix/module-eval/nats-authelia.nix index fe9feca3..42880bb6 100644 --- a/nix/module-eval/nats-authelia.nix +++ b/nix/module-eval/nats-authelia.nix @@ -103,45 +103,21 @@ let ok = natsOldPath.containers.swarm-nats.config.services.nats.settings.max_payload == 8388608; } { - # Reads the RENDERED unit text, not the module's source, because the - # failure this defends against renders perfectly: systemd substitutes - # `$NAME` in `ExecStart` regardless of quoting, so a single dollar - # here hands the responder `.term.{hive}.>` — a grant that parses, is - # 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"; + # The responder refuses the hive's shared agent client and takes no flag + # granting it subjects: an `--agent-publish-subject` fails the unit at + # start. + name = "the responder grants the hive's shared agent client nothing"; ok = let exec = natsOldPath.containers.swarm-nats.config.systemd.services.swarm-nats-auth.serviceConfig.ExecStart; 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 - # subject families are independent features (terminal rows and the - # turn-state header) and dropping either should fail as its own arm - # 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. + # The per-agent grant: the terminal and agent-state streams keyed on the + # agent alone, with the doubled dollar, and the role the store writes for + # it. name = "the responder grants a verified agent its own hive-free subjects"; ok = let diff --git a/swarm-nats-auth/src/agent_token.rs b/swarm-nats-auth/src/agent_token.rs index 9c4eb24b..29d14f36 100644 --- a/swarm-nats-auth/src/agent_token.rs +++ b/swarm-nats-auth/src/agent_token.rs @@ -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 { diff --git a/swarm-nats-auth/src/main.rs b/swarm-nats-auth/src/main.rs index 4e804931..90f337f2 100644 --- a/swarm-nats-auth/src/main.rs +++ b/swarm-nats-auth/src/main.rs @@ -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-` reads as *the agent called ``* — the one thing @@ -118,18 +119,6 @@ struct Args { #[arg(long = "hive-publish-subject")] hive_publish_subjects: Vec, - /// 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, - /// 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()); diff --git a/swarm-nats-auth/src/policy.rs b/swarm-nats-auth/src/policy.rs index a465960f..cd6c226c 100644 --- a/swarm-nats-auth/src/policy.rs +++ b/swarm-nats-auth/src/policy.rs @@ -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>, @@ -45,12 +45,10 @@ pub struct Policy { bucket: String, readers: Vec, extra_hive_subjects: Vec, - extra_agent_subjects: Vec, agent_token_subjects: Vec, } -/// 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-` and `hive--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-` reads as *the agent called ``*, @@ -88,7 +85,6 @@ impl Policy { bucket: String, readers: Vec, extra_hive_subjects: Vec, - extra_agent_subjects: Vec, ) -> anyhow::Result { 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 { - self.extra_agent_subjects - .iter() - .map(|s| s.replace(HIVE_PLACEHOLDER, hive)) - .collect() - } - fn reader_subjects(&self) -> Vec { 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-` - // to `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] diff --git a/swarm-queue-client/src/agent_icon.rs b/swarm-queue-client/src/agent_icon.rs index b7c08966..0186022a 100644 --- a/swarm-queue-client/src/agent_icon.rs +++ b/swarm-queue-client/src/agent_icon.rs @@ -16,9 +16,6 @@ //! //! **Each agent writes its own key** with its own queue credential. The auth //! callout grants that credential `$KV.agent-icons.` 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 //! opening the bucket, so that one subject is its whole grant. Creating the diff --git a/swarm-queue-client/src/lib.rs b/swarm-queue-client/src/lib.rs index 9e17485e..7ff3c597 100644 --- a/swarm-queue-client/src/lib.rs +++ b/swarm-queue-client/src/lib.rs @@ -260,10 +260,9 @@ pub struct DeployRequest { /// on. That agent's harness subscribes to its own and turns each message into /// an inbox turn. /// -/// Its own family rather than a leaf under `$SWARM.term.`: every agent on a -/// hive's shared credential may publish under `$SWARM.term..>`, and the -/// harness trusts [`AgentInput::from`] only because the controller is the one -/// client granted publish here. +/// Its own family rather than a leaf under `$SWARM.term.`, so no agent's +/// terminal grant covers it: the harness trusts [`AgentInput::from`] only +/// because the controller is the one client granted publish here. #[must_use] pub fn agent_input_subject(agent: &str) -> String { 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 /// [`agent_input_subject`], nothing else, so a request made under the /// library's default `_INBOX.` is refused its reply and times out. The -/// `agent` token keeps it disjoint from [`client_inbox_prefix`] and from the -/// default, whose second token is a 22-character nuid. +/// `agent` token keeps it disjoint from the default, whose second token is a +/// 22-character nuid. #[must_use] pub fn agent_inbox_prefix(agent: &str) -> String { 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. #[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)] pub struct AgentInput {