Watch
0
0
Fork
You've already forked hyperhive
0

github: PATs live in swarm bao; the agent fetches them itself

An operator links an agent's GitHub personal access token in the swarm UI
(LinkGithubAccountForm, "link github account" on /agents). swarm-controller's
PUT /api/hives/{hive}/agents/{agent}/github-account stores it at
swarm/agents/<agent>/github-token (swarm_secret_client::github), a flat leaf
under the agent's prefix that the agent's existing read grant already covers:
no policy change, and no list grant, since there is one token per agent.

In the agent, hive-agent-github-token (oneshot + 2-minute timer, as the agent
user, under its own store certificate, ordered before hive-github-notify)
reads that path and writes <state>/github-token, 0600 and agent-owned, the
file the gh wrapper, git credential helper and hive-github-notify already
read. It replaces the file by rename only when the bytes changed and never
deletes it: a hive-written github-token stays until a token is linked in the
swarm UI. It is installed only with a store address and
services.hyperhive.agent.github.enable.

Removed: the dashboard's CR3D3NTIALS page (credentials.html/js/css, its
build entries and H0M3 tile; GITHUB was its only tab), hive-c0re's
dashboard/matrix_accounts.rs with GET/POST /api/github-account,
priv_client::write_agent_github_token, the host socket's
SetAgentGithubToken and `hivectl github set-token`, and hive-priv's
WriteAgentGithubToken with write_agent_state_file, its only caller gone.

Docs: integrations/github.md and swarm/ui.md describe the swarm path,
swarm/credentials.md gains the store-path row, and the hive UI docs,
hivectl docs and security.md's hive-priv table drop the removed pieces.

Closes #4347
This commit is contained in:
atlas 2026-10-02 17:48:27 +02:00
commit 8e23feb01b
41 changed files with 804 additions and 846 deletions

View file

@ -0,0 +1,128 @@
//! 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<String, Error> {
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 -field=value`, 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", &"<redacted>")
.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}");
}
}

View file

@ -5,7 +5,7 @@
//! the rules every path obeys ([`path`]), the translation from this
//! 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`], [`acp`]). Each of those is a thing the controller
//! ([`matrix`], [`queue`], [`mtls`], [`forge`], [`github`], [`acp`]). Each of those is a thing the controller
//! and a hive must say identically, so it is said once here.
//!
//! [`policy`] is the same kind of agreement seen from the other side: which of
@ -25,6 +25,7 @@
pub mod acp;
pub mod client;
pub mod forge;
pub mod github;
pub mod matrix;
pub mod mtls;
pub mod path;