Watch
0
0
Fork
You've already forked hyperhive
0

fetch the agent's backend env file from the store, seeded from the existing file

the agent's own root unit reads swarm/agents/<agent>/backend-env into /run and
creates it from backendEnvironmentFile with -cas=0 when the store holds nothing.
the shared agent policy gains create (not update) on that one leaf. the harness
and the subagent daemon load the old file first and the fetched one after it.
This commit is contained in:
atlas 2026-10-27 21:14:44 +01:00
commit 75e9e205e3
3 changed files with 933 additions and 31 deletions

View file

@ -0,0 +1,99 @@
//! 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<String, Error> {
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", &"<redacted>")
.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("<redacted>"), "{shown}");
}
}

View file

@ -6,7 +6,7 @@
//! deployment's environment into a logged-in client ([`client`]), and, per kind
//! of secret, the path it lives at together with the fields it holds
//! ([`matrix`], [`queue`], [`mtls`], [`forge`], [`github`], [`grafana`],
//! [`acp`], [`attic`], [`ci`], [`review_classifier`], [`builder`], [`ci_metrics`],
//! [`acp`], [`attic`], [`backend_env`], [`ci`], [`review_classifier`], [`builder`], [`ci_metrics`],
//! [`claude_login`]).
//! Each of those is a thing the controller and a hive must say identically, so
//! it is said once here.
@ -31,6 +31,7 @@
pub mod acp;
pub mod attic;
pub mod backend_env;
pub mod builder;
pub mod cert;
#[cfg(feature = "otel")]

View file

@ -30,7 +30,7 @@
//! Rendering stays separate from writing so the text can be asserted with no store to talk to.
use crate::{
Error,
Error, backend_env,
path::{Kind, MOUNT, ROOT, checked_segment},
};
@ -111,9 +111,9 @@ pub fn agents_from_role_names(names: &[String]) -> Vec<String> {
.collect()
}
/// One read stanza. With [`list_stanza`], the only shapes this module emits, so
/// "writes nothing" is a property of the renderers rather than of each call
/// site.
/// One read stanza. With [`list_stanza`] and [`create_stanza`], the only shapes
/// this module emits, so what an agent may write is a property of the renderers
/// rather than of each call site.
fn read_stanza(path: &str) -> String {
format!("path \"{path}\" {{\n capabilities = [\"read\"]\n}}\n")
}
@ -124,6 +124,13 @@ fn list_stanza(path: &str) -> String {
format!("path \"{path}\" {{\n capabilities = [\"list\"]\n}}\n")
}
/// One create stanza: `create` and `read`, never `update`. A leaf that holds
/// nothing takes a first write and the holder cannot replace it afterwards,
/// which is what a `-cas=0` write needs and all it gets.
fn create_stanza(path: &str) -> String {
format!("path \"{path}\" {{\n capabilities = [\"create\", \"read\"]\n}}\n")
}
/// The one policy every hive's cert-auth role attaches.
///
/// Inside [`HIVE_PREFIX`] so that the controller may write it. It is the object
@ -196,10 +203,12 @@ const ENTITY_NAME_TEMPLATE: &str = "{{identity.entity.name}}";
/// each agent's entity after the agent, and a login that no entity of that
/// name owns reads `swarm/agents/<generated name>/`, where nothing is stored.
///
/// Nothing here writes: an agent that could write its own credentials could
/// hand itself an identity it was never issued. An agent has no business with
/// another agent's credentials, a hive's, a service's, or the controller's, so
/// none of [`render_shared_hives`]'s breadth applies. A hive still reads this
/// The one write is `create` on the agent's own [`backend_env`] leaf, without
/// `update`: the agent seeds it from the file it has today, once, and cannot
/// replace it. Any other write would let an agent hand itself an identity it
/// was never issued. An agent has no business with another agent's
/// credentials, a hive's, a service's, or the controller's, so none of
/// [`render_shared_hives`]'s breadth applies. A hive still reads this
/// agent's secrets, and closing that is its own decision.
#[must_use]
pub fn render_shared_agent() -> String {
@ -210,7 +219,11 @@ pub fn render_shared_agent() -> String {
let list = list_stanza(&format!(
"{MOUNT}/metadata/{ROOT}/{kind}/{ENTITY_NAME_TEMPLATE}/*"
));
format!("{read}{list}")
let seed = create_stanza(&format!(
"{MOUNT}/data/{ROOT}/{kind}/{ENTITY_NAME_TEMPLATE}/{}",
backend_env::LEAF
));
format!("{read}{list}{seed}")
}
/// What an agent's token grants under [`SHARED_AGENT_POLICY`]: the document
@ -241,7 +254,8 @@ mod tests {
assert_eq!(
render_shared_agent(),
"path \"secret/data/swarm/agents/{{identity.entity.name}}/*\" {\n capabilities = [\"read\"]\n}\n\
path \"secret/metadata/swarm/agents/{{identity.entity.name}}/*\" {\n capabilities = [\"list\"]\n}\n"
path \"secret/metadata/swarm/agents/{{identity.entity.name}}/*\" {\n capabilities = [\"list\"]\n}\n\
path \"secret/data/swarm/agents/{{identity.entity.name}}/backend-env\" {\n capabilities = [\"create\", \"read\"]\n}\n"
);
}
@ -250,7 +264,6 @@ mod tests {
let shared = render_shared_agent();
assert!(!shared.contains("atlas"));
assert!(!shared.contains("agents/*"), "{shared}");
assert!(!shared.contains("create"));
assert!(!shared.contains("update"));
assert!(!shared.contains("delete"));
}
@ -456,10 +469,11 @@ mod tests {
/// What `effective_agent("atlas")` must be, byte for byte.
const ATLAS_DOCUMENT: &str = "path \"secret/data/swarm/agents/atlas/*\" {\n capabilities = [\"read\"]\n}\n\
path \"secret/metadata/swarm/agents/atlas/*\" {\n capabilities = [\"list\"]\n}\n";
path \"secret/metadata/swarm/agents/atlas/*\" {\n capabilities = [\"list\"]\n}\n\
path \"secret/data/swarm/agents/atlas/backend-env\" {\n capabilities = [\"create\", \"read\"]\n}\n";
#[test]
fn an_agents_document_is_two_stanzas_naming_that_agent() {
fn an_agents_document_is_its_stanzas_naming_that_agent() {
assert_eq!(effective_agent("atlas"), ATLAS_DOCUMENT);
}
@ -495,13 +509,13 @@ mod tests {
assert!(!p.contains("argus"), "no other principal is named");
assert_eq!(
p.matches("path \"").count(),
2,
"two stanzas, or the document grants something unaccounted for: {p}"
3,
"three stanzas, or the document grants something unaccounted for: {p}"
);
}
#[test]
fn an_agents_document_is_exactly_its_own_two_stanzas() {
fn an_agents_document_is_exactly_its_own_stanzas() {
// Pinned byte for byte: an added stanza (a hive's queue credential, a
// second agent) is exactly what a presence check misses.
assert_eq!(effective_agent("atlas"), ATLAS_DOCUMENT);
@ -509,18 +523,49 @@ mod tests {
assert!(!effective_agent("other").contains("agents/atlas/"));
}
/// The paths of the stanzas in `document` that grant `capability`.
fn paths_granting(document: &str, capability: &str) -> Vec<String> {
let mut paths = Vec::new();
let mut current = "";
for line in document.lines() {
if let Some(rest) = line.strip_prefix("path \"") {
current = rest.split_once('"').map_or("", |(path, _)| path);
} else if line.contains(&format!("\"{capability}\"")) {
paths.push(current.to_owned());
}
}
paths
}
#[test]
fn an_agents_grant_writes_nothing() {
fn an_agents_only_write_is_creating_its_own_backend_env() {
// An agent that could write its own credentials could hand itself an
// identity it was never issued — and `create`/`update` on that path is
// exactly what the controller holds, so the wall is the capability.
// identity it was never issued. The one leaf it may create is the one
// it seeds from its backend environment file, and `update` on it would
// let the agent replace a value the operator set.
let p = effective_agent("atlas");
for capability in ["create", "update", "delete", "sudo", "patch"] {
let leaf = format!(
"secret/data/{}",
crate::backend_env::env_path("atlas").expect("legal")
);
// The control: the walk finds the stanzas that are there.
assert_eq!(
paths_granting(&p, "read"),
["secret/data/swarm/agents/atlas/*", leaf.as_str()]
);
assert_eq!(paths_granting(&p, "create"), [leaf.as_str()]);
for capability in ["update", "delete", "sudo", "patch"] {
assert!(
!p.contains(capability),
"an agent's document must not grant {capability}: {p}"
);
}
// The exact stanza overrides the glob for its path, so it carries
// `read` itself.
assert!(
p.contains("backend-env\" {\n capabilities = [\"create\", \"read\"]"),
"{p}"
);
}
#[test]
@ -530,8 +575,9 @@ mod tests {
// granted, nor is `list` anywhere but `metadata/`.
let p = effective_agent("atlas");
let stanzas: Vec<&str> = p.split_inclusive("}\n").collect();
assert_eq!(stanzas.len(), 2, "{p}");
for stanza in stanzas {
assert_eq!(stanzas.len(), 3, "{p}");
// The third is the create stanza, pinned by the test above.
for stanza in &stanzas[..2] {
let on_data = stanza.starts_with("path \"secret/data/");
let on_metadata = stanza.starts_with("path \"secret/metadata/");
assert!(on_data != on_metadata, "{stanza}");