manager_server: reject proposals that modify flake.nix

submit_apply_commit now diffs the freshly-tagged proposal/<id>
against applied/main and refuses if flake.nix is in the
changeset. flake.nix is fixed boilerplate the meta flake
depends on (it exports nixosModules.default = import ./agent
.nix); silent edits there would break the nixosConfiguration
in subtle ways. the manager prompt already says don't touch
it; this is the host-side belt — clear error to the manager
on submit, row marked failed in sqlite, no orphan pending
approval to chase. diff-failure is logged + ignored: the
build path surfaces concrete errors if flake.nix is actually
broken.
This commit is contained in:
müde 2026-05-16 01:42:11 +02:00
parent 68ef6ab433
commit 6b3ef4549c
2 changed files with 50 additions and 8 deletions

View file

@ -74,14 +74,6 @@ Pick anything from here when relevant. Cross-cutting design notes live in
once a terminal sibling lands — would keep the audit once a terminal sibling lands — would keep the audit
trails browsable without forever-growth. trails browsable without forever-growth.
- **Reject proposals that touch `flake.nix`.** The manager's
prompt says don't edit it, but nothing on the host side
enforces. Add a check in
`manager_server::submit_apply_commit`: after fetching the
proposal sha into applied, `git diff-tree <sha> -- flake.nix`
— non-empty diff → refuse + clear error message. Cheap
belt-and-suspenders.
- **Inert `nix flake lock` no-args call in `meta::sync_agents`.** - **Inert `nix flake lock` no-args call in `meta::sync_agents`.**
Still valid in current nix (resolves missing inputs without Still valid in current nix (resolves missing inputs without
bumping existing ones) but parallel to the deprecated bumping existing ones) but parallel to the deprecated

View file

@ -324,6 +324,26 @@ async fn submit_apply_commit(
return Err(anyhow::anyhow!("git_fetch_to_tag: {e:#}")); return Err(anyhow::anyhow!("git_fetch_to_tag: {e:#}"));
} }
}; };
// Reject proposals that touch flake.nix — that file is fixed
// boilerplate the meta flake depends on (it exports
// `nixosModules.default = import ./agent.nix`); a manager edit
// there silently breaks the nixosConfiguration. Prompt tells the
// manager not to; this is the host-side belt.
let proposal_ref = format!("refs/tags/{tag}");
match proposal_modifies(&applied_dir, &proposal_ref, "flake.nix").await {
Ok(true) => {
let note = "proposal modifies flake.nix — that file is hive-c0re-owned \
boilerplate; edit only agent.nix (and sibling modules)";
let _ = coord.approvals.mark_failed(id, note);
return Err(anyhow::anyhow!(note));
}
Ok(false) => {}
Err(e) => {
// Diff itself failed — log + continue. The build will
// surface concrete errors if flake.nix is actually borked.
tracing::warn!(error = ?e, %tag, "flake.nix tamper-check failed; allowing");
}
}
coord coord
.approvals .approvals
.set_fetched_sha(id, &sha) .set_fetched_sha(id, &sha)
@ -331,6 +351,36 @@ async fn submit_apply_commit(
Ok((id, sha)) Ok((id, sha))
} }
/// True iff `proposal_ref`'s tree differs from `refs/heads/main` at
/// `path`. Used to enforce that proposals don't touch hive-c0re-owned
/// files in the applied repo.
async fn proposal_modifies(
applied_dir: &std::path::Path,
proposal_ref: &str,
path: &str,
) -> anyhow::Result<bool> {
let out = crate::lifecycle::git_command()
.current_dir(applied_dir)
.args([
"diff",
"--name-only",
&format!("refs/heads/main..{proposal_ref}"),
"--",
path,
])
.output()
.await
.map_err(|e| anyhow::anyhow!("spawn git diff: {e}"))?;
if !out.status.success() {
anyhow::bail!(
"git diff exited {}: {}",
out.status,
String::from_utf8_lossy(&out.stderr).trim()
);
}
Ok(!out.stdout.iter().all(u8::is_ascii_whitespace))
}
/// On `AskOperator { ttl_seconds: Some(n) }`, sleep n seconds and then /// On `AskOperator { ttl_seconds: Some(n) }`, sleep n seconds and then
/// try to resolve the question with `[expired]`. If the operator (or /// try to resolve the question with `[expired]`. If the operator (or
/// any other path) already answered it, `answer()` returns Err and /// any other path) already answered it, `answer()` returns Err and