publish operators' forge-profile pronouns to kv and list them in the agent prompt
swarm-controller reads each member of authelia's operator group's forge profile every five minutes and keeps one key per operator in the new operator-profiles bucket. Every agent may list and read that bucket, and hive-agent renders it into the system prompt at harness start, falling back to today's prompt when the bucket is empty or doesn't answer within five seconds. Fixes #5383
This commit is contained in:
parent
a2b4320cc4
commit
6942f8fd27
16 changed files with 681 additions and 35 deletions
|
|
@ -118,6 +118,10 @@ mod tests {
|
|||
"$SWARM.term.atlas".to_owned(),
|
||||
"$SWARM.agent-state.atlas".to_owned(),
|
||||
"$KV.agent-icons.atlas".to_owned(),
|
||||
"$JS.API.STREAM.INFO.KV_operator-profiles".to_owned(),
|
||||
"$JS.API.CONSUMER.CREATE.KV_operator-profiles".to_owned(),
|
||||
"$JS.API.CONSUMER.CREATE.KV_operator-profiles.>".to_owned(),
|
||||
"$JS.API.DIRECT.GET.KV_operator-profiles.>".to_owned(),
|
||||
]
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -173,12 +173,16 @@ impl Policy {
|
|||
/// must already be a single `[A-Za-z0-9_-]` segment, which the token parse
|
||||
/// guarantees, so it cannot widen a subject with `.`, `*` or `>`.
|
||||
pub fn agent_token_permissions(&self, agent: &str) -> Option<Permissions> {
|
||||
let publish: Vec<String> = self
|
||||
let mut publish: Vec<String> = self
|
||||
.agent_token_subjects
|
||||
.iter()
|
||||
.map(|s| s.replace(AGENT_PLACEHOLDER, agent))
|
||||
.collect();
|
||||
(!publish.is_empty()).then(|| Permissions {
|
||||
if publish.is_empty() {
|
||||
return None;
|
||||
}
|
||||
publish.extend(Self::operator_profiles_read());
|
||||
Some(Permissions {
|
||||
publish,
|
||||
subscribe: Some(vec![
|
||||
format!("{}.>", swarm_queue_client::agent_inbox_prefix(agent)),
|
||||
|
|
@ -196,6 +200,26 @@ impl Policy {
|
|||
})
|
||||
}
|
||||
|
||||
/// Reading every key of the operator-profile bucket
|
||||
/// (`swarm_queue_client::operator_profiles::read_all`), which an agent
|
||||
/// renders into its system prompt.
|
||||
///
|
||||
/// The whole bucket, since every agent reads every operator. Not an
|
||||
/// `{agent}` template, so it is granted here rather than through
|
||||
/// `--agent-token-publish-subject`. `STREAM.INFO` opens the bucket, both
|
||||
/// `CONSUMER.CREATE` forms are the `keys()` listing (an ephemeral consumer
|
||||
/// carries no name, and `>` never matches zero tokens), and `DIRECT.GET`
|
||||
/// reads each key. No `$KV.` subject: the controller is the only writer.
|
||||
fn operator_profiles_read() -> [String; 4] {
|
||||
let stream = swarm_queue_client::operator_profiles::STREAM;
|
||||
[
|
||||
format!("$JS.API.STREAM.INFO.{stream}"),
|
||||
format!("$JS.API.CONSUMER.CREATE.{stream}"),
|
||||
format!("$JS.API.CONSUMER.CREATE.{stream}.>"),
|
||||
format!("$JS.API.DIRECT.GET.{stream}.>"),
|
||||
]
|
||||
}
|
||||
|
||||
/// The permissions for `client_id`, or `None` when no rule matches.
|
||||
///
|
||||
/// `None` is a denial. It is not "grant nothing and let them connect":
|
||||
|
|
@ -724,6 +748,18 @@ mod tests {
|
|||
.expect("the default policy is valid")
|
||||
}
|
||||
|
||||
/// An agent grant's publish list without the operator-profile read every
|
||||
/// agent holds, so a test can compare the templated part exactly.
|
||||
fn templated(grant: &Permissions) -> Vec<String> {
|
||||
let shared = Policy::operator_profiles_read();
|
||||
grant
|
||||
.publish
|
||||
.iter()
|
||||
.filter(|s| !shared.contains(s))
|
||||
.cloned()
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_reader_can_open_and_list_the_agent_status_bucket() {
|
||||
let subjects = policy().reader_subjects();
|
||||
|
|
@ -1878,6 +1914,10 @@ mod tests {
|
|||
vec![
|
||||
"$SWARM.term.atlas".to_owned(),
|
||||
"$SWARM.agent-state.atlas".to_owned(),
|
||||
"$JS.API.STREAM.INFO.KV_operator-profiles".to_owned(),
|
||||
"$JS.API.CONSUMER.CREATE.KV_operator-profiles".to_owned(),
|
||||
"$JS.API.CONSUMER.CREATE.KV_operator-profiles.>".to_owned(),
|
||||
"$JS.API.DIRECT.GET.KV_operator-profiles.>".to_owned(),
|
||||
]
|
||||
);
|
||||
}
|
||||
|
|
@ -1891,12 +1931,12 @@ mod tests {
|
|||
.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");
|
||||
assert_eq!(atlas.publish, vec![subject("atlas")]);
|
||||
assert_eq!(templated(&atlas), vec![subject("atlas")]);
|
||||
assert!(!atlas.publish.contains(&subject("argus")));
|
||||
// Control: the same template does grant argus its own key, so atlas
|
||||
// lacking it is atlas's grant being scoped.
|
||||
let argus = p.agent_token_permissions("argus").expect("configured");
|
||||
assert_eq!(argus.publish, vec![subject("argus")]);
|
||||
assert_eq!(templated(&argus), vec![subject("argus")]);
|
||||
}
|
||||
|
||||
/// The subagent template as `swarm-nats.nix` spells it expands to the
|
||||
|
|
@ -1910,10 +1950,10 @@ mod tests {
|
|||
.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");
|
||||
assert_eq!(g.publish, vec![stream_subjects("atlas")]);
|
||||
assert_eq!(templated(&g), vec![stream_subjects("atlas")]);
|
||||
assert!(subject("atlas", "scout").starts_with(g.publish[0].trim_end_matches('>')));
|
||||
let argus = p.agent_token_permissions("argus").expect("configured");
|
||||
assert_eq!(argus.publish, vec![stream_subjects("argus")]);
|
||||
assert_eq!(templated(&argus), vec![stream_subjects("argus")]);
|
||||
assert_ne!(g.publish, argus.publish);
|
||||
|
||||
let stream = stream_name("atlas");
|
||||
|
|
@ -1941,11 +1981,11 @@ mod tests {
|
|||
])
|
||||
.expect("a per-agent template is valid");
|
||||
let atlas = p.agent_token_permissions("atlas").expect("configured");
|
||||
assert_eq!(atlas.publish, vec![direct_get_subject("atlas")]);
|
||||
assert_eq!(templated(&atlas), vec![direct_get_subject("atlas")]);
|
||||
// Control: argus's own expansion is a different subject, so atlas
|
||||
// lacking it is the grant being scoped, not the template being empty.
|
||||
let argus = p.agent_token_permissions("argus").expect("configured");
|
||||
assert_eq!(argus.publish, vec![direct_get_subject("argus")]);
|
||||
assert_eq!(templated(&argus), vec![direct_get_subject("argus")]);
|
||||
assert!(!atlas.publish.contains(&direct_get_subject("argus")));
|
||||
|
||||
for refused in [
|
||||
|
|
@ -1989,9 +2029,9 @@ mod tests {
|
|||
])
|
||||
.expect("a per-agent template is valid");
|
||||
let atlas = p.agent_token_permissions("atlas").expect("configured");
|
||||
assert_eq!(atlas.publish, vec![direct_get_subject("atlas")]);
|
||||
assert_eq!(templated(&atlas), vec![direct_get_subject("atlas")]);
|
||||
let argus = p.agent_token_permissions("argus").expect("configured");
|
||||
assert_eq!(argus.publish, vec![direct_get_subject("argus")]);
|
||||
assert_eq!(templated(&argus), vec![direct_get_subject("argus")]);
|
||||
|
||||
for refused in [
|
||||
direct_get_subject("argus"),
|
||||
|
|
@ -2022,4 +2062,44 @@ mod tests {
|
|||
fn with_no_agent_token_subject_configured_an_agent_token_is_refused() {
|
||||
assert!(policy().agent_token_permissions("atlas").is_none());
|
||||
}
|
||||
|
||||
/// Every agent reads every operator's profile, and only reads it: the
|
||||
/// bucket opens, lists and answers a direct get, and nothing writes,
|
||||
/// reshapes or deletes it.
|
||||
#[test]
|
||||
fn an_agent_may_list_and_read_every_operator_profile_and_write_none() {
|
||||
use swarm_queue_client::operator_profiles::{BUCKET, STREAM};
|
||||
|
||||
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");
|
||||
let may = |subject: &str| atlas.publish.iter().any(|g| covers(g, subject));
|
||||
for allowed in [
|
||||
format!("$JS.API.STREAM.INFO.{STREAM}"),
|
||||
format!("$JS.API.CONSUMER.CREATE.{STREAM}"),
|
||||
format!("$JS.API.CONSUMER.CREATE.{STREAM}.c1.$KV.{BUCKET}.>"),
|
||||
format!("$JS.API.DIRECT.GET.{STREAM}.$KV.{BUCKET}.mara"),
|
||||
] {
|
||||
assert!(may(&allowed), "atlas may not publish {allowed}");
|
||||
}
|
||||
for refused in [
|
||||
format!("$KV.{BUCKET}.mara"),
|
||||
format!("$JS.API.STREAM.CREATE.{STREAM}"),
|
||||
format!("$JS.API.STREAM.UPDATE.{STREAM}"),
|
||||
format!("$JS.API.STREAM.DELETE.{STREAM}"),
|
||||
format!("$JS.API.STREAM.PURGE.{STREAM}"),
|
||||
"$JS.API.DIRECT.GET.KV_agent-inference.$KV.agent-inference.argus".to_owned(),
|
||||
] {
|
||||
assert!(!may(&refused), "atlas may publish {refused}");
|
||||
}
|
||||
// The hive's own client is not the reader and gets none of it.
|
||||
let hive = p.permissions("hive-alpha").expect("admitted");
|
||||
assert!(
|
||||
!hive
|
||||
.publish
|
||||
.iter()
|
||||
.any(|g| covers(g, &format!("$JS.API.STREAM.INFO.{STREAM}")))
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue