lifecycle: bind each sub-agent's config repo read-only at /agents/<name>/config

This commit is contained in:
damocles 2026-05-20 10:00:28 +02:00
parent 56e7eb6e73
commit 1529c2d777
4 changed files with 46 additions and 3 deletions

View file

@ -768,6 +768,8 @@ fn set_nspawn_flags(
claude_dir: &Path,
notes_dir: &Path,
) -> Result<()> {
use std::fmt::Write as _;
// Ensure /shared directory exists before binding. systemd-nspawn requires the bind source to exist.
std::fs::create_dir_all(HOST_SHARED_ROOT)
.with_context(|| format!("create {HOST_SHARED_ROOT}"))?;
@ -775,6 +777,11 @@ fn set_nspawn_flags(
let path = format!("/etc/nixos-containers/{container}.conf");
let original = std::fs::read_to_string(&path).with_context(|| format!("read {path}"))?;
// Logical agent name (container name minus the sub-agent prefix).
// For the manager the strip is a no-op — harmless, manager paths
// below are gated on `container == MANAGER_NAME` anyway.
let agent_name = container.strip_prefix(AGENT_PREFIX).unwrap_or(container);
// Compute the in-container state mount point. Sub-agents get
// /agents/<name>/state; the manager keeps the legacy /state path.
// Claude credentials always land at /root/.claude for all agents so
@ -783,7 +790,6 @@ fn set_nspawn_flags(
let notes_mount = if container == MANAGER_NAME {
CONTAINER_NOTES_MOUNT.to_owned()
} else {
let agent_name = container.strip_prefix(AGENT_PREFIX).unwrap_or(container);
format!("/agents/{agent_name}/state")
};
let claude_mount = CONTAINER_CLAUDE_MOUNT;
@ -796,7 +802,6 @@ fn set_nspawn_flags(
shared = HOST_SHARED_ROOT,
);
if container == MANAGER_NAME {
use std::fmt::Write as _;
// systemd-nspawn refuses to start a container whose bind
// source doesn't exist. The meta repo is created by the
// startup migration, but make sure the directory is there
@ -832,6 +837,24 @@ fn set_nspawn_flags(
" --bind-ro={HOST_META_ROOT}:{mount}",
mount = crate::meta::CONTAINER_MANAGER_META_MOUNT,
);
} else {
// Sub-agents get a READ-ONLY view of their own proposed
// config repo at /agents/<name>/config — agent.nix plus
// whatever extra files the manager split the config into.
// Lets an agent inspect exactly what defines it and request
// precise changes from the manager. RO is load-bearing: the
// agent must NOT edit its own config — changes only ever flow
// through the manager's RW proposed repo + the approval
// queue. The manager already has this dir RW via the /agents
// tree bind above.
let config_dir = format!("{HOST_AGENTS_ROOT}/{agent_name}/config");
// nspawn refuses to start when a bind source is missing.
// `setup_proposed` seeds this dir before spawn reaches here,
// but create defensively so a missing repo degrades to an
// empty RO dir instead of a container that won't boot.
std::fs::create_dir_all(&config_dir)
.with_context(|| format!("create {config_dir}"))?;
let _ = write!(binds, " --bind-ro={config_dir}:/agents/{agent_name}/config");
}
let bind_flag = format!("EXTRA_NSPAWN_FLAGS=\"{binds}\"");
let mut lines: Vec<String> = original