| 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 |
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 |
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 |
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 |
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 |
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 |
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 |
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 |
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 |
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 |
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 |
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 |
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 |
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 |
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 |
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 |
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 |
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 |
|
|