//! The backend environment agreement: where an agent's backend credentials //! (`ANTHROPIC_API_KEY`, `ANTHROPIC_BASE_URL`, an ACP provider key) live in //! the store, and what the object at that path holds. //! //! The object holds the text of a systemd `EnvironmentFile`. The agent's own //! `hive-agent-backend-env` unit (`nix/agent-modules/backend-env.nix`) reads it //! under the agent's certificate and writes it where the harness's //! `EnvironmentFile=` points. A leaf no one has written yet is created by that //! same unit from the agent's existing `backendEnvironmentFile`, which is why //! [`crate::policy::render_shared_agent`] grants the agent `create` on this one //! leaf and no other. use serde::{Deserialize, Serialize}; use crate::{ Error, path::{Kind, principal_prefix}, }; /// The last segment of the path, shared with the policy stanza that lets an /// agent create it. pub(crate) const LEAF: &str = "backend-env"; /// The path holding `agent`'s backend environment. /// /// A flat leaf under the agent's prefix, like /// [`crate::github::account_path`], so the agent's own read stanza /// ([`crate::policy::render_shared_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 env_path(agent: &str) -> Result { let prefix = principal_prefix(Kind::Agent, agent)?; Ok(format!("{prefix}/{LEAF}")) } /// What an [`env_path`] holds. /// /// No `Debug` derive: `env` carries live credentials, and a derived `Debug` is /// one `{:?}` away from a log line. #[derive(Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct BackendEnv { /// The file text, `KEY=value` per line. `backend-env.nix` writes it with /// `bao kv put … env=@file` and reads it with `bao kv get -field=env`, so /// this name is load-bearing for a reader this crate does not control. pub env: String, } impl std::fmt::Debug for BackendEnv { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { f.debug_struct("BackendEnv") .field("env", &"") .finish() } } #[cfg(test)] mod tests { use super::*; #[test] fn the_env_lands_under_the_agent_prefix() { // Spelled out: `backend-env.nix` spells the same string. assert_eq!( env_path("atlas").expect("a plain name is legal"), "swarm/agents/atlas/backend-env" ); } #[test] fn a_traversal_in_the_agent_name_is_refused() { for bad in ["../argus", "a/b", "atlas/../argus", "a.b", ""] { let e = env_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!(env_path("a-b_C9").is_ok()); } #[test] fn the_stored_field_is_env() { let stored: BackendEnv = serde_json::from_str(r#"{"env":"A=b\n"}"#).expect("the documented shape decodes"); assert_eq!(stored.env, "A=b\n"); } #[test] fn debug_hides_the_credentials() { let shown = format!( "{:?}", BackendEnv { env: "ANTHROPIC_API_KEY=sk-live".to_owned() } ); assert!(!shown.contains("sk-live"), "{shown}"); assert!(shown.contains(""), "{shown}"); } }