From 7022cd3826c70d7c4a8141e1927298272ae5083f Mon Sep 17 00:00:00 2001 From: atlas Date: Thu, 4 Jun 2026 13:53:43 +0200 Subject: [PATCH] fix: split WriteAgentStateFile into WriteAgentForgeToken + WriteAgentMatrixToken MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses mara's review: each credential type gets its own PrivRequest variant, making the exact priv surface visible in the wire protocol. No runtime filename dispatch — the operation name is the gate. - WriteAgentForgeToken { agent_name, token } → state/forge-token - WriteAgentMatrixToken { agent_name, token } → state/matrix-token - priv_client: two typed fns (write_agent_forge_token, write_agent_matrix_token) - forge.rs: split mint_and_persist_token into mint_and_persist_agent_token (priv) + mint_and_persist_core_token (direct write); drop dead token_path fn - matrix.rs: call write_agent_matrix_token directly --- hive-c0re/src/forge.rs | 57 +++++++++++++----------------------- hive-c0re/src/matrix.rs | 2 +- hive-c0re/src/priv_client.rs | 30 ++++++++++++------- hive-priv/src/main.rs | 40 ++++++++++--------------- hive-sh4re/src/priv_proto.rs | 40 ++++++++++++------------- 5 files changed, 75 insertions(+), 94 deletions(-) diff --git a/hive-c0re/src/forge.rs b/hive-c0re/src/forge.rs index 61e4597a..19ec131b 100644 --- a/hive-c0re/src/forge.rs +++ b/hive-c0re/src/forge.rs @@ -74,12 +74,6 @@ const TOKEN_SCOPES: &str = "read:user,write:user,read:notification,write:notific /// See `docs/forge.md::Token scopes`. const CORE_TOKEN_SCOPES: &str = "read:admin,write:admin,read:user,write:user,read:notification,write:notification,write:repository,write:issue,write:organization,write:misc"; -/// Token file inside the agent's bind-mounted state dir (visible as -/// `/state/forge-token` from inside the container). -fn token_path(name: &str) -> PathBuf { - Coordinator::agent_notes_dir(name).join("forge-token") -} - /// Probe whether `hive-forge` exists as a nixos-container. Cheap — /// `nixos-container list` is just a directory scan in /etc. Routed /// through hive-priv: `nixos-container` needs root, and hive-c0re runs @@ -291,41 +285,30 @@ async fn mint_token(name: &str, scopes: &str) -> Result { Ok(token) } -/// Mint a fresh access token for `name` and persist it to `path` (0600). -/// For paths outside the agent state tree (e.g. the core admin token at -/// `/var/lib/hyperhive/forge-core-token`), writes directly — hive-c0re -/// owns those paths. For paths inside `AGENT_STATE_ROOT` the write is -/// routed through hive-priv (root helper) because hive-c0re runs -/// unprivileged and cannot write to agent-owned state directories. -async fn mint_and_persist_token(name: &str, path: &Path, scopes: &str) -> Result<()> { +/// Mint a fresh Forgejo access token for an agent and write it to the +/// agent's state dir via hive-priv. hive-c0re runs unprivileged and +/// cannot write to agent-owned (0755) state directories directly. +async fn mint_and_persist_agent_token(name: &str) -> Result<()> { + let token = mint_token(name, TOKEN_SCOPES).await?; + crate::priv_client::write_agent_forge_token(name, &token) + .await + .with_context(|| format!("write forge-token for {name} via hive-priv")) +} + +/// Mint a fresh Forgejo access token for the `core` admin user and +/// write it directly to `path`. Unlike agent tokens this path is owned +/// by hive-c0re itself (under `/var/lib/hyperhive/`), so a direct +/// write is both correct and necessary (no priv round-trip). +async fn mint_and_persist_core_token(path: &Path) -> Result<()> { use std::os::unix::fs::PermissionsExt; - let token = mint_token(name, scopes).await?; - - // Agent state paths must go through hive-priv. - // Heuristic: any path containing an "agents" component is considered - // an agent state path (matches `/var/lib/hyperhive/agents//...`). - // All current callers pass either CORE_TOKEN_PATH (no "agents" component) - // or `token_path(name)` (under AGENT_STATE_ROOT which contains "agents"). - // If a future non-agent path ever gains an "agents" component, this guard - // would incorrectly route it to priv — add an explicit exclusion then. - if path.components().any(|c| c.as_os_str() == "agents") { - let filename = path - .file_name() - .and_then(|n| n.to_str()) - .context("could not determine filename for priv write")?; - return crate::priv_client::write_agent_state_file(name, filename, &format!("{token}\n")) - .await - .with_context(|| format!("write {filename} for {name} via hive-priv")); - } - - // Non-agent paths (core admin token, etc.): write directly. + let token = mint_token("core", CORE_TOKEN_SCOPES).await?; if let Some(parent) = path.parent() { std::fs::create_dir_all(parent).ok(); } std::fs::write(path, format!("{token}\n")) - .with_context(|| format!("write token to {}", path.display()))?; + .with_context(|| format!("write core token to {}", path.display()))?; let _ = std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600)); - tracing::info!(%name, path = %path.display(), "forge: persisted access token"); + tracing::info!(path = %path.display(), "forge: persisted core access token"); Ok(()) } @@ -338,7 +321,7 @@ pub async fn ensure_user_for(name: &str) -> Result<()> { } ensure_user_exists(name, false, None).await?; ensure_user_email(name).await; - mint_and_persist_token(name, &token_path(name), TOKEN_SCOPES).await + mint_and_persist_agent_token(name).await } /// Provision a forgejo user for `name` and return the freshly-minted @@ -451,7 +434,7 @@ async fn ensure_core_user_and_token() -> Result { } } ensure_user_exists("core", true, None).await?; - mint_and_persist_token("core", path, CORE_TOKEN_SCOPES).await?; + mint_and_persist_core_token(path).await?; let raw = std::fs::read_to_string(path) .with_context(|| format!("read {CORE_TOKEN_PATH} after mint"))?; Ok(raw.trim().to_owned()) diff --git a/hive-c0re/src/matrix.rs b/hive-c0re/src/matrix.rs index 6c35fe5f..03d94775 100644 --- a/hive-c0re/src/matrix.rs +++ b/hive-c0re/src/matrix.rs @@ -420,7 +420,7 @@ pub async fn ensure_user_for( // unprivileged `hive-core` user and cannot write to agent-owned state // directories directly. hive-priv writes the file 0600 and chowns it // to the agent user so it is readable from inside the container. - crate::priv_client::write_agent_state_file(name, "matrix-token", &format!("{access_token}\n")) + crate::priv_client::write_agent_matrix_token(name, &access_token) .await .with_context(|| format!("matrix: write matrix-token for {name} via hive-priv"))?; tracing::info!(%name, "matrix: provisioned access token"); diff --git a/hive-c0re/src/priv_client.rs b/hive-c0re/src/priv_client.rs index 46859835..d49c920c 100644 --- a/hive-c0re/src/priv_client.rs +++ b/hive-c0re/src/priv_client.rs @@ -256,18 +256,26 @@ pub async fn run_forge_admin(args: &[&str]) -> Result<(String, String)> { check(call(&PrivRequest::RunForgeAdmin { args: owned }).await?) } -/// Write `content` to `//state/` -/// via hive-priv (running as root). The file is written 0600 and chowned -/// to the agent user so it is readable from inside the agent container. -/// -/// Used for credential files (forge-token, matrix-token) that hive-c0re -/// mints but cannot write directly because those paths are inside agent- -/// owned (0755) state directories and hive-c0re runs unprivileged. -pub async fn write_agent_state_file(agent_name: &str, filename: &str, content: &str) -> Result<()> { - ok(call(&PrivRequest::WriteAgentStateFile { +/// Write the Forgejo access token for `agent_name` to +/// `//state/forge-token` via hive-priv +/// (running as root). The file is written 0600 and chowned to the agent +/// user so it is readable from inside the agent container. +pub async fn write_agent_forge_token(agent_name: &str, token: &str) -> Result<()> { + ok(call(&PrivRequest::WriteAgentForgeToken { agent_name: agent_name.to_owned(), - filename: filename.to_owned(), - content: content.to_owned(), + token: token.to_owned(), + }) + .await?) +} + +/// Write the Matrix access token for `agent_name` to +/// `//state/matrix-token` via hive-priv +/// (running as root). The file is written 0600 and chowned to the agent +/// user so it is readable from inside the agent container. +pub async fn write_agent_matrix_token(agent_name: &str, token: &str) -> Result<()> { + ok(call(&PrivRequest::WriteAgentMatrixToken { + agent_name: agent_name.to_owned(), + token: token.to_owned(), }) .await?) } diff --git a/hive-priv/src/main.rs b/hive-priv/src/main.rs index 44726272..4ef47aad 100644 --- a/hive-priv/src/main.rs +++ b/hive-priv/src/main.rs @@ -322,38 +322,28 @@ async fn exec(req: PrivRequest, writer: &mut OwnedWriteHalf) -> Result<(String, run_forge_admin(args).await } - PrivRequest::WriteAgentStateFile { + PrivRequest::WriteAgentForgeToken { ref agent_name, - ref filename, - ref content, + ref token, } => { validate_agent_name(agent_name)?; - validate_state_filename(filename)?; - write_agent_state_file(agent_name, filename, content) + write_agent_state_file(agent_name, "forge-token", &format!("{token}\n")) + } + + PrivRequest::WriteAgentMatrixToken { + ref agent_name, + ref token, + } => { + validate_agent_name(agent_name)?; + write_agent_state_file(agent_name, "matrix-token", &format!("{token}\n")) } } } -/// Explicit allowlist of filenames `WriteAgentStateFile` may write. -/// New credential files must be added here deliberately — hive-priv -/// rejects any filename not in this list. -const ALLOWED_STATE_FILENAMES: &[&str] = &["forge-token", "matrix-token"]; - -/// Validate a filename destined for `WriteAgentStateFile` against the -/// explicit allowlist. Only known credential filenames are accepted. -fn validate_state_filename(filename: &str) -> Result<()> { - if !ALLOWED_STATE_FILENAMES.contains(&filename) { - bail!( - "state filename {filename:?} not in allowlist {:?}", - ALLOWED_STATE_FILENAMES - ); - } - Ok(()) -} - -/// Write `content` to `AGENT_STATE_ROOT//state/`, -/// chown to the agent user (derived from the state dir's existing owner), -/// and chmod 0600. Running as root (hive-priv), so all of this succeeds +/// Shared helper for `WriteAgentForgeToken` and `WriteAgentMatrixToken`. +/// Writes `content` to `AGENT_STATE_ROOT//state/`, +/// chowns to the agent user (derived from the state dir's existing owner), +/// and chmods 0600. Running as root (hive-priv), so this succeeds /// regardless of the file's prior owner/permissions. fn write_agent_state_file( agent_name: &str, diff --git a/hive-sh4re/src/priv_proto.rs b/hive-sh4re/src/priv_proto.rs index d04002b7..17a2dbe7 100644 --- a/hive-sh4re/src/priv_proto.rs +++ b/hive-sh4re/src/priv_proto.rs @@ -229,29 +229,29 @@ pub enum PrivRequest { args: Vec, }, - // --- Agent state file writes --- - /// Write a credential file into an agent's bind-mounted state directory. + // --- Agent credential writes --- + /// Write `forge-token` into `AGENT_STATE_ROOT//state/forge-token`. /// - /// Path resolved by hive-priv: `AGENT_STATE_ROOT//state/`. - /// hive-priv validates both the agent name and filename before writing. - /// - /// After writing, the file is chowned to the agent user's uid/gid - /// (read from the state directory's owner) and chmoded 0600 so - /// only the agent process can read it. - /// - /// Required because hive-c0re runs as the unprivileged `hive-core` - /// user and cannot write to agent-owned (0755) state directories - /// after the privsep that moved c0re from root to a dedicated unix user. - WriteAgentStateFile { + /// hive-priv validates `agent_name`, creates the state dir if absent, + /// writes the file 0600, and chowns it to the state dir's owner so + /// the agent process can read it. Required because hive-c0re runs + /// unprivileged and cannot write to agent-owned state directories. + WriteAgentForgeToken { /// Logical agent name (validated by `validate_agent_name`). agent_name: String, - /// Allowlisted credential filename within the state dir. - /// Only `"forge-token"` and `"matrix-token"` are accepted; - /// hive-priv rejects any other value. - filename: String, - /// File content to write. Written as-is; caller is responsible for - /// including any trailing newline. - content: String, + /// Token value. hive-priv appends a trailing newline before writing. + token: String, + }, + + /// Write `matrix-token` into `AGENT_STATE_ROOT//state/matrix-token`. + /// + /// Same semantics as `WriteAgentForgeToken` — validates name, creates + /// dir, writes 0600, chowns to agent owner. + WriteAgentMatrixToken { + /// Logical agent name (validated by `validate_agent_name`). + agent_name: String, + /// Token value. hive-priv appends a trailing newline before writing. + token: String, }, }