Skip to main content

max / makenotwork

Gate the ops-exec sync plane on observe:artifact + a declared root pull_file/pull_dir/pull_glob had no capability gate and no path confinement, so collect(host, '~/.tauri/passwords.env') could rsync any file off any host into dist_root. "THE WALL" held on the agent /pull plane but not the sync plane the agent hosts are actually collected over. Mirror the agent plane, fail-closed: - ops-exec: gate_pull requires observe:artifact and confines to a per-executor pull_root (lexical: reject .., component-wise prefix). LocalExec/SshExec::with_pull_root; 6 tests incl. the passwords.env case. - bento: per-host pull_root topology field, threaded through build_sync; a host with no root collects nothing. - sando: the backup pull now grants observe:artifact and confines to the dump's own directory. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-07-23 14:10 UTC
Commit: 5df81b8ebef841b939e6492c2acc52af99546e70
Parent: c024e7e
5 files changed, +321 insertions, -21 deletions
@@ -657,11 +657,12 @@ mod tests {
657 657 name = "fw13"
658 658 ssh = "local"
659 659 targets = ["linux/x86_64"]
660 + pull_root = "{repo}"
660 661
661 662 [app.demo]
662 - repo = "{}"
663 + repo = "{repo}"
663 664 "#,
664 - repo.display()
665 + repo = repo.display()
665 666 ))
666 667 .unwrap();
667 668
@@ -778,6 +779,7 @@ repo = "{}"
778 779 name = "fw13"
779 780 ssh = "local"
780 781 targets = ["linux/x86_64"]
782 + pull_root = "{repo}"
781 783
782 784 [[host]]
783 785 name = "winbox"
@@ -785,9 +787,9 @@ ssh = "local"
785 787 targets = ["windows/x86_64"]
786 788
787 789 [app.demo]
788 - repo = "{}"
790 + repo = "{repo}"
789 791 "#,
790 - repo.display()
792 + repo = repo.display()
791 793 ))
792 794 .unwrap();
793 795
@@ -98,12 +98,28 @@ pub fn build_executor(host: &Host) -> Arc<dyn Executor> {
98 98 ///
99 99 /// So a mac host has two transports at once: `AgentRpc` to sign, `SshExec` to
100 100 /// fetch what it signed. Both reach the same box; only the privilege differs.
101 + ///
102 + /// The sync transport is confined to the host's declared `pull_root` and gated
103 + /// on its `observe:artifact` grant (`ops_exec::gate_pull`). Both are
104 + /// fail-closed: a host that declares no `pull_root` collects nothing. That
105 + /// confinement is what keeps a `collect(host, '…/.tauri/passwords.env')` from
106 + /// depositing the notary credential into `dist_root`.
101 107 pub fn build_sync(host: &Host) -> Arc<dyn Executor> {
102 108 let caps = CapabilitySet::from_tokens(&host.actuate, &host.observe);
103 109 if host.ssh == "local" || host.ssh.is_empty() {
104 - Arc::new(LocalExec::new(caps))
110 + let exec = LocalExec::new(caps);
111 + let exec = match &host.pull_root {
112 + Some(root) => exec.with_pull_root(root),
113 + None => exec,
114 + };
115 + Arc::new(exec)
105 116 } else {
106 - Arc::new(SshExec::new(host.ssh.clone(), caps))
117 + let exec = SshExec::new(host.ssh.clone(), caps);
118 + let exec = match &host.pull_root {
119 + Some(root) => exec.with_pull_root(root),
120 + None => exec,
121 + };
122 + Arc::new(exec)
107 123 }
108 124 }
109 125
@@ -159,10 +175,15 @@ mod tests {
159 175 let h = host(
160 176 "[[host]]\nname = \"mbp\"\nssh = \"mbp\"\ntargets = [\"macos/aarch64\"]\n\
161 177 transport = \"agent\"\nagent_url = \"http://mbp:8765\"\n\
162 - actuate = [\"build\", \"sign\", \"notarize\", \"staple\"]",
178 + actuate = [\"build\", \"sign\", \"notarize\", \"staple\"]\n\
179 + observe = [\"build-log\", \"gatekeeper\", \"artifact\"]\n\
180 + pull_root = \"/nonexistent\"",
163 181 );
164 182 // The sync transport must not be the agent. AgentRpc refuses pull_glob
165 183 // by design, so a non-refusing error proves we got an ssh transport.
184 + // The host declares `artifact` + a `pull_root` so the pull clears the
185 + // fail-closed sync gate and reaches the actual ssh rsync (which then
186 + // fails on the unreachable host / no match) rather than the gate.
166 187 let sync = build_sync(&h);
167 188 let err = sync
168 189 .pull_glob(
@@ -176,6 +197,10 @@ mod tests {
176 197 !err.to_string().contains("unsupported by design"),
177 198 "sync transport for an agent host must NOT be AgentRpc: {err}"
178 199 );
200 + assert!(
201 + !err.to_string().contains("no artifact root declared"),
202 + "the declared pull_root must let the pull reach the ssh transport: {err}"
203 + );
179 204 // ...while the exec transport for the same host still is the agent.
180 205 let exec = build_executor(&h);
181 206 let err = exec
@@ -199,6 +224,48 @@ mod tests {
199 224 assert!(build_sync(&h).capabilities().permits(&Action::Build));
200 225 }
201 226
227 + /// The finding this fix closes, end to end through `build_sync`: a host with
228 + /// a declared `pull_root` collects artifacts under it but refuses a path
229 + /// outside it (the `~/.tauri/passwords.env` exfil). Confinement is lexical,
230 + /// so no real filesystem is needed.
231 + #[tokio::test]
232 + async fn sync_pull_is_confined_to_declared_root() {
233 + let h = host(
234 + "[[host]]\nname = \"fw13\"\nssh = \"local\"\ntargets = [\"linux/x86_64\"]\n\
235 + pull_root = \"/home/max/Code/Apps\"",
236 + );
237 + let sync = build_sync(&h);
238 + // A path outside the declared root is refused before any rsync.
239 + let err = sync
240 + .pull_file(
241 + std::path::Path::new("/home/max/.tauri/passwords.env"),
242 + std::path::Path::new("/tmp/out"),
243 + &Default::default(),
244 + )
245 + .await
246 + .expect_err("a path outside pull_root must be denied");
247 + assert!(
248 + err.to_string()
249 + .contains("escapes the declared artifact root"),
250 + "{err}"
251 + );
252 +
253 + // A host with NO declared root collects nothing at all (fail-closed).
254 + let bare = host("[[host]]\nname = \"fw13\"\nssh = \"local\"\ntargets = [\"linux/x86_64\"]");
255 + let err = build_sync(&bare)
256 + .pull_file(
257 + std::path::Path::new("/home/max/Code/Apps/goingson/x.dmg"),
258 + std::path::Path::new("/tmp/out"),
259 + &Default::default(),
260 + )
261 + .await
262 + .expect_err("no pull_root ⇒ no pulls");
263 + assert!(
264 + err.to_string().contains("no artifact root declared"),
265 + "{err}"
266 + );
267 + }
268 +
202 269 #[test]
203 270 fn agent_host_executor_permits_sign() {
204 271 // The mac host: agent transport, widened grant. Proves AgentRpc is
@@ -20,7 +20,7 @@ use crate::domain::{AppId, Target};
20 20 use anyhow::{Context, Result};
21 21 use serde::Deserialize;
22 22 use std::collections::HashMap;
23 - use std::path::Path;
23 + use std::path::{Path, PathBuf};
24 24
25 25 /// The resolved build matrix: shared hosts, plus each app's own manifest read
26 26 /// from its repo.
@@ -127,6 +127,18 @@ pub struct Host {
127 127 /// Observe capabilities (read-only host inspection).
128 128 #[serde(default = "default_observe")]
129 129 pub observe: Vec<String>,
130 + /// Absolute path ON THIS HOST that artifact pulls are confined to — the
131 + /// declared root the sync transport (`build_sync`) rsyncs collected files
132 + /// out of. Never tilde-expanded (it names a location on the remote host, not
133 + /// the daemon): write it out, e.g. `/home/max/Code/Apps`,
134 + /// `/Users/max/Code/Apps`, `C:/Users/me/Code/Apps`.
135 + ///
136 + /// Unset ⇒ this host's sync transport pulls NOTHING (`ops_exec` is
137 + /// fail-closed). Without it, `collect(host, '/Users/max/.tauri/passwords.env')`
138 + /// would rsync the notary credential into `dist_root` — "THE WALL" held on
139 + /// the agent plane but not the sync plane the agent hosts are collected over.
140 + #[serde(default)]
141 + pub pull_root: Option<PathBuf>,
130 142 }
131 143
132 144 /// Every build host can, by definition, build and package. Keeping these the
@@ -186,11 +186,21 @@ pub async fn fetch(
186 186 port,
187 187 } => {
188 188 // Through the executor rather than a hand-rolled `rsync -e ssh`:
189 - // one transport, one set of SSH flags. Fetching a dump needs no
190 - // authority on the host beyond the SSH key itself, so the
191 - // capability set is empty — `pull_file` is data movement, not a
192 - // gated action.
193 - let exec = SshExec::new(user_host, CapabilitySet::default()).with_port(port);
189 + // one transport, one set of SSH flags. The sync plane is now
190 + // gated (ops_exec::gate_pull): grant `observe:artifact` and
191 + // confine to the dump's own directory. `path` is operator config
192 + // (the BackupSource in sando.toml), not attacker input, so the
193 + // parent-dir root is a formality here — but it keeps this pull
194 + // fail-closed like every other, rather than an open read of the
195 + // remote host.
196 + let caps = CapabilitySet::from_tokens(Vec::<&str>::new(), ["artifact"]);
197 + let exec = SshExec::new(user_host, caps).with_port(port);
198 + let exec = match Path::new(&path).parent() {
199 + Some(dir) if !dir.as_os_str().is_empty() => exec.with_pull_root(dir),
200 + // A bare filename with no directory: confine to the path
201 + // itself (starts_with is reflexive), still fail-closed.
202 + _ => exec.with_pull_root(&path),
203 + };
194 204 // NOT `--partial`: a truncated leftover here is dangerous, not
195 205 // useful — a resumed fetch could splice two different dumps into
196 206 // one plausible-looking file (CF4). NOT `-z` either: the dump is
@@ -14,11 +14,11 @@
14 14 use crate::capability::{CapabilityDenied, CapabilitySet};
15 15 use crate::executor::{Executor, SyncOpts, run_command_into_sink};
16 16 use crate::remote::{LogSink, RemoteHost, RunOutput, sh_quote};
17 - use crate::step::Step;
17 + use crate::step::{Action, ObserveKind, Step};
18 18 use anyhow::{Context, Result};
19 19 use async_trait::async_trait;
20 20 use std::fmt::Write as _;
21 - use std::path::Path;
21 + use std::path::{Component, Path, PathBuf};
22 22 use tokio::process::Command;
23 23
24 24 /// Render a step to a single `/bin/sh` command line: optional `cd`, env
@@ -51,6 +51,59 @@ fn gate(caps: &CapabilitySet, host: &str, step: &Step) -> Result<()> {
51 51 }
52 52 }
53 53
54 + /// Gate a sync-plane artifact pull: the caller-side equivalent of the agent's
55 + /// `/pull` handler (`agent::pull`). Two conditions, both **fail-closed**:
56 + ///
57 + /// 1. The grant must include `observe:artifact` — retrieving a produced release
58 + /// artifact is an observe-plane capability, distinct from reading the build
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.
63 + ///
64 + /// Without this, `pull_file`/`pull_dir`/`pull_glob` would rsync ANY path off the
65 + /// host (`collect('mbp', '/Users/max/.tauri/passwords.env', …)` deposits the
66 + /// notary credential into `dist_root`): "THE WALL" held on the agent plane and
67 + /// not on the sync plane the agent hosts are actually collected over.
68 + ///
69 + /// Confinement is **lexical** (reject `..`, require a component-wise prefix),
70 + /// not canonicalizing like the agent's `confine_to_root`: the ssh transport's
71 + /// path is on a remote host we cannot `canonicalize()` without an extra
72 + /// round-trip, so both transports share the one check. The root is
73 + /// operator-declared on trusted infra and the threat closed here is a recipe- or
74 + /// daemon-supplied wild path; a symlink *inside* the root pointing out is the
75 + /// agent plane's stronger (canonicalizing) guarantee, out of scope here.
76 + fn gate_pull(
77 + caps: &CapabilitySet,
78 + host: &str,
79 + pull_root: Option<&Path>,
80 + requested: &Path,
81 + ) -> Result<()> {
82 + if !caps.permits_observe(&ObserveKind::Artifact) {
83 + return Err(CapabilityDenied::new(host, &Action::Observe(ObserveKind::Artifact)).into());
84 + }
85 + let Some(root) = pull_root else {
86 + anyhow::bail!(
87 + "pull from `{host}` denied: no artifact root declared for this host \
88 + (set `pull_root` in the host's topology entry)"
89 + );
90 + };
91 + anyhow::ensure!(
92 + !requested
93 + .components()
94 + .any(|c| matches!(c, Component::ParentDir)),
95 + "pull path `{}` from `{host}` contains `..`",
96 + requested.display()
97 + );
98 + anyhow::ensure!(
99 + requested.starts_with(root),
100 + "pull path `{}` escapes the declared artifact root `{}` on `{host}`",
101 + requested.display(),
102 + root.display()
103 + );
104 + Ok(())
105 + }
106 +
54 107 /// An env *value* is sh-quoted at render time, but the *name* is interpolated
55 108 /// verbatim (`NAME=value`), so a name with shell metacharacters could inject a
56 109 /// command. Require each name to be a bare shell identifier before dispatch.
@@ -147,6 +200,7 @@ async fn run_rsync(mut cmd: Command, what: &str) -> Result<()> {
147 200 pub struct LocalExec {
148 201 host: RemoteHost,
149 202 caps: CapabilitySet,
203 + pull_root: Option<PathBuf>,
150 204 }
151 205
152 206 impl LocalExec {
@@ -154,8 +208,18 @@ impl LocalExec {
154 208 Self {
155 209 host: RemoteHost::new("local"),
156 210 caps,
211 + pull_root: None,
157 212 }
158 213 }
214 +
215 + /// Confine this transport's artifact pulls to `root` (see [`gate_pull`]).
216 + /// Required before any `pull_*` succeeds — pulls are fail-closed, so an
217 + /// executor built without a root refuses every pull.
218 + #[must_use]
219 + pub fn with_pull_root(mut self, root: impl Into<PathBuf>) -> Self {
220 + self.pull_root = Some(root.into());
221 + self
222 + }
159 223 }
160 224
161 225 #[async_trait]
@@ -168,6 +232,7 @@ impl Executor for LocalExec {
168 232 }
169 233
170 234 async fn pull_file(&self, remote: &Path, local: &Path, opts: &SyncOpts) -> Result<()> {
235 + gate_pull(&self.caps, "local", self.pull_root.as_deref(), remote)?;
171 236 // No trailing slash: rsync copies the file itself.
172 237 let src = remote.to_string_lossy().to_string();
173 238 run_rsync(
@@ -178,6 +243,7 @@ impl Executor for LocalExec {
178 243 }
179 244
180 245 async fn pull_dir(&self, remote: &Path, local: &Path, opts: &SyncOpts) -> Result<()> {
246 + gate_pull(&self.caps, "local", self.pull_root.as_deref(), remote)?;
181 247 // Local "pull" is just a local rsync; trailing slash = contents.
182 248 let src = format!("{}/", remote.display());
183 249 run_rsync(
@@ -188,6 +254,12 @@ impl Executor for LocalExec {
188 254 }
189 255
190 256 async fn pull_glob(&self, remote_glob: &str, local_dir: &Path, opts: &SyncOpts) -> Result<()> {
257 + gate_pull(
258 + &self.caps,
259 + "local",
260 + self.pull_root.as_deref(),
261 + Path::new(remote_glob),
262 + )?;
191 263 // No remote shell to expand for us, and `rsync` is spawned directly (no
192 264 // shell), so expand in-process. This is the half of `pull_glob` that
193 265 // differs from the ssh transport, and the reason the trait's docs warn
@@ -219,6 +291,7 @@ impl Executor for LocalExec {
219 291 pub struct SshExec {
220 292 host: RemoteHost,
221 293 caps: CapabilitySet,
294 + pull_root: Option<PathBuf>,
222 295 }
223 296
224 297 impl SshExec {
@@ -226,6 +299,7 @@ impl SshExec {
226 299 Self {
227 300 host: RemoteHost::new(ssh_target),
228 301 caps,
302 + pull_root: None,
229 303 }
230 304 }
231 305
@@ -238,6 +312,15 @@ impl SshExec {
238 312 self
239 313 }
240 314
315 + /// Confine this transport's artifact pulls to `root` — an absolute path as
316 + /// seen ON THIS HOST (a remote root, so it is never tilde-expanded by the
317 + /// caller). See [`gate_pull`]; required before any `pull_*` succeeds.
318 + #[must_use]
319 + pub fn with_pull_root(mut self, root: impl Into<PathBuf>) -> Self {
320 + self.pull_root = Some(root.into());
321 + self
322 + }
323 +
241 324 pub fn ssh_target(&self) -> &str {
242 325 self.host.ssh_target()
243 326 }
@@ -253,6 +336,12 @@ impl Executor for SshExec {
253 336 }
254 337
255 338 async fn pull_file(&self, remote: &Path, local: &Path, opts: &SyncOpts) -> Result<()> {
339 + gate_pull(
340 + &self.caps,
341 + self.host.ssh_target(),
342 + self.pull_root.as_deref(),
343 + remote,
344 + )?;
256 345 // No trailing slash: rsync copies the file itself, not "contents of".
257 346 let src = format!("{}:{}", self.host.ssh_target(), remote.display());
258 347 let ssh = Some(self.host.ssh_args());
@@ -264,6 +353,12 @@ impl Executor for SshExec {
264 353 }
265 354
266 355 async fn pull_dir(&self, remote: &Path, local: &Path, opts: &SyncOpts) -> Result<()> {
356 + gate_pull(
357 + &self.caps,
358 + self.host.ssh_target(),
359 + self.pull_root.as_deref(),
360 + remote,
361 + )?;
267 362 let src = format!("{}:{}/", self.host.ssh_target(), remote.display());
268 363 let ssh = Some(self.host.ssh_args());
269 364 run_rsync(
@@ -274,6 +369,12 @@ impl Executor for SshExec {
274 369 }
275 370
276 371 async fn pull_glob(&self, remote_glob: &str, local_dir: &Path, opts: &SyncOpts) -> Result<()> {
372 + gate_pull(
373 + &self.caps,
374 + self.host.ssh_target(),
375 + self.pull_root.as_deref(),
376 + Path::new(remote_glob),
377 + )?;
277 378 // The REMOTE shell expands this one: rsync hands an un-`--protect-args`
278 379 // remote path to the login shell on the far side, so the wildcard must
279 380 // reach it intact — hence no quoting here. rsync exits non-zero when the
@@ -315,6 +416,12 @@ mod tests {
315 416 VecSink::default()
316 417 }
317 418
419 + /// The grant a sync transport needs to pull artifacts: `observe:artifact`
420 + /// and nothing else. Pair with `.with_pull_root(...)` in the pull tests.
421 + fn artifact_caps() -> CapabilitySet {
422 + CapabilitySet::from_tokens(Vec::<&str>::new(), ["artifact"])
423 + }
424 +
318 425 #[test]
319 426 fn render_plain_argv_quotes_each_token() {
320 427 let step = Step::new(Action::Build, ["echo", "a b", "c"]);
@@ -396,7 +503,7 @@ mod tests {
396 503 let dst = dir.path().join("dst");
397 504 tokio::fs::create_dir_all(&src).await.unwrap();
398 505 tokio::fs::write(src.join("f.txt"), b"hi").await.unwrap();
399 - let exec = LocalExec::new(CapabilitySet::default());
506 + let exec = LocalExec::new(artifact_caps()).with_pull_root(dir.path());
400 507 exec.push_dir(&src, &mid, &SyncOpts::default())
401 508 .await
402 509 .unwrap();
@@ -416,7 +523,7 @@ mod tests {
416 523 let src = dir.path().join("dump.sql.gz");
417 524 let dst = dir.path().join("fetched.sql.gz");
418 525 tokio::fs::write(&src, b"DUMPBYTES").await.unwrap();
419 - let exec = LocalExec::new(CapabilitySet::default());
526 + let exec = LocalExec::new(artifact_caps()).with_pull_root(dir.path());
420 527 exec.pull_file(&src, &dst, &SyncOpts::default())
421 528 .await
422 529 .unwrap();
@@ -437,7 +544,7 @@ mod tests {
437 544 tokio::fs::create_dir_all(&src).await.unwrap();
438 545 tokio::fs::create_dir_all(&out).await.unwrap();
439 546 tokio::fs::write(src.join("f.txt"), b"hi").await.unwrap();
440 - let exec = LocalExec::new(CapabilitySet::default());
547 + let exec = LocalExec::new(artifact_caps()).with_pull_root(dir.path());
441 548 exec.pull_file(&src, &out, &SyncOpts::default())
442 549 .await
443 550 .unwrap();
@@ -464,7 +571,7 @@ mod tests {
464 571 tokio::fs::write(src.join("b.msi"), b"B").await.unwrap();
465 572 tokio::fs::write(src.join("notes.txt"), b"N").await.unwrap();
466 573
467 - let exec = LocalExec::new(CapabilitySet::default());
574 + let exec = LocalExec::new(artifact_caps()).with_pull_root(dir.path());
468 575 let pattern = format!("{}/*.msi", src.display());
469 576 exec.pull_glob(&pattern, &out, &SyncOpts::default())
470 577 .await
@@ -483,7 +590,7 @@ mod tests {
483 590 let dir = tempfile::tempdir().unwrap();
484 591 let out = dir.path().join("dist");
485 592 tokio::fs::create_dir_all(&out).await.unwrap();
486 - let exec = LocalExec::new(CapabilitySet::default());
593 + let exec = LocalExec::new(artifact_caps()).with_pull_root(dir.path());
487 594 let pattern = format!("{}/nope/*.msi", dir.path().display());
488 595 let err = exec
489 596 .pull_glob(&pattern, &out, &SyncOpts::default())
@@ -502,7 +609,7 @@ mod tests {
502 609 tokio::fs::create_dir_all(&out).await.unwrap();
503 610 let f = dir.path().join("GoingsOn.dmg");
504 611 tokio::fs::write(&f, b"DMG").await.unwrap();
505 - let exec = LocalExec::new(CapabilitySet::default());
612 + let exec = LocalExec::new(artifact_caps()).with_pull_root(dir.path());
506 613 exec.pull_glob(&f.to_string_lossy(), &out, &SyncOpts::default())
507 614 .await
508 615 .unwrap();
@@ -512,6 +619,108 @@ mod tests {
512 619 );
513 620 }
514 621
622 + /// The exfiltration the gate closes: a pull with no `observe:artifact`
623 + /// grant is denied before any rsync spawns, even with a root set.
624 + #[tokio::test]
625 + async fn pull_denied_without_artifact_grant() {
626 + let dir = tempfile::tempdir().unwrap();
627 + let f = dir.path().join("secret");
628 + tokio::fs::write(&f, b"S").await.unwrap();
629 + // A build/sign grant is not an artifact-read grant.
630 + let caps = CapabilitySet::from_tokens(["build", "sign"], ["build-log"]);
631 + let exec = LocalExec::new(caps).with_pull_root(dir.path());
632 + let err = exec
633 + .pull_file(&f, &dir.path().join("out"), &SyncOpts::default())
634 + .await
635 + .unwrap_err();
636 + let denied = err
637 + .downcast_ref::<CapabilityDenied>()
638 + .expect("CapabilityDenied so a caller can audit-log it");
639 + assert_eq!(denied.action, "observe:artifact");
640 + assert!(!dir.path().join("out").exists(), "denied pull must not run");
641 + }
642 +
643 + /// Fail-closed: an executor with the grant but NO declared root pulls
644 + /// nothing. This is what stops an un-configured host from being an open
645 + /// read primitive.
646 + #[tokio::test]
647 + async fn pull_denied_without_pull_root() {
648 + let dir = tempfile::tempdir().unwrap();
649 + let f = dir.path().join("a.dmg");
650 + tokio::fs::write(&f, b"D").await.unwrap();
651 + let exec = LocalExec::new(artifact_caps()); // no with_pull_root
652 + let err = exec
653 + .pull_file(&f, &dir.path().join("out"), &SyncOpts::default())
654 + .await
655 + .unwrap_err();
656 + assert!(
657 + err.to_string().contains("no artifact root declared"),
658 + "{err}"
659 + );
660 + }
661 +
662 + /// The `passwords.env` case: an authorized caller with a legitimate root
663 + /// still cannot reach a sibling path outside it.
664 + #[tokio::test]
665 + async fn pull_denied_outside_declared_root() {
666 + let dir = tempfile::tempdir().unwrap();
667 + let root = dir.path().join("artifacts");
668 + let secret = dir.path().join("passwords.env"); // sibling of root, not under it
669 + tokio::fs::create_dir_all(&root).await.unwrap();
670 + tokio::fs::write(&secret, b"NOTARY_PW=hunter2")
671 + .await
672 + .unwrap();
673 + let exec = LocalExec::new(artifact_caps()).with_pull_root(&root);
674 + let err = exec
675 + .pull_file(&secret, &dir.path().join("out"), &SyncOpts::default())
676 + .await
677 + .unwrap_err();
678 + assert!(
679 + err.to_string()
680 + .contains("escapes the declared artifact root")
681 + );
682 + assert!(!dir.path().join("out").exists());
683 + }
684 +
685 + /// A `..` component is refused even when the resolved target would land back
686 + /// inside the root — the check is lexical, so it never has to resolve it.
687 + #[tokio::test]
688 + async fn pull_denied_on_parent_dir_component() {
689 + let dir = tempfile::tempdir().unwrap();
690 + let root = dir.path().join("artifacts");
691 + tokio::fs::create_dir_all(&root).await.unwrap();
692 + let exec = LocalExec::new(artifact_caps()).with_pull_root(&root);
693 + let sneaky = root.join("..").join("passwords.env");
694 + let err = exec
695 + .pull_file(&sneaky, &dir.path().join("out"), &SyncOpts::default())
696 + .await
697 + .unwrap_err();
698 + assert!(err.to_string().contains("contains `..`"), "{err}");
699 + }
700 +
701 + /// `starts_with` is component-wise, so a root prefix that is a string prefix
702 + /// but NOT a path prefix (`/x/artifacts` vs `/x/artifacts-evil`) does not
703 + /// leak. Pins that the confinement isn't a naive string compare.
704 + #[tokio::test]
705 + async fn pull_root_is_a_path_prefix_not_a_string_prefix() {
706 + let dir = tempfile::tempdir().unwrap();
707 + let root = dir.path().join("artifacts");
708 + let evil = dir.path().join("artifacts-evil");
709 + tokio::fs::create_dir_all(&root).await.unwrap();
710 + tokio::fs::create_dir_all(&evil).await.unwrap();
711 + let f = evil.join("x.dmg");
712 + tokio::fs::write(&f, b"D").await.unwrap();
713 + let exec = LocalExec::new(artifact_caps()).with_pull_root(&root);
714 + let err = exec
715 + .pull_file(&f, &dir.path().join("out"), &SyncOpts::default())
716 + .await
717 + .unwrap_err();
718 + assert!(
719 + err.to_string()
720 + .contains("escapes the declared artifact root")
721 + );
722 + }
723 +
515 724 #[test]
516 725 fn ssh_pull_glob_leaves_the_wildcard_for_the_remote_shell() {
517 726 // rsync hands an un---protect-args remote path to the far-side login