Watch
0
0
Fork
You've already forked hyperhive
0

Compare commits

...
Author SHA1 Message Date
atlas
bd64a8590d docs/security: use contractions, so vale's Microsoft style is clean
The section this branch adds was the only source of `Microsoft.Contractions`
errors in the file — 9 of them, and vale over the pre-change bytes still
reports exactly those 9, so the count is attributable rather than inferred
from a repo-wide total that was already red.

One of the nine is not a contraction: vale reads `it is` in "Narrowing it is
tracked in ..." as a subject-verb pair, but it is `it` (the object of
narrowing) plus `is tracked`. Rephrased so the sentence leads with the issue
instead, which drops the false hit and the passive-voice warning together.
2026-09-09 18:40:41 +02:00
atlas
3752482524 swarm: let every hive read every agent's credential, and say so
A hive reads its agents' credentials with its own certificate, and nothing
said which paths that certificate may read, so the read half of a delivery
answered 403.

The grant is wide on purpose. An agent's path does not name the hive
hosting it -- agents move -- so a per-hive grant has to be an enumeration
the controller re-emits whenever the roster changes, and an enumeration
that can drift or land out of order advertises a boundary it does not
hold. A wide grant that says what it is beats a narrow one that only looks
narrow. mara's call, on the PR: rather a too-lax scope than one that
pretends to be strict.

What that buys, beyond honesty: the document is identical for every hive
and depends on nothing, so it is written once at startup beside the rest of
a hive's provisioning instead of on every declaration. No derived state, no
re-emission, and the ordering hazard that came with one stops existing.

What still holds is read-only. A hive cannot write an agent's credential,
so it cannot hand itself an agent's identity, and the grant reaches nothing
in the store outside the agent-credential prefix.

The fact is documented where someone meets the boundary rather than only in
this message, and the two ways to narrow it later -- scope per hive, or
give agents their own store identity -- are tracked.
2026-09-09 18:40:41 +02:00
atlas
e638db262e swarm-controller: keep a hive's read grant in step with its declaration
The controller writes an agent's credential; the hive fetches it back with
its own certificate. Nothing said which paths that certificate may read, so
the read half of a delivery answers 403 with no way to tell why.

The grant is derived from the declaration, so it is re-rendered at the one
place the declaration changes -- WantedWriter::set -- rather than at its
caller, which would work today and break on the second caller.

Emitted before the KV write: a grant that lands late is a 403 on an agent's
first fetch, while one that shrinks early only affects an agent already
being torn down. A failed write then leaves a superset the next declaration
re-renders.

Destroyed agents are filtered out. The declared set is a hive's whole
history -- a destroyed entry stays so that redeclaring it Up is refused as
the terminal transition it is -- so granting every declared agent would
leave a torn-down agent's credentials readable forever.

The sink is a trait because a missed emission is that same untraceable 403:
the double pins which agents were published, and the no-sink and refusing
arms pin the two deployments that are not a happy path. Not covered: the
call site inside set(), which needs a live queue.

The cert role moves to its own module on the way past. It is the
controller's identity at the store, not something the matrix route owns,
and the policy writer needs the same login.
2026-09-09 18:40:41 +02:00
atlas
c590447e8f swarm-secret-client: give the store handle a policy write
The renderer beside this produces a hive's read policy; nothing could put
one in the store. `read`/`write` are KV v2 verbs and a policy is not a
secret, so this is a second endpoint rather than another path.

A whole-document write, because the store has no merge verb: the caller
renders from the current agent set, so a stanza missing from the render
is a grant that is meant to be gone.
2026-09-09 18:40:41 +02:00
atlas
7792b62609 swarm-secret-client: render a hive's read policy, and refuse names that inject
The controller writes an agent's credential; the hive reads it back with its
own token. Nothing says which paths that token may read, so the read half of
a delivery has no identity at all and answers 403.

This is the pure half of the fix: the policy text is a function of a hive name
and its agent set, so the shape can be asserted with no store to talk to.

One stanza per hosted agent rather than a prefix grant. An agent's credential
path does not name the hive hosting it -- deliberately, since agents migrate --
so "this hive's agents" has no prefix expression and must be enumerated. The
grant is read-only: the controller mints these and never reads one back.

The paths come from MOUNT and AGENT_PREFIX rather than literals, so the policy
cannot drift from the module that builds the paths it grants.

checked_segment runs before any name reaches HCL. That is policy injection
rather than path traversal -- a name can close a stanza and open a wider one --
so the test carries a real injection string, paired with the reachable-charset
control that stops it passing by refusing everything.

An empty agent set renders an empty policy, which grants nothing.
2026-09-09 18:40:41 +02:00
damocles
004cf7f449 swarm-queue-client: fix AgentState::Destroyed doc, entry never drops out of the set 2026-09-09 18:40:39 +02:00
9 changed files with 250 additions and 15 deletions

View file

@ -45,6 +45,28 @@ matrix **identities** — the public handles (`name`, `user_id` `@user:server`,
peers can find and address one another on a shared matrix instance. Only the peers can find and address one another on a shared matrix instance. Only the
public handle crosses that boundary; the token never does. public handle crosses that boundary; the token never does.
### The swarm secret store isn't a boundary between hives
A hive reads its agents' credentials out of the swarm's secret store with its
own certificate. **Every hive's policy grants read on every agent's
credentials**, not only on the agents it hosts — so a compromised hive can read
the matrix token of an agent running on a different hive.
That's deliberate and it's the interim state, not the intent. An agent's
credential path doesn't name the hive hosting it (agents move between hives),
so a per-hive grant has to be an enumeration the controller re-emits whenever
the roster changes — and an enumeration that can drift or land out of order
advertises a boundary it doesn't actually hold. A wide grant that says what it
is beats a narrow one that only looks narrow.
What still holds: the grant is **read-only** (a hive can't write an agent's
credential, so it can't hand itself an agent's identity), and it's scoped to
the agent-credential prefix — nothing else in the store is reachable with it.
**#4137** tracks narrowing it, with the two candidate directions: scope
the grant per hive (and pay for the re-emission), or give each agent container
its own store identity so credentials never pass through a hive at all.
### Threat model: prompt injection → confused deputy ### Threat model: prompt injection → confused deputy
The realistic adversary **never needs to breach the container**. They supply The realistic adversary **never needs to breach the container**. They supply

View file

@ -47,7 +47,9 @@ mod forge;
mod issue_report; mod issue_report;
mod matrix_account; mod matrix_account;
mod otel_http_client; mod otel_http_client;
mod read_policy;
mod status; mod status;
mod store;
mod vcs_metrics; mod vcs_metrics;
mod wanted; mod wanted;
mod webhook; mod webhook;
@ -1620,8 +1622,15 @@ async fn main() -> Result<()> {
let config_prs = forge_client.clone().map(config_pr::spawn); let config_prs = forge_client.clone().map(config_pr::spawn);
let state_forge = keep_forge_for_state(forge_client, webhook_secret.clone()); let state_forge = keep_forge_for_state(forge_client, webhook_secret.clone());
let hives = load_hives();
// Before serving, because a hive whose policy does not exist cannot read
// anything this daemon writes for it. Same "log and carry on" shape as
// every connect above.
read_policy::ensure_hive_policies(&hives.iter().map(|h| h.name.clone()).collect::<Vec<_>>())
.await;
let state = AppState { let state = AppState {
hives: Arc::new(load_hives()), hives: Arc::new(hives),
links: Arc::new(load_links()), links: Arc::new(load_links()),
wanted: wanted_writer(status.as_ref()), wanted: wanted_writer(status.as_ref()),
agent_status: agent_status_reader(status.as_ref()), agent_status: agent_status_reader(status.as_ref()),

View file

@ -26,20 +26,11 @@ use axum::extract::State;
use axum::http::StatusCode; use axum::http::StatusCode;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use swarm_queue_client::{CredentialNotice, credential_subject}; use swarm_queue_client::{CredentialNotice, credential_subject};
use swarm_secret_client::{SecretStore, matrix}; use swarm_secret_client::matrix;
use utoipa::ToSchema; use utoipa::ToSchema;
use super::{AppState, error_problem, swarm_hive}; use super::{AppState, error_problem, swarm_hive};
/// The cert-auth role this daemon logs into the secret store as.
///
/// `nix/host-modules/swarm-bao.nix`'s `controllerPolicyName` creates the role,
/// names the policy after it, and `nix/module-eval.nix` pins the literal.
///
/// ⚠️ Not the certificate's CN. The role *matches on* the CN
/// (`allowed_common_names`), so the two are deliberately different strings.
const CERT_ROLE: &str = "swarm-controller";
fn default_mode() -> String { fn default_mode() -> String {
"token".to_owned() "token".to_owned()
} }
@ -156,7 +147,7 @@ pub async fn put_matrix_account(
// touched, so a failed login leaves no partial state behind. // touched, so a failed login leaves no partial state behind.
let (token, homeserver, user_id) = resolve_credential(&req).await.map_err(|b| *b)?; let (token, homeserver, user_id) = resolve_credential(&req).await.map_err(|b| *b)?;
let store = SecretStore::from_env(CERT_ROLE).await.map_err(|e| { let store = crate::store::connect().await.map_err(|e| {
tracing::warn!(error = %e, "connecting to the swarm secret store failed"); tracing::warn!(error = %e, "connecting to the swarm secret store failed");
error_problem(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string()) error_problem(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string())
})?; })?;

View file

@ -0,0 +1,48 @@
//! Writing the policy a hive's own certificate logs in with.
//!
//! The controller writes a credential; the hive fetches it with its own
//! certificate. Nothing said which paths that certificate may read, so the
//! read half of every delivery answers 403.
//!
//! A startup pass, not a hook: the document is the same for every hive and
//! does not depend on which agents exist ([`swarm_secret_client::policy`]
//! explains why it is that wide), so there is nothing to keep in step with
//! anything. Per-hive failures are reported and skipped — one unwritable
//! policy should not take down a daemon that serves everything else, and the
//! next start retries it.
use anyhow::{Context, Result};
use swarm_secret_client::{SecretStore, policy};
/// Give every hive in `hives` the read policy its certificate will carry.
///
/// Does nothing when this deployment has no store identity, which is the
/// shape a controller without a secret store runs in.
pub async fn ensure_hive_policies(hives: &[String]) {
if hives.is_empty() {
return;
}
let store = match crate::store::connect().await {
Ok(store) => store,
Err(e) => {
tracing::info!(reason = %e, "no secret store reachable; hive read policies are not managed here");
return;
}
};
for hive in hives {
if let Err(e) = write_one(&store, hive).await {
tracing::warn!(hive, error = %format!("{e:#}"), "writing this hive's read policy failed");
}
}
}
/// One hive's policy: its own name, the shared document.
async fn write_one(store: &SecretStore, hive: &str) -> Result<()> {
let name = policy::hive_object_name(hive)?;
store
.write_policy(&name, &policy::render())
.await
.with_context(|| format!("writing the read policy {name}"))?;
tracing::info!(hive, policy = %name, "hive read policy in place");
Ok(())
}

View file

@ -0,0 +1,25 @@
//! The controller's own identity at the swarm's secret store.
//!
//! Two paths log in: writing an agent's credential, and writing the read
//! grant that lets a hive fetch one back. Both present the same certificate
//! under the same role, so the role is named here rather than at each caller.
use swarm_secret_client::{Error, SecretStore};
/// The cert-auth role the controller logs in under.
///
/// `nix/host-modules/swarm-bao.nix`'s `controllerPolicyName` creates the role,
/// names the policy after it, and `nix/module-eval.nix` pins the literal.
///
/// ⚠️ Not the certificate's CN. The role *matches on* the CN
/// (`allowed_common_names`), so the two are deliberately different strings.
pub const CERT_ROLE: &str = "swarm-controller";
/// Log in to the store with this deployment's certificate.
///
/// # Errors
/// Whatever [`SecretStore::from_env`] raises — an unset `BAO_*` variable, an
/// unreadable identity file, or a store that refuses the login.
pub async fn connect() -> Result<SecretStore, Error> {
SecretStore::from_env(CERT_ROLE).await
}

View file

@ -92,9 +92,12 @@ pub enum AgentState {
/// Exists on the hive and is not running. /// Exists on the hive and is not running.
Offline, Offline,
/// Torn down entirely — the hive runs the destroy template once, then /// Torn down entirely — the hive runs the destroy template once, then
/// this agent drops out of the declared set rather than staying as a /// treats absence as agreement rather than re-running it. The key
/// third steady state. Irreversible: there is no state that brings a /// stays in the declared set with this state forever, on purpose: it's
/// destroyed agent back short of a fresh deploy. /// what lets a redeclare of an already-destroyed agent be rejected as
/// a terminal-state transition instead of silently reviving it.
/// Irreversible: there is no state that brings a destroyed agent back
/// short of a fresh deploy.
Destroyed, Destroyed,
} }

View file

@ -151,6 +151,22 @@ impl SecretStore {
vaultrs::kv2::set(&self.inner, MOUNT, path, value).await?; vaultrs::kv2::set(&self.inner, MOUNT, path, value).await?;
Ok(()) Ok(())
} }
/// Replace the ACL policy named `name` with `policy`.
///
/// A whole-document write, not a merge: the store has no other verb, and
/// the caller renders the document from the current agent set anyway, so
/// a stanza that is gone from the render is meant to be gone from the
/// grant. Render the text with [`crate::policy`] rather than by hand.
///
/// # Errors
/// [`Error::Vault`] when the token's own policy does not cover
/// `sys/policies/acl/<name>` — which is what a controller scoped to the
/// `hive-*` namespace gets for any other name.
pub async fn write_policy(&self, name: &str, policy: &str) -> Result<(), Error> {
vaultrs::sys::policy::set(&self.inner, name, policy).await?;
Ok(())
}
} }
#[cfg(test)] #[cfg(test)]

View file

@ -8,6 +8,11 @@
//! ([`matrix`]). Each of those is a thing the controller and a hive must say //! ([`matrix`]). Each of those is a thing the controller and a hive must say
//! identically, so it is said once here. //! identically, so it is said once here.
//! //!
//! [`policy`] is the same kind of agreement seen from the other side: which of
//! those paths a hive's own token may read. It belongs here rather than in the
//! controller because the grant and the path are one statement — spelled
//! differently they produce a 403 that names neither.
//!
//! [`client`] is deliberately ignorant of all of it: it moves whatever type a //! [`client`] is deliberately ignorant of all of it: it moves whatever type a
//! caller names, so a second kind of secret is a new module beside [`matrix`] //! caller names, so a second kind of secret is a new module beside [`matrix`]
//! and not another field on a struct shared with it. //! and not another field on a struct shared with it.
@ -15,6 +20,7 @@
pub mod client; pub mod client;
pub mod matrix; pub mod matrix;
pub mod path; pub mod path;
pub mod policy;
pub use client::SecretStore; pub use client::SecretStore;

View file

@ -0,0 +1,115 @@
//! The read agreement: which credentials a hive's own token may fetch.
//!
//! The mirror of [`crate::matrix`]. That module says where a credential lives;
//! this one says who is allowed to read it, and the two have to agree on the
//! same path or a delivery fails with a 403 that names nothing.
//!
//! ⚠️ Every hive gets the same document, and it grants read on **every**
//! agent's credentials rather than on the ones that hive hosts. That is a
//! decision, not an oversight: an agent's path does not name its hive, so a
//! per-hive grant has to be enumerated and re-emitted, and an enumeration that
//! can silently drift advertises a boundary it does not hold. A wide grant that
//! says so beats a narrow one that only looks narrow. The narrower shapes, and
//! what they would cost, are in `docs/trust-boundary/security.md`.
//!
//! Rendering stays separate from writing so the text can be asserted with no
//! store to talk to.
use crate::{
Error,
path::{AGENT_PREFIX, MOUNT, checked_segment},
};
/// Namespace for a hive's own policy and cert-auth role.
///
/// The controller's own grant is scoped to `hive-*` for both, so this prefix is
/// the difference between a hive the controller may provision and a policy it
/// must not be able to rewrite — including its own.
pub const HIVE_PREFIX: &str = "hive-";
/// The policy and cert-auth role name for `hive`. One name, both objects: the
/// role attaches the policy by spelling it identically.
///
/// # Errors
/// [`Error::PathSegment`] when `hive` holds anything but `[A-Za-z0-9_-]`.
pub fn hive_object_name(hive: &str) -> Result<String, Error> {
checked_segment("hive", hive)?;
Ok(format!("{HIVE_PREFIX}{hive}"))
}
/// Render the document every hive's policy holds: read on every agent's
/// credentials.
///
/// Takes no arguments because it depends on nothing — same text for every
/// hive, unchanged by which agents exist. That is what makes it a deploy-time
/// object rather than derived state with a re-emission to get wrong.
///
/// Read-only: the controller mints these and never reads one back.
#[must_use]
pub fn render() -> String {
format!("path \"{MOUNT}/data/{AGENT_PREFIX}/*\" {{\n capabilities = [\"read\"]\n}}\n")
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn the_document_grants_read_over_the_whole_agent_prefix() {
assert_eq!(
render(),
"path \"secret/data/swarm/agents/*\" {\n capabilities = [\"read\"]\n}\n"
);
}
#[test]
fn the_grant_is_read_only() {
// The half of the old policy that survives the widening: a hive reads
// credentials, and a hive that could write one could hand itself an
// agent's identity.
let p = render();
assert!(!p.contains("create"));
assert!(!p.contains("update"));
assert!(!p.contains("delete"));
assert!(!p.contains("list"));
}
#[test]
fn no_name_reaches_the_document_at_all() {
// Why the injection cases that used to live here are gone rather than
// relaxed: nothing interpolates into the text any more, so there is no
// stanza for a name to close. `hive_object_name` still validates,
// because a name does reach the policy's *identifier*.
let p = render();
assert!(!p.contains("pr1ma"));
assert_eq!(p.matches("path \"").count(), 1, "one stanza, no per-agent");
assert!(
hive_object_name("atlas/*\" { capabilities = [\"root\"] }").is_err(),
"the object NAME is still a place a name can do damage"
);
}
#[test]
fn the_legal_charset_is_actually_reachable() {
// The control for the case above: if every name were refused, that
// assertion would pass while proving nothing.
assert!(hive_object_name("a-b_C9").is_ok());
}
#[test]
fn every_hive_gets_a_byte_identical_document() {
// The property the deploy-time write depends on: nothing about a hive
// or its agents changes the text, so there is nothing to re-emit.
assert_eq!(render(), render());
}
#[test]
fn the_object_name_sits_inside_the_namespace_the_controller_may_write() {
// `hive-` is what the controller's own policy scopes both
// `sys/policies/acl/` and `auth/cert/certs/` to, so a name outside it
// is one the controller cannot create at all.
let n = hive_object_name("pr1ma").expect("legal");
assert_eq!(n, "hive-pr1ma");
assert!(n.starts_with(HIVE_PREFIX));
}
}