Skip to main content

max / makenotwork

Let a build host declare more than one artifact root pom's first hand-off release failed at collect. gate_pull confines a pull to the host's declared artifact root, fw13 and astra declare /home/max/Code/Apps, and pom is at /home/max/Code/MNW/pom. The fence was right and the root was too narrow to describe the machine. Widening it is not the fix: the one path covering both trees is ~/Code, which also covers ~/Code/_private and the signing keys, so widening trades away exactly what the gate holds. A host has some artifact trees and not others, and now it can say so. pull_root stays and pull_roots joins it, unioned. Every topology in existence writes the singular, and a config that must be edited in lockstep with a binary is a way to brick the daemon. Nothing hit this before because nothing exercised it: pom used to end at deploy, which pushes rather than pulls, the library crates publish to crates.io and never collect, and every collecting app lived under Apps. Closes bento ff5a37cf.
Co-Authored-By
Claude Opus 5 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-08-09 18:12 UTC
Signed with PGP, not checked
Commit: 3fb2511c16fab0c5e24adab95b1dd54dc549aa37
Parent: af10cde
3 files changed, +195 insertions, -47 deletions
@@ -155,20 +155,11 @@
155 155 /// depositing the notary credential into `dist_root`.
156 156 pub fn build_sync(host: &Host) -> Arc<dyn Executor> {
157 157 let caps = CapabilitySet::from_tokens(&host.actuate, &host.observe);
158 + let roots = host.artifact_roots();
158 159 if host.ssh == "local" || host.ssh.is_empty() {
159 - let exec = LocalExec::new(caps);
160 - let exec = match &host.pull_root {
161 - Some(root) => exec.with_pull_root(root),
162 - None => exec,
163 - };
164 - Arc::new(exec)
160 + Arc::new(LocalExec::new(caps).with_pull_roots(roots))
165 161 } else {
166 - let exec = SshExec::new(host.ssh.clone(), caps);
167 - let exec = match &host.pull_root {
168 - Some(root) => exec.with_pull_root(root),
169 - None => exec,
170 - };
171 - Arc::new(exec)
162 + Arc::new(SshExec::new(host.ssh.clone(), caps).with_pull_roots(roots))
172 163 }
173 164 }
174 165
@@ -286,6 +277,40 @@
286 277 );
287 278 }
288 279
280 + /// pom's case: one host, two trees. The singular and the plural are unioned,
281 + /// so a topology that adds `pull_roots` keeps whatever `pull_root` said.
282 + #[tokio::test]
283 + async fn sync_pull_accepts_every_declared_root() {
284 + let h = host(
285 + "[[host]]\nname = \"fw13\"\nssh = \"local\"\ntargets = [\"linux/x86_64\"]\n\
286 + pull_root = \"/home/max/Code/Apps\"\n\
287 + pull_roots = [\"/home/max/Code/MNW\"]",
288 + );
289 + assert_eq!(
290 + h.artifact_roots(),
291 + vec![
292 + std::path::PathBuf::from("/home/max/Code/Apps"),
293 + std::path::PathBuf::from("/home/max/Code/MNW"),
294 + ],
295 + );
296 + let sync = build_sync(&h);
297 + // The refusal must still name the secret-bearing sibling as out of
298 + // bounds: adding MNW widens the gate by MNW, not by ~/Code.
299 + let err = sync
300 + .pull_file(
301 + std::path::Path::new("/home/max/Code/_private/sando/api-token"),
302 + std::path::Path::new("/tmp/out"),
303 + &SyncOpts::default(),
304 + )
305 + .await
306 + .expect_err("_private is under neither root");
307 + assert!(
308 + err.to_string()
309 + .contains("escapes every declared artifact root"),
310 + "{err}"
311 + );
312 + }
313 +
289 314 #[test]
290 315 fn local_host_syncs_locally() {
291 316 let h = host("[[host]]\nname = \"fw13\"\nssh = \"local\"\ntargets = [\"linux/x86_64\"]");
@@ -315,7 +340,7 @@
315 340 .expect_err("a path outside pull_root must be denied");
316 341 assert!(
317 342 err.to_string()
318 - .contains("escapes the declared artifact root"),
343 + .contains("escapes every declared artifact root"),
319 344 "{err}"
320 345 );
321 346
@@ -209,8 +209,35 @@
209 209 /// fail-closed). Without it, `collect(host, '/Users/max/.tauri/passwords.env')`
210 210 /// would rsync the notary credential into `dist_root` — "THE WALL" held on
211 211 /// the agent plane but not the sync plane the agent hosts are collected over.
212 + ///
213 + /// Kept alongside [`Self::pull_roots`] rather than replaced by it: every
214 + /// topology in existence writes the singular, and a config that has to be
215 + /// edited in lockstep with a binary is a way to brick the daemon.
212 216 #[serde(default)]
213 217 pub pull_root: Option<PathBuf>,
218 + /// Further artifact roots on this host, on the same terms as
219 + /// [`Self::pull_root`]. The two are unioned; declaring both is normal.
220 + ///
221 + /// Plural because a build host builds out of more than one tree. fw13 and
222 + /// astra hold `~/Code/Apps` and `~/Code/MNW`, and pom's first hand-off
223 + /// release failed collecting from the second (2026-08-09). Widening the
224 + /// singular to `~/Code` would have covered `~/Code/_private` and its signing
225 + /// keys, which is the thing the fence is for, so the list says the narrower
226 + /// true thing instead.
227 + #[serde(default)]
228 + pub pull_roots: Vec<PathBuf>,
229 + }
230 +
231 + impl Host {
232 + /// Every artifact root declared for this host, singular and plural merged.
233 + /// Empty means this host pulls nothing, which is the fail-closed default.
234 + pub fn artifact_roots(&self) -> Vec<PathBuf> {
235 + self.pull_root
236 + .iter()
237 + .cloned()
238 + .chain(self.pull_roots.iter().cloned())
239 + .collect()
240 + }
214 241 }
215 242
216 243 /// Every build host can, by definition, build and package. Keeping these the
@@ -57,15 +57,24 @@
57 57 /// 1. The grant must include `observe:artifact` — retrieving a produced release
58 58 /// artifact is an observe-plane capability, distinct from reading the build
59 59 /// log. Denied → [`CapabilityDenied`], downcastable for audit.
60 - /// 2. `requested` must lie under the executor's declared `pull_root`. A
61 - /// transport with **no** root pulls nothing — that is the "declared per-host
62 - /// artifact root" the audit finding calls for.
60 + /// 2. `requested` must lie under one of the executor's declared artifact roots.
61 + /// A transport with **no** roots pulls nothing — that is the "declared
62 + /// per-host artifact root" the audit finding calls for.
63 63 ///
64 64 /// Without this, `pull_file`/`pull_dir`/`pull_glob` would rsync ANY path off the
65 65 /// host (`collect('mbp', '/Users/max/.tauri/passwords.env', …)` deposits the
66 66 /// notary credential into `dist_root`): "THE WALL" held on the agent plane and
67 67 /// not on the sync plane the agent hosts are actually collected over.
68 68 ///
69 + /// **Several roots, not one, because a build host builds from several trees.**
70 + /// One root was right while every collecting app lived under `~/Code/Apps`. pom
71 + /// is the first that does not — it is at `~/Code/MNW/pom`, and its release
72 + /// failed here on 2026-08-09. The fix is not a wider root: the only single path
73 + /// covering both is `~/Code`, which contains `~/Code/_private` and its signing
74 + /// keys, so widening would hand away exactly what this gate exists to hold. A
75 + /// list says the true thing instead, which is that a host has some artifact
76 + /// trees and not others.
77 + ///
69 78 /// Confinement is **lexical** (reject `..`, require a component-wise prefix),
70 79 /// not canonicalizing like the agent's `confine_to_root`: the ssh transport's
71 80 /// path is on a remote host we cannot `canonicalize()` without an extra
@@ -76,18 +85,21 @@
76 85 fn gate_pull(
77 86 caps: &CapabilitySet,
78 87 host: &str,
79 - pull_root: Option<&Path>,
88 + pull_roots: &[PathBuf],
80 89 requested: &Path,
81 90 ) -> Result<()> {
82 91 if !caps.permits_observe(&ObserveKind::Artifact) {
83 92 return Err(CapabilityDenied::new(host, &Action::Observe(ObserveKind::Artifact)).into());
84 93 }
85 - let Some(root) = pull_root else {
94 + if pull_roots.is_empty() {
86 95 anyhow::bail!(
87 96 "pull from `{host}` denied: no artifact root declared for this host \
88 - (set `pull_root` in the host's topology entry)"
97 + (set `pull_root` or `pull_roots` in the host's topology entry)"
89 98 );
90 - };
99 + }
100 + // Checked before the prefix test, and separately: `..` inside a path that
101 + // also happens to start with a root would otherwise pass the prefix and
102 + // climb out of it afterwards.
91 103 anyhow::ensure!(
92 104 !requested
93 105 .components()
@@ -96,10 +108,14 @@
96 108 requested.display()
97 109 );
98 110 anyhow::ensure!(
99 - requested.starts_with(root),
100 - "pull path `{}` escapes the declared artifact root `{}` on `{host}`",
111 + pull_roots.iter().any(|root| requested.starts_with(root)),
112 + "pull path `{}` escapes every declared artifact root on `{host}` ({})",
101 113 requested.display(),
102 - root.display()
114 + pull_roots
115 + .iter()
116 + .map(|r| format!("`{}`", r.display()))
117 + .collect::<Vec<_>>()
118 + .join(", "),
103 119 );
104 120 Ok(())
105 121 }
@@ -206,7 +222,7 @@
206 222 pub struct LocalExec {
207 223 host: RemoteHost,
208 224 caps: CapabilitySet,
209 - pull_root: Option<PathBuf>,
225 + pull_roots: Vec<PathBuf>,
210 226 }
211 227
212 228 impl LocalExec {
@@ -214,7 +230,7 @@
214 230 Self {
215 231 host: RemoteHost::new("local"),
216 232 caps,
217 - pull_root: None,
233 + pull_roots: Vec::new(),
218 234 }
219 235 }
220 236
@@ -223,7 +239,20 @@
223 239 /// executor built without a root refuses every pull.
224 240 #[must_use]
225 241 pub fn with_pull_root(mut self, root: impl Into<PathBuf>) -> Self {
226 - self.pull_root = Some(root.into());
242 + self.pull_roots.push(root.into());
243 + self
244 + }
245 +
246 + /// Declare several artifact roots at once. A host builds out of more than
247 + /// one tree (`~/Code/Apps` and `~/Code/MNW`), and naming them is the
248 + /// alternative to a single root wide enough to cover both.
249 + #[must_use]
250 + pub fn with_pull_roots<I, P>(mut self, roots: I) -> Self
251 + where
252 + I: IntoIterator<Item = P>,
253 + P: Into<PathBuf>,
254 + {
255 + self.pull_roots.extend(roots.into_iter().map(Into::into));
227 256 self
228 257 }
229 258 }
@@ -238,7 +267,7 @@
238 267 }
239 268
240 269 async fn pull_file(&self, remote: &Path, local: &Path, opts: &SyncOpts) -> Result<()> {
241 - gate_pull(&self.caps, "local", self.pull_root.as_deref(), remote)?;
270 + gate_pull(&self.caps, "local", &self.pull_roots, remote)?;
242 271 // No trailing slash: rsync copies the file itself.
243 272 let src = remote.to_string_lossy().to_string();
244 273 run_rsync(
@@ -249,7 +278,7 @@
249 278 }
250 279
251 280 async fn pull_dir(&self, remote: &Path, local: &Path, opts: &SyncOpts) -> Result<()> {
252 - gate_pull(&self.caps, "local", self.pull_root.as_deref(), remote)?;
281 + gate_pull(&self.caps, "local", &self.pull_roots, remote)?;
253 282 // Local "pull" is just a local rsync; trailing slash = contents.
254 283 let src = format!("{}/", remote.display());
255 284 run_rsync(
@@ -263,7 +292,7 @@
263 292 gate_pull(
264 293 &self.caps,
265 294 "local",
266 - self.pull_root.as_deref(),
295 + &self.pull_roots,
267 296 Path::new(remote_glob),
268 297 )?;
269 298 // No remote shell to expand for us, and `rsync` is spawned directly (no
@@ -297,7 +326,7 @@
297 326 pub struct SshExec {
298 327 host: RemoteHost,
299 328 caps: CapabilitySet,
300 - pull_root: Option<PathBuf>,
329 + pull_roots: Vec<PathBuf>,
301 330 }
302 331
303 332 impl SshExec {
@@ -305,7 +334,7 @@
305 334 Self {
306 335 host: RemoteHost::new(ssh_target),
307 336 caps,
308 - pull_root: None,
337 + pull_roots: Vec::new(),
309 338 }
310 339 }
311 340
@@ -323,7 +352,20 @@
323 352 /// caller). See [`gate_pull`]; required before any `pull_*` succeeds.
324 353 #[must_use]
325 354 pub fn with_pull_root(mut self, root: impl Into<PathBuf>) -> Self {
326 - self.pull_root = Some(root.into());
355 + self.pull_roots.push(root.into());
356 + self
357 + }
358 +
359 + /// Declare several artifact roots at once. A host builds out of more than
360 + /// one tree (`~/Code/Apps` and `~/Code/MNW`), and naming them is the
361 + /// alternative to a single root wide enough to cover both.
362 + #[must_use]
363 + pub fn with_pull_roots<I, P>(mut self, roots: I) -> Self
364 + where
365 + I: IntoIterator<Item = P>,
366 + P: Into<PathBuf>,
367 + {
368 + self.pull_roots.extend(roots.into_iter().map(Into::into));
327 369 self
328 370 }
329 371
@@ -342,12 +384,7 @@
342 384 }
343 385
344 386 async fn pull_file(&self, remote: &Path, local: &Path, opts: &SyncOpts) -> Result<()> {
345 - gate_pull(
346 - &self.caps,
347 - self.host.ssh_target(),
348 - self.pull_root.as_deref(),
349 - remote,
350 - )?;
387 + gate_pull(&self.caps, self.host.ssh_target(), &self.pull_roots, remote)?;
351 388 // No trailing slash: rsync copies the file itself, not "contents of".
352 389 let src = format!("{}:{}", self.host.ssh_target(), remote.display());
353 390 let ssh = Some(self.host.ssh_args());
@@ -359,12 +396,7 @@
359 396 }
360 397
361 398 async fn pull_dir(&self, remote: &Path, local: &Path, opts: &SyncOpts) -> Result<()> {
362 - gate_pull(
363 - &self.caps,
364 - self.host.ssh_target(),
365 - self.pull_root.as_deref(),
366 - remote,
367 - )?;
399 + gate_pull(&self.caps, self.host.ssh_target(), &self.pull_roots, remote)?;
368 400 let src = format!("{}:{}/", self.host.ssh_target(), remote.display());
369 401 let ssh = Some(self.host.ssh_args());
370 402 run_rsync(
@@ -378,7 +410,7 @@
378 410 gate_pull(
379 411 &self.caps,
380 412 self.host.ssh_target(),
381 - self.pull_root.as_deref(),
413 + &self.pull_roots,
382 414 Path::new(remote_glob),
383 415 )?;
384 416 // The REMOTE shell expands this one: rsync hands an un-`--protect-args`
@@ -665,6 +697,70 @@
665 697 );
666 698 }
667 699
700 + /// A host that builds out of two trees can collect from either. This is the
701 + /// pom case: apps under `~/Code/Apps`, pom under `~/Code/MNW`, and the only
702 + /// single root covering both also covers `~/Code/_private`.
703 + #[tokio::test]
704 + async fn pull_allowed_from_any_declared_root() {
705 + let dir = tempfile::tempdir().unwrap();
706 + let (apps, mnw) = (dir.path().join("Apps"), dir.path().join("MNW"));
707 + tokio::fs::create_dir_all(&apps).await.unwrap();
708 + tokio::fs::create_dir_all(&mnw).await.unwrap();
709 + let artifact = mnw.join("pom");
710 + tokio::fs::write(&artifact, b"ELF").await.unwrap();
711 + let out = dir.path().join("out");
712 + let exec = LocalExec::new(artifact_caps()).with_pull_roots([&apps, &mnw]);
713 + exec.pull_file(&artifact, &out, &SyncOpts::default())
714 + .await
715 + .expect("the second root is as good as the first");
716 + assert!(out.exists());
717 + }
718 +
719 + /// Adding a root widens the gate by exactly that root and no further. The
720 + /// secret sits beside both, and is reachable from neither.
721 + #[tokio::test]
722 + async fn extra_roots_do_not_widen_to_their_parent() {
723 + let dir = tempfile::tempdir().unwrap();
724 + let (apps, mnw) = (dir.path().join("Apps"), dir.path().join("MNW"));
725 + tokio::fs::create_dir_all(&apps).await.unwrap();
726 + tokio::fs::create_dir_all(&mnw).await.unwrap();
727 + let secret = dir.path().join("_private").join("api-token");
728 + tokio::fs::create_dir_all(secret.parent().unwrap())
729 + .await
730 + .unwrap();
731 + tokio::fs::write(&secret, b"tok").await.unwrap();
732 + let exec = LocalExec::new(artifact_caps()).with_pull_roots([&apps, &mnw]);
733 + let err = exec
734 + .pull_file(&secret, &dir.path().join("out"), &SyncOpts::default())
735 + .await
736 + .unwrap_err();
737 + assert!(
738 + err.to_string()
739 + .contains("escapes every declared artifact root"),
740 + "{err}"
741 + );
742 + assert!(!dir.path().join("out").exists());
743 + }
744 +
745 + /// The error names every root, because "escapes the artifact root" with one
746 + /// root unnamed is a message an operator cannot act on when there are two.
747 + #[tokio::test]
748 + async fn refusal_names_every_declared_root() {
749 + let dir = tempfile::tempdir().unwrap();
750 + let (apps, mnw) = (dir.path().join("Apps"), dir.path().join("MNW"));
751 + tokio::fs::create_dir_all(&apps).await.unwrap();
752 + tokio::fs::create_dir_all(&mnw).await.unwrap();
753 + let outside = dir.path().join("elsewhere");
754 + tokio::fs::write(&outside, b"x").await.unwrap();
755 + let exec = LocalExec::new(artifact_caps()).with_pull_roots([&apps, &mnw]);
756 + let err = exec
757 + .pull_file(&outside, &dir.path().join("out"), &SyncOpts::default())
758 + .await
759 + .unwrap_err()
760 + .to_string();
761 + assert!(err.contains("Apps") && err.contains("MNW"), "{err}");
762 + }
763 +
668 764 /// The `passwords.env` case: an authorized caller with a legitimate root
669 765 /// still cannot reach a sibling path outside it.
670 766 #[tokio::test]
@@ -683,7 +779,7 @@
683 779 .unwrap_err();
684 780 assert!(
685 781 err.to_string()
686 - .contains("escapes the declared artifact root")
782 + .contains("escapes every declared artifact root")
687 783 );
688 784 assert!(!dir.path().join("out").exists());
689 785 }
@@ -723,7 +819,7 @@
723 819 .unwrap_err();
724 820 assert!(
725 821 err.to_string()
726 - .contains("escapes the declared artifact root")
822 + .contains("escapes every declared artifact root")
727 823 );
728 824 }
729 825