hide .git on the agent config mounts, like /knowledge already does
An agent's config mount is a git repo, so it could read every branch and the full history of a config whose currently deployed value is the only thing it may act on -- and an abandoned branch looks no different from a live one. The knowledge bind already solved this with an empty tmpfs overlaid on its .git. Same rule, extended: an agent is handed a working tree, never a repository. Folds both cases into git_overlay_flags so the reason is stated once instead of hardcoded per mount. Config mounts are matched by shape rather than a name list because the set grows at runtime with each child bound into a parent.
This commit is contained in:
parent
c32a9367e4
commit
9f26c416c0
1 changed files with 78 additions and 15 deletions
|
|
@ -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=<mount>/.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/<name>/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<String> {
|
||||
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 `<name>`; inner units inherit it
|
||||
// via `LoadCredential=<name>`. 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 {
|
||||
|
|
|
|||
Loading…
Reference in a new issue