From e59c01059a752d3eeb3102751541e7f36c230103 Mon Sep 17 00:00:00 2001 From: atlas Date: Sun, 18 Oct 2026 05:37:28 +0200 Subject: [PATCH] refuse a diff that makes agents.md anything but a regular file --- hive-agent/src/swarm_agents_md.rs | 69 +++++++++++++++++++++++++++++++ 1 file changed, 69 insertions(+) diff --git a/hive-agent/src/swarm_agents_md.rs b/hive-agent/src/swarm_agents_md.rs index fe744747..5655b4f3 100644 --- a/hive-agent/src/swarm_agents_md.rs +++ b/hive-agent/src/swarm_agents_md.rs @@ -204,6 +204,13 @@ fn apply_diff(state_dir: &Path, apply: &Apply) -> ApplyReply { if at != apply.base_revision { return ApplyReply::Stale { revision: at }; } + if let Some(mode) = non_regular_mode(&apply.diff) { + return ApplyReply::Rejected { + error: format!( + "the diff sets file mode {mode}; AGENTS.md must stay a regular file (100644)" + ), + }; + } let work = match tempfile::tempdir() { Ok(dir) => dir, Err(e) => return failed("creating a directory to apply in", &e), @@ -234,6 +241,12 @@ fn apply_diff(state_dir: &Path, apply: &Apply) -> ApplyReply { error: format!("the diff must change AGENTS.md and nothing else; it left {left:?}"), }; } + // Belt and braces for the mode check above: never read through a link. + if !std::fs::symlink_metadata(©).is_ok_and(|m| m.file_type().is_file()) { + return ApplyReply::Rejected { + error: "the diff left AGENTS.md as something other than a regular file".to_owned(), + }; + } let edited = match std::fs::read(©) { Ok(bytes) => bytes, Err(e) => return failed("reading the applied AGENTS.md", &e), @@ -248,8 +261,20 @@ fn apply_diff(state_dir: &Path, apply: &Apply) -> ApplyReply { /// Run `git ` in `dir` with `diff` on stdin. The outer error is git not /// running; the inner one is git's stderr when it exits non-zero. +/// The first file mode a diff header sets that is not `100644`, if any. +fn non_regular_mode(diff: &str) -> Option<&str> { + diff.lines() + .filter_map(|l| { + l.strip_prefix("new file mode ") + .or_else(|| l.strip_prefix("new mode ")) + }) + .map(str::trim) + .find(|m| *m != "100644") +} + fn git(dir: &Path, args: &[&str], diff: &str) -> std::io::Result> { let mut child = std::process::Command::new("git") + .args(["-c", "core.symlinks=false"]) .args(args) .current_dir(dir) // Outside a repository `git apply` patches plain files, as `patch` @@ -396,6 +421,50 @@ mod tests { assert_eq!(file(&dir), "# atlas\n"); } + /// A symlink diff must not make the harness read the link's target into + /// AGENTS.md, which the mirror then publishes. + #[test] + fn a_diff_that_creates_a_symlink_is_refused_and_nothing_is_read_through_it() { + let secret = tempfile::tempdir().expect("tempdir"); + let target = secret.path().join("secret"); + std::fs::write(&target, "do not publish\n").expect("write"); + let link = format!( + "diff --git a/AGENTS.md b/AGENTS.md\nnew file mode 120000\n--- /dev/null\n+++ b/AGENTS.md\n@@ -0,0 +1 @@\n+{}\n\\ No newline at end of file\n", + target.display() + ); + + // Fresh create. + let dir = tempfile::tempdir().expect("tempdir"); + let reply = apply_diff(dir.path(), &apply("", &link)); + assert!(matches!(reply, ApplyReply::Rejected { .. }), "{reply:?}"); + assert!(!dir.path().join("AGENTS.md").exists()); + + // Typechange of an existing file. + let dir = state(BEFORE); + let typechange = format!( + "diff --git a/AGENTS.md b/AGENTS.md\ndeleted file mode 100644\n--- a/AGENTS.md\n+++ /dev/null\n@@ -1,2 +0,0 @@\n-# atlas\n-keep notes short\n{link}" + ); + let reply = apply_diff(dir.path(), &apply(BEFORE, &typechange)); + assert!(matches!(reply, ApplyReply::Rejected { .. }), "{reply:?}"); + assert_eq!(file(&dir), BEFORE); + } + + /// The mode check is the first layer; with it out of the way git must + /// still not write a link (`core.symlinks=false`) and the regular-file + /// check must hold. + #[test] + fn a_link_never_survives_the_apply_even_without_the_mode_check() { + let dir = tempfile::tempdir().expect("tempdir"); + let work = tempfile::tempdir().expect("tempdir"); + let diff = "diff --git a/AGENTS.md b/AGENTS.md\nnew file mode 120000\n--- /dev/null\n+++ b/AGENTS.md\n@@ -0,0 +1 @@\n+/etc/hostname\n\\ No newline at end of file\n"; + super::git(work.path(), &["apply"], diff) + .expect("git runs") + .expect("applies"); + let meta = std::fs::symlink_metadata(work.path().join("AGENTS.md")).expect("exists"); + assert!(meta.file_type().is_file(), "git wrote a link"); + drop(dir); + } + /// The next mirror write, or `None` after ten seconds without one. async fn next(rx: &mut tokio::sync::mpsc::UnboundedReceiver) -> Option { tokio::time::timeout(Duration::from_secs(10), rx.recv())