From 6b3ef4549cfa7642daa1bfba51867a3f671c7bcb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?m=C3=BCde?= Date: Sat, 16 May 2026 01:42:11 +0200 Subject: [PATCH] manager_server: reject proposals that modify flake.nix MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit submit_apply_commit now diffs the freshly-tagged proposal/ 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. --- TODO.md | 8 ------ hive-c0re/src/manager_server.rs | 50 +++++++++++++++++++++++++++++++++ 2 files changed, 50 insertions(+), 8 deletions(-) diff --git a/TODO.md b/TODO.md index d153f72..a9ee2be 100644 --- a/TODO.md +++ b/TODO.md @@ -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 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 -- flake.nix` - — non-empty diff → refuse + clear error message. Cheap - belt-and-suspenders. - - **Inert `nix flake lock` no-args call in `meta::sync_agents`.** Still valid in current nix (resolves missing inputs without bumping existing ones) but parallel to the deprecated diff --git a/hive-c0re/src/manager_server.rs b/hive-c0re/src/manager_server.rs index a15b338..2a47d34 100644 --- a/hive-c0re/src/manager_server.rs +++ b/hive-c0re/src/manager_server.rs @@ -324,6 +324,26 @@ async fn submit_apply_commit( 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 .approvals .set_fetched_sha(id, &sha) @@ -331,6 +351,36 @@ async fn submit_apply_commit( 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 { + 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 /// try to resolve the question with `[expired]`. If the operator (or /// any other path) already answered it, `answer()` returns Err and