Watch
0
0
Fork
You've already forked hyperhive
0

swarm-claude-login: refuse a step whose agent name is not an identifier

The minter parses `Request.agent` with `Ident::parse`, as the controller
does, before it makes a scratch directory, runs `claude auth login` or
touches the store, and answers `Failed` otherwise. The name becomes a
directory name and a store path in the one service that writes every
agent's credentials, so it is checked at this service's own boundary.

The doc states that the `operatorsOnly` authelia rule, not the
controller, keeps the routes to operators, and that a stale version record
waits for a relogin. The env-strip comment says it is defence in depth.

Refs #5268
This commit is contained in:
atlas 2026-10-14 23:11:09 +02:00 • committed by mara
commit 693e6e738b
5 changed files with 104 additions and 3 deletions

View file

@ -124,6 +124,16 @@ async fn handle(payload: &[u8], logins: &Arc<login::Logins>) -> Reply {
};
}
};
// Before anything else: the name becomes a scratch directory's name and
// a store path, and this service holds write on every agent's leaf.
let (Request::Start { agent, .. } | Request::Code { agent, .. } | Request::Cancel { agent }) =
&request;
if let Err(reason) = hive_types::Ident::parse(agent) {
tracing::warn!(agent = ?agent, reason, "a login step for an invalid agent name; refused");
return Reply::Failed {
error: format!("{agent:?} is not an agent name: {reason}"),
};
}
let (agent, reply) = match request {
Request::Start { agent, operator } => {
tracing::info!(%agent, %operator, "starting a claude login");
@ -190,3 +200,73 @@ async fn client_secret(client_id: &str) -> Result<String> {
),
}
}
#[cfg(test)]
mod tests {
use std::sync::Arc;
use std::time::Duration;
use swarm_queue_client::claude_login::{Reply, Request};
use super::{handle, login};
#[tokio::test]
async fn a_step_for_an_invalid_agent_name_is_refused_before_anything_is_made() {
let root = std::env::temp_dir().join(format!(
"swarm-claude-login-test-hostile-{}",
std::process::id()
));
let _ = std::fs::remove_dir_all(&root);
let scratch = root.join("scratch");
std::fs::create_dir_all(&scratch).expect("temp dir");
let logins = Arc::new(login::Logins::new(
login::tests::stubs().join("claude"),
scratch.clone(),
Duration::from_secs(10),
));
for agent in ["../escaped", "..", "", "a/b", "Atlas"] {
for request in [
Request::Start {
agent: agent.to_owned(),
operator: "op".to_owned(),
},
Request::Code {
agent: agent.to_owned(),
code: "c".to_owned(),
},
Request::Cancel {
agent: agent.to_owned(),
},
] {
let payload = serde_json::to_vec(&request).expect("encodes");
let reply = handle(&payload, &logins).await;
assert!(
matches!(reply, Reply::Failed { .. }),
"{agent:?}: {reply:?}"
);
}
let made: Vec<_> = std::fs::read_dir(&root)
.expect("root")
.map(|e| e.expect("entry").file_name())
.collect();
assert_eq!(made, vec!["scratch"], "{agent:?} made a directory");
assert_eq!(
std::fs::read_dir(&scratch).expect("scratch").count(),
0,
"{agent:?}"
);
}
// The control: a valid name starts.
let payload = serde_json::to_vec(&Request::Start {
agent: "atlas".to_owned(),
operator: "op".to_owned(),
})
.expect("encodes");
assert!(matches!(
handle(&payload, &logins).await,
Reply::Started { .. }
));
drop(logins);
let _ = std::fs::remove_dir_all(&root);
}
}