diff --git a/hive-priv/src/main.rs b/hive-priv/src/main.rs index 66d1f803..a5ca3d75 100644 --- a/hive-priv/src/main.rs +++ b/hive-priv/src/main.rs @@ -2335,6 +2335,35 @@ fn validate_bind_path(path: &str) -> Result<()> { /// write network isolation settings, then append `EXTRA_NSPAWN_FLAGS`. /// When `isolation` is `Some`, writes `PRIVATE_NETWORK=1` + veth wiring; /// when `None`, writes `PRIVATE_NETWORK=0`. +/// `--tmpfs=/.git` for every bound git repo, hiding its metadata +/// from inside the container. +/// +/// Two kinds of mount qualify, for one reason: **the agent is given a +/// working tree, never a repository.** `/knowledge` is the hive's shared +/// docs — whose `.git/config` has held a credential the host-side worker +/// embedded — and `/agents//config` is a config repo, an agent's own +/// or a parent's read-only view of a child's. In both cases `.git` carries +/// every branch and the full history of a document whose *currently +/// deployed* value is the only thing a reader may act on, and an abandoned +/// branch is indistinguishable from a live one. +/// +/// An overlay rather than an exported copy: there is no second tree to +/// keep in sync, so nothing can go stale, and no code path has to remember +/// to refresh it. +/// +/// ⚠️ Ordering matters — these must be appended **after** the `--bind` +/// flags so nspawn mounts them on top of the already-mounted trees. +/// Config mounts are matched by shape, not by a name list: the set is +/// dynamic, growing with each child bound into a parent. +fn git_overlay_flags(binds: &[BindMount]) -> Vec { + binds + .iter() + .map(|b| b.container_path.as_str()) + .filter(|p| *p == "/knowledge" || (p.starts_with("/agents/") && p.ends_with("/config"))) + .map(|p| format!("--tmpfs={p}/.git")) + .collect() +} + fn write_nspawn_flags( container: &str, binds: &[BindMount], @@ -2395,19 +2424,7 @@ fn write_nspawn_flags( format!("{flag}={}:{}", b.host_path, b.container_path) }) .collect(); - // Defense-in-depth for the knowledge bind-mount: overlay an empty tmpfs - // on /knowledge/.git so the repo metadata (including any credentials the - // host-side git worker embedded in .git/config) is invisible inside agent - // containers. Agents only need the working-tree documents; .git/ has no - // legitimate use in-container. The --tmpfs must come after the --bind-ro - // so nspawn processes it as an overlay on top of the already-mounted tree. - // `crate::knowledge::CONTAINER_MOUNT` is "/knowledge" (hive-c0re const). - if binds - .iter() - .any(|b| b.container_path.as_str() == "/knowledge") - { - flags.push("--tmpfs=/knowledge/.git".to_owned()); - } + flags.extend(git_overlay_flags(binds)); // Credential forwarding: nspawn loads each host secret into the // container's credential store under ``; inner units inherit it // via `LoadCredential=`. Validated (name charset + bind-path @@ -2573,12 +2590,58 @@ async fn sync_agent_tmpfiles(agents: &[AgentTmpfilesEntry]) -> Result<(String, S #[cfg(test)] mod tests { use super::{ - OwnedFd, PAUSED_MARKER_FILE, PrivRequest, check_fd_agreement, contains_secret_shaped_run, - limits_dropin_body, redact_secret_line, remove_marker_in, write_state_file_nofollow, + BindMount, OwnedFd, PAUSED_MARKER_FILE, PrivRequest, check_fd_agreement, + contains_secret_shaped_run, git_overlay_flags, limits_dropin_body, redact_secret_line, + remove_marker_in, write_state_file_nofollow, }; use std::path::PathBuf; use std::sync::atomic::{AtomicU32, Ordering}; + fn bind(container_path: &str) -> BindMount { + BindMount { + host_path: "/var/lib/hyperhive/whatever".to_owned(), + container_path: container_path.to_owned(), + read_only: true, + } + } + + /// Every bound git repo gets its `.git` overlaid — the knowledge tree + /// and *each* config mount, an agent's own plus every child's. + /// + /// The child case is the one worth pinning: that set grows at runtime + /// as agents gain children, so a rule written as a list of names would + /// silently stop covering new ones. + #[test] + fn every_bound_git_repo_gets_its_dot_git_hidden() { + let flags = git_overlay_flags(&[ + bind("/knowledge"), + bind("/agents/atlas/config"), + bind("/agents/kiddo/config"), + ]); + assert_eq!( + flags, + [ + "--tmpfs=/knowledge/.git", + "--tmpfs=/agents/atlas/config/.git", + "--tmpfs=/agents/kiddo/config/.git", + ] + ); + } + + /// ...and nothing else does. A blanket "overlay .git on every bind" + /// would mask a real `.git` under `state/`, where an agent legitimately + /// keeps working clones of its own. + #[test] + fn non_repo_mounts_are_left_alone() { + let flags = git_overlay_flags(&[ + bind("/agents/atlas/state"), + bind("/shared"), + bind("/applied"), + bind("/agents/atlas/config-notes"), + ]); + assert!(flags.is_empty(), "overlaid a non-repo mount: {flags:?}"); + } + /// A request that streams into a caller-supplied descriptor. fn fd_taking_request() -> PrivRequest { PrivRequest::SendAgentSnapshotToFd {