//! The GitHub agreement: where an agent's GitHub personal access token lives in //! the store, and what the object at that path holds. //! //! An operator hands `swarm-controller` the token, the controller stores it at //! [`account_path`], and `nix/agent-modules/github-token.nix` reads it back //! under the agent's own certificate. One token per agent, so one path and no //! directory to list. Neither end is senior, so both halves are stated once, //! here, beside [`crate::forge`]. use serde::{Deserialize, Serialize}; use crate::{ Error, path::{Kind, principal_prefix}, }; /// The path holding `agent`'s GitHub token. /// /// A flat leaf under the agent's prefix, like /// [`crate::forge::agent_token_path`], so the agent's own read stanza /// ([`crate::policy::render_agent`]) already covers it. /// /// # Errors /// [`Error::PathSegment`] when `agent` contains anything but `[A-Za-z0-9_-]`, /// which is what keeps one agent's name from addressing another agent's secret. pub fn account_path(agent: &str) -> Result { let prefix = principal_prefix(Kind::Agent, agent)?; Ok(format!("{prefix}/github-token")) } /// What an [`account_path`] holds. /// /// No `Debug` derive: `value` is a live GitHub credential, and a derived /// `Debug` is one `{:?}` away from a log line. #[derive(Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct Credential { /// The token. `github-token.nix` reads the store with /// `bao kv get -format=json` and pulls `.data.data.value` out with /// `jq`, so this name is load-bearing for a reader this crate does not /// control. pub value: String, } impl std::fmt::Debug for Credential { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { f.debug_struct("Credential") .field("value", &"") .finish() } } #[cfg(test)] mod tests { use super::*; fn covered_by(policy: &str, path: &str) -> bool { policy.lines().any(|line| { line.strip_prefix("path \"") .and_then(|rest| rest.split_once("\" {")) .and_then(|(p, _)| p.strip_suffix('*')) .is_some_and(|prefix| format!("secret/data/{path}").starts_with(prefix)) }) } #[test] fn the_token_lands_under_the_agent_prefix() { // Spelled out: `github-token.nix` spells the same string. assert_eq!( account_path("atlas").expect("a plain name is legal"), "swarm/agents/atlas/github-token" ); } #[test] fn a_traversal_in_the_agent_name_is_refused() { for bad in ["../argus", "a/b", "atlas/../argus", "a.b", ""] { let e = account_path(bad).expect_err("a traversal is not legal"); assert!(matches!(e, Error::PathSegment { kind: "agent", .. }), "{e}"); } // The control: the legal charset stays reachable. assert!(account_path("a-b_C9").is_ok()); } #[test] fn the_agents_own_read_grant_covers_the_path() { let path = account_path("atlas").expect("legal"); let own = crate::policy::render_agent("atlas").expect("legal"); assert!( covered_by(&own, &path), "no stanza in\n{own}\ncovers {path}" ); // The control: another agent's grant does not reach it. let other = crate::policy::render_agent("argus").expect("legal"); assert!(!covered_by(&other, &path), "argus's policy reaches {path}"); } #[test] fn no_github_key_is_also_a_directory() { // KV-v2 cannot hold a key whose name is also a prefix of another key. let key = account_path("atlas").expect("legal"); for other in [ crate::forge::agent_token_path("atlas").expect("legal"), crate::forge::accounts_dir("atlas").expect("legal"), ] { assert_ne!(key, other); assert!(!other.starts_with(&format!("{key}/")), "{other}"); assert!(!key.starts_with(&format!("{other}/")), "{key}"); } } #[test] fn the_value_field_matches_what_the_nix_reader_asks_for() { let json = serde_json::to_string(&Credential { value: "t".to_owned(), }) .expect("a String serialises"); assert_eq!(json, r#"{"value":"t"}"#); } #[test] fn debug_never_prints_the_value() { let c = Credential { value: "0123456789abcdef".to_owned(), }; let shown = format!("{c:?}"); assert!(!shown.contains("0123456789abcdef"), "{shown}"); assert!(shown.contains("redacted"), "{shown}"); } }