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`.
|
/// write network isolation settings, then append `EXTRA_NSPAWN_FLAGS`.
|
||||||
/// When `isolation` is `Some`, writes `PRIVATE_NETWORK=1` + veth wiring;
|
/// When `isolation` is `Some`, writes `PRIVATE_NETWORK=1` + veth wiring;
|
||||||
/// when `None`, writes `PRIVATE_NETWORK=0`.
|
/// 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(
|
fn write_nspawn_flags(
|
||||||
container: &str,
|
container: &str,
|
||||||
binds: &[BindMount],
|
binds: &[BindMount],
|
||||||
|
|
@ -2395,19 +2424,7 @@ fn write_nspawn_flags(
|
||||||
format!("{flag}={}:{}", b.host_path, b.container_path)
|
format!("{flag}={}:{}", b.host_path, b.container_path)
|
||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
// Defense-in-depth for the knowledge bind-mount: overlay an empty tmpfs
|
flags.extend(git_overlay_flags(binds));
|
||||||
// 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());
|
|
||||||
}
|
|
||||||
// Credential forwarding: nspawn loads each host secret into the
|
// Credential forwarding: nspawn loads each host secret into the
|
||||||
// container's credential store under `<name>`; inner units inherit it
|
// container's credential store under `<name>`; inner units inherit it
|
||||||
// via `LoadCredential=<name>`. Validated (name charset + bind-path
|
// via `LoadCredential=<name>`. Validated (name charset + bind-path
|
||||||
|
|
@ -2573,12 +2590,58 @@ async fn sync_agent_tmpfiles(agents: &[AgentTmpfilesEntry]) -> Result<(String, S
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::{
|
use super::{
|
||||||
OwnedFd, PAUSED_MARKER_FILE, PrivRequest, check_fd_agreement, contains_secret_shaped_run,
|
BindMount, OwnedFd, PAUSED_MARKER_FILE, PrivRequest, check_fd_agreement,
|
||||||
limits_dropin_body, redact_secret_line, remove_marker_in, write_state_file_nofollow,
|
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::path::PathBuf;
|
||||||
use std::sync::atomic::{AtomicU32, Ordering};
|
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.
|
/// A request that streams into a caller-supplied descriptor.
|
||||||
fn fd_taking_request() -> PrivRequest {
|
fn fd_taking_request() -> PrivRequest {
|
||||||
PrivRequest::SendAgentSnapshotToFd {
|
PrivRequest::SendAgentSnapshotToFd {
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue