Skip to main content

capsule_runtime/
host_paths.rs

1//! Host folders the operator lends every contained capsule on this host: the `host_paths:` list in
2//! `~/.murmur/config.yaml`.
3//!
4//! Each entry names one host directory and an access, `read` or `read-write`. On
5//! `landlock+seccomp` it becomes one Landlock rule for every command, plan shell step and native
6//! tool; on `mountns+pivot_root+landlock+seccomp` it is also bound into the composed root at the
7//! directory's own absolute path. No rule ever carries `Execute`, so `capabilities.shell.allow`
8//! stays the complete list of what a contained capsule can run. Below `landlock+seccomp` nothing
9//! is scoped, and an entry is checked and reported but adds nothing.
10//!
11//! Checking happens in three steps, each refusing with the entry's index and its path as written:
12//!
13//! 1. [`declare_host_paths`]: the shape of what the operator wrote (`path`, `access`, `~`
14//!    expansion, `/`, the same path twice). No filesystem access.
15//! 2. [`resolve_host_paths`]: the entry on this host, at launch (exists, is a directory, not a
16//!    system path, not read-write over murmur's own home, not inside the work folder, not the
17//!    same directory twice), and the credential locations it reaches.
18//! 3. [`check_execute_roots`]: against what the session may run, on the applied tier, at
19//!    staging and by `mur run --explain-scope`.
20//!
21//! Platform-neutral: the Landlock rules and binds live in `sandbox` and `sealed`, behind the same
22//! `target_os = "linux"` gates as every other rule.
23
24use std::fmt;
25use std::path::{Path, PathBuf};
26
27use serde::{Deserialize, Serialize};
28
29/// What a host path lets a capsule do there. Required in every entry: there is no default.
30#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
31#[serde(rename_all = "kebab-case")]
32pub enum HostPathAccess {
33    /// Read files and list directories: Landlock `ReadFile | ReadDir`, and a read-only bind.
34    Read,
35    /// The workdir's rights without `Execute`, and a read-write bind.
36    ReadWrite,
37}
38
39impl HostPathAccess {
40    /// The spelling in `config.yaml` and in every report.
41    #[must_use]
42    pub fn wire_name(self) -> &'static str {
43        match self {
44            Self::Read => "read",
45            Self::ReadWrite => "read-write",
46        }
47    }
48
49    /// The access `value` spells, or `None` for anything but `read` and `read-write`.
50    #[must_use]
51    pub fn parse(value: &str) -> Option<Self> {
52        match value {
53            "read" => Some(Self::Read),
54            "read-write" => Some(Self::ReadWrite),
55            _ => None,
56        }
57    }
58}
59
60impl fmt::Display for HostPathAccess {
61    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
62        f.write_str(self.wire_name())
63    }
64}
65
66/// One `host_paths` entry exactly as the config file holds it. Both fields are optional here so
67/// a missing one is refused by [`declare_host_paths`] naming the entry, rather than failing the
68/// whole file's parse.
69#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize, Serialize)]
70pub struct HostPathEntry {
71    #[serde(default, skip_serializing_if = "Option::is_none")]
72    pub path: Option<String>,
73    #[serde(default, skip_serializing_if = "Option::is_none")]
74    pub access: Option<String>,
75}
76
77/// An entry whose shape is valid: `~` expanded, `.` and `..` folded, not yet looked up on the
78/// host.
79#[derive(Debug, Clone, PartialEq, Eq)]
80pub struct DeclaredHostPath {
81    /// Position in the config file's list.
82    pub index: usize,
83    /// `path` as written.
84    pub declared: String,
85    /// `path` made absolute against `HOME` and folded lexically.
86    pub expanded: PathBuf,
87    pub access: HostPathAccess,
88}
89
90/// A host path a session grants: checked on this host, in config order.
91#[derive(Debug, Clone, PartialEq, Eq)]
92pub struct HostPath {
93    /// `path` as written in the config file.
94    pub declared: String,
95    /// The directory it resolves to, symlinks followed. The rule and the bind are taken here.
96    pub path: PathBuf,
97    pub access: HostPathAccess,
98    /// Every [`CREDENTIAL_LOCATIONS`] entry present on this host that this directory is, lies
99    /// under, or contains, spelled `~/<location>`. Empty for an entry reaching none.
100    pub credential_locations: Vec<String>,
101}
102
103impl HostPath {
104    /// The locations joined for a report, or `None` when there are none.
105    #[must_use]
106    pub fn credential_location(&self) -> Option<String> {
107        (!self.credential_locations.is_empty()).then(|| self.credential_locations.join(", "))
108    }
109}
110
111/// Why an entry cannot be honoured. Every refusal names the entry's index, its path as written,
112/// the directory it resolves to when it has one, and the reason.
113#[derive(Debug, Clone, PartialEq, Eq)]
114pub struct HostPathRefusal {
115    pub index: usize,
116    /// `path` as written, or `<none>` for an entry with no `path`.
117    pub declared: String,
118    pub resolved: Option<PathBuf>,
119    pub reason: String,
120}
121
122impl fmt::Display for HostPathRefusal {
123    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
124        write!(f, "host_paths[{}] ({}", self.index, self.declared)?;
125        if let Some(resolved) = &self.resolved {
126            if Path::new(&self.declared) != resolved.as_path() {
127                write!(f, ", resolved to {}", resolved.display())?;
128            }
129        }
130        write!(f, "): {}", self.reason)
131    }
132}
133
134impl std::error::Error for HostPathRefusal {}
135
136/// What the applied tier does with a host path, as a stable wire name.
137#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
138#[serde(rename_all = "lowercase")]
139pub enum HostPathMechanism {
140    /// `landlock+seccomp`: one Landlock rule over the host's own filesystem.
141    Landlock,
142    /// `mountns+pivot_root+landlock+seccomp`: a bind into the composed root, plus the same rule.
143    Bind,
144    /// Any weaker tier: nothing is scoped, so the entry adds nothing.
145    None,
146}
147
148impl HostPathMechanism {
149    /// The mechanism of the tier whose wire name is `enforcement_tier`
150    /// ([`crate::ScopeReport::enforcement_tier`]).
151    #[must_use]
152    pub fn for_enforcement_tier(enforcement_tier: &str) -> Self {
153        match enforcement_tier {
154            "mountns+pivot_root+landlock+seccomp" => Self::Bind,
155            "landlock+seccomp" => Self::Landlock,
156            _ => Self::None,
157        }
158    }
159
160    #[must_use]
161    pub fn wire_name(self) -> &'static str {
162        match self {
163            Self::Landlock => "landlock",
164            Self::Bind => "bind",
165            Self::None => "none",
166        }
167    }
168
169    /// What this mechanism does with one entry, in words, for `mur doctor`.
170    #[must_use]
171    pub fn describe(self, access: HostPathAccess) -> &'static str {
172        match (self, access) {
173            (Self::Landlock, HostPathAccess::Read) => {
174                "a read-only Landlock rule over the host's own folder"
175            }
176            (Self::Landlock, HostPathAccess::ReadWrite) => {
177                "a read-write Landlock rule over the host's own folder, never executable"
178            }
179            (Self::Bind, HostPathAccess::Read) => {
180                "a read-only bind at its own path in the composed root, plus a read-only Landlock rule"
181            }
182            (Self::Bind, HostPathAccess::ReadWrite) => {
183                "a read-write bind at its own path in the composed root, plus a read-write \
184                 Landlock rule, never executable"
185            }
186            (Self::None, _) => "nothing: this tier scopes no filesystem, so every host folder is already reachable",
187        }
188    }
189}
190
191/// One element of `ScopeReport::host_paths`: `--explain-scope --json` and
192/// `session_start.effective_grants.host_paths`. Every key is always serialized.
193#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
194pub struct HostPathReport {
195    /// The directory the entry resolves to.
196    pub path: String,
197    /// The entry's `path` as written.
198    pub declared: String,
199    pub access: HostPathAccess,
200    pub mechanism: HostPathMechanism,
201    /// The credential locations the entry reaches, joined with `, `, or `null`.
202    pub credential_location: Option<String>,
203}
204
205impl HostPathReport {
206    /// `declared → path (access)`, plus the credential note.
207    #[must_use]
208    pub fn render(&self) -> String {
209        let mut line = format!("{} → {} ({})", self.declared, self.path, self.access);
210        if let Some(location) = &self.credential_location {
211            line.push_str(&format!(", reaches credential location {location}"));
212        }
213        line
214    }
215}
216
217/// The reports for `host_paths` under `mechanism`, in config order.
218#[must_use]
219pub fn host_path_reports(
220    host_paths: &[HostPath],
221    mechanism: HostPathMechanism,
222) -> Vec<HostPathReport> {
223    host_paths
224        .iter()
225        .map(|host_path| HostPathReport {
226            path: host_path.path.display().to_string(),
227            declared: host_path.declared.clone(),
228            access: host_path.access,
229            mechanism,
230            credential_location: host_path.credential_location(),
231        })
232        .collect()
233}
234
235/// A home-relative place that holds a credential, and the credential backstop patterns
236/// (`shell::CREDENTIAL_ENV_PATTERNS`) for the same credential, where there is one.
237#[derive(Debug, Clone, Copy, PartialEq, Eq)]
238pub struct CredentialLocation {
239    /// Relative to `HOME`.
240    pub path: &'static str,
241    pub backstop: &'static [&'static str],
242}
243
244/// Every credential location a host path is warned about (`W-SEC-035`). The one list.
245pub const CREDENTIAL_LOCATIONS: &[CredentialLocation] = &[
246    CredentialLocation {
247        path: ".ssh",
248        backstop: &[],
249    },
250    CredentialLocation {
251        path: ".gnupg",
252        backstop: &[],
253    },
254    CredentialLocation {
255        path: ".aws",
256        backstop: &["AWS_*"],
257    },
258    CredentialLocation {
259        path: ".config/gh",
260        backstop: &["GITHUB_TOKEN", "GH_TOKEN"],
261    },
262    CredentialLocation {
263        path: ".docker",
264        backstop: &["DOCKER_*"],
265    },
266    CredentialLocation {
267        path: ".kube",
268        backstop: &["KUBECONFIG"],
269    },
270    CredentialLocation {
271        path: ".npmrc",
272        backstop: &["NPM_TOKEN"],
273    },
274    CredentialLocation {
275        path: ".pypirc",
276        backstop: &["PYPI_TOKEN"],
277    },
278    CredentialLocation {
279        path: ".cargo/credentials.toml",
280        backstop: &["CARGO_REGISTRY_TOKEN"],
281    },
282    CredentialLocation {
283        path: ".cargo/credentials",
284        backstop: &["CARGO_REGISTRY_TOKEN"],
285    },
286    CredentialLocation {
287        path: ".config/gcloud",
288        backstop: &["GOOGLE_API_KEY"],
289    },
290    CredentialLocation {
291        path: ".cache/huggingface",
292        backstop: &["HUGGING_FACE_HUB_TOKEN"],
293    },
294    CredentialLocation {
295        path: ".netrc",
296        backstop: &[],
297    },
298    CredentialLocation {
299        path: ".git-credentials",
300        backstop: &[],
301    },
302    // Provider keys in `config.yaml`'s `credentials:` and `inference.api_key`.
303    CredentialLocation {
304        path: ".murmur",
305        backstop: &[],
306    },
307];
308
309/// Host directories a host path may not be, or lie under: a composed root carries its own copy of
310/// each or replaces it, and the private `/tmp` replaces `/tmp`, so an entry there would not reach
311/// the host's folder on every tier. The canonical `/tmp` is added by [`system_paths`].
312const SYSTEM_PATHS: &[&str] = &["/etc", "/proc", "/sys", "/dev"];
313
314/// Every system path a host path is refused under, literal and canonical.
315fn system_paths() -> Vec<PathBuf> {
316    let mut paths: Vec<PathBuf> = crate::sealed::SEALED_RUNTIME_PATHS
317        .iter()
318        .chain(SYSTEM_PATHS)
319        .map(PathBuf::from)
320        .collect();
321    paths.push(PathBuf::from("/tmp"));
322    paths.push(crate::temp_dir::canonical_tmp());
323    let canonical: Vec<PathBuf> = paths
324        .iter()
325        .filter_map(|path| std::fs::canonicalize(path).ok())
326        .collect();
327    for path in canonical {
328        if !paths.contains(&path) {
329            paths.push(path);
330        }
331    }
332    paths
333}
334
335/// Checks the shape of every entry and expands `~` against `home`, the `HOME` `mur` runs with.
336///
337/// Refuses an entry with no `path` or no `access`, an `access` other than `read` or
338/// `read-write`, a relative path (`~user/…` and `$VAR/…` are not expanded, so they are relative),
339/// a path that is or folds to `/`, and a path an earlier entry already names.
340pub fn declare_host_paths(
341    entries: &[HostPathEntry],
342    home: Option<&Path>,
343) -> Result<Vec<DeclaredHostPath>, HostPathRefusal> {
344    let mut declared: Vec<DeclaredHostPath> = Vec::with_capacity(entries.len());
345    for (index, entry) in entries.iter().enumerate() {
346        let written = entry.path.clone().unwrap_or_else(|| "<none>".to_string());
347        let refuse = |reason: String| HostPathRefusal {
348            index,
349            declared: written.clone(),
350            resolved: None,
351            reason,
352        };
353        let Some(path) = entry.path.as_deref().filter(|path| !path.trim().is_empty()) else {
354            return Err(refuse(
355                "the entry has no path (an unquoted `~` is YAML null: write `path: \"~\"`)"
356                    .to_string(),
357            ));
358        };
359        let access = match entry.access.as_deref() {
360            None => {
361                return Err(refuse(
362                    "access is required: write `access: read` or `access: read-write`".to_string(),
363                ))
364            }
365            Some(value) => HostPathAccess::parse(value).ok_or_else(|| {
366                refuse(format!(
367                    "access '{value}' is neither `read` nor `read-write`"
368                ))
369            })?,
370        };
371        let expanded = expand_home(path, home).map_err(refuse)?;
372        let expanded = crate::protected_paths::normalize_absolute(&expanded);
373        if expanded == Path::new("/") {
374            return Err(refuse(
375                "names /, the whole host filesystem; name the folder the capsule needs".to_string(),
376            ));
377        }
378        if let Some(earlier) = declared.iter().find(|other| other.expanded == expanded) {
379            return Err(refuse(format!(
380                "names the same directory as host_paths[{}]",
381                earlier.index
382            )));
383        }
384        declared.push(DeclaredHostPath {
385            index,
386            declared: path.to_string(),
387            expanded,
388            access,
389        });
390    }
391    Ok(declared)
392}
393
394/// `path` made absolute: `~` and `~/…` against `home`; anything else must already be absolute.
395fn expand_home(path: &str, home: Option<&Path>) -> Result<PathBuf, String> {
396    let rest = if path == "~" {
397        Some("")
398    } else {
399        path.strip_prefix("~/")
400    };
401    if let Some(rest) = rest {
402        let home = home
403            .filter(|home| home.is_absolute())
404            .ok_or_else(|| "starts with ~, and HOME is not set to an absolute path".to_string())?;
405        return Ok(home.join(rest));
406    }
407    if path.starts_with('~') {
408        return Err(
409            "is relative: ~user is not expanded; write an absolute path or ~/…".to_string(),
410        );
411    }
412    let path = PathBuf::from(path);
413    if !path.is_absolute() {
414        return Err(
415            "is relative; write an absolute path or ~/… (environment variables are not expanded)"
416                .to_string(),
417        );
418    }
419    Ok(path)
420}
421
422/// `path` folded lexically, then canonicalized through its nearest existing ancestor: for a
423/// directory that may not exist yet, such as a workdir root before its first run. Folded first so
424/// a `..` beneath the missing part cannot defeat a `starts_with` comparison.
425fn resolve_through_existing_ancestor(path: &Path) -> PathBuf {
426    crate::protected_paths::canonicalize_existing_prefix(
427        &crate::protected_paths::normalize_absolute(path),
428    )
429}
430
431/// What [`resolve_host_paths`] checks each entry against.
432#[derive(Debug, Clone, Copy)]
433pub struct HostPathContext<'a> {
434    /// The `HOME` `mur` runs with: where the credential locations are looked for.
435    pub home: Option<&'a Path>,
436    /// Murmur's own home, `~/.murmur` (`murmur_home::home_path()`): a `read-write` entry over it
437    /// is refused.
438    pub murmur_home: Option<&'a Path>,
439    /// The session's work folder root: an entry inside it is refused.
440    pub workdir: &'a Path,
441}
442
443/// Resolves every declared entry on this host and checks it, in config order.
444///
445/// Refuses an entry that does not exist or is not a directory, that resolves to `/`, that is or
446/// lies under a system path the runtime replaces, a `read-write` entry that is, lies under or
447/// contains murmur's own home, an entry inside the work folder, and an entry resolving to a
448/// directory an earlier one already names. Credential locations are found but never refuse.
449pub fn resolve_host_paths(
450    declared: &[DeclaredHostPath],
451    context: &HostPathContext<'_>,
452) -> Result<Vec<HostPath>, HostPathRefusal> {
453    let system = system_paths();
454    let murmur_home = context.murmur_home.map(resolve_through_existing_ancestor);
455    let workdir = resolve_through_existing_ancestor(context.workdir);
456    let mut resolved: Vec<HostPath> = Vec::with_capacity(declared.len());
457    let mut indices: Vec<usize> = Vec::with_capacity(declared.len());
458    for entry in declared {
459        let refuse = |resolved: Option<PathBuf>, reason: String| HostPathRefusal {
460            index: entry.index,
461            declared: entry.declared.clone(),
462            resolved,
463            reason,
464        };
465        let path = match std::fs::canonicalize(&entry.expanded) {
466            Ok(path) => path,
467            Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
468                return Err(refuse(
469                    None,
470                    format!("{} does not exist", entry.expanded.display()),
471                ))
472            }
473            Err(error) => {
474                return Err(refuse(
475                    None,
476                    format!("{} cannot be resolved: {error}", entry.expanded.display()),
477                ))
478            }
479        };
480        let is_dir = std::fs::metadata(&path).map(|meta| meta.is_dir());
481        if !matches!(is_dir, Ok(true)) {
482            return Err(refuse(
483                Some(path),
484                "is not a directory; host_paths names folders only".to_string(),
485            ));
486        }
487        if path == Path::new("/") {
488            return Err(refuse(
489                Some(path),
490                "resolves to /, the whole host filesystem".to_string(),
491            ));
492        }
493        if let Some(system_path) = system.iter().find(|system| path.starts_with(system)) {
494            return Err(refuse(
495                Some(path.clone()),
496                format!(
497                    "lies under {}, which the runtime replaces with its own copy in a composed \
498                     root (and a private /tmp replaces /tmp), so the entry would not reach the \
499                     host's folder on every tier",
500                    system_path.display()
501                ),
502            ));
503        }
504        if entry.access == HostPathAccess::ReadWrite {
505            if let Some(murmur_home) = murmur_home.as_deref() {
506                if path.starts_with(murmur_home) || murmur_home.starts_with(&path) {
507                    return Err(refuse(
508                        Some(path.clone()),
509                        format!(
510                            "is read-write over murmur's own home {}, which holds this config \
511                             file and every session's records; a capsule that could write it \
512                             would widen what the next capsule gets. Use `access: read`, or \
513                             name a folder that does not contain it",
514                            murmur_home.display()
515                        ),
516                    ));
517                }
518            }
519        }
520        if path.starts_with(&workdir) {
521            return Err(refuse(
522                Some(path.clone()),
523                format!(
524                    "lies inside the work folder {}, which the capsule already reaches read-write",
525                    workdir.display()
526                ),
527            ));
528        }
529        if let Some(position) = resolved.iter().position(|other| other.path == path) {
530            return Err(refuse(
531                Some(path),
532                format!(
533                    "resolves to the same directory as host_paths[{}]",
534                    indices[position]
535                ),
536            ));
537        }
538        let credential_locations = context
539            .home
540            .map(|home| credential_locations(&path, home))
541            .unwrap_or_default();
542        indices.push(entry.index);
543        resolved.push(HostPath {
544            declared: entry.declared.clone(),
545            path,
546            access: entry.access,
547            credential_locations,
548        });
549    }
550    Ok(resolved)
551}
552
553/// Every [`CREDENTIAL_LOCATIONS`] entry that exists under `home` and that `path` is, lies under,
554/// or contains, spelled `~/<location>`.
555#[must_use]
556pub fn credential_locations(path: &Path, home: &Path) -> Vec<String> {
557    let home = std::fs::canonicalize(home).unwrap_or_else(|_| home.to_path_buf());
558    CREDENTIAL_LOCATIONS
559        .iter()
560        .filter(|location| {
561            let lexical = home.join(location.path);
562            if std::fs::symlink_metadata(&lexical).is_err() {
563                return false;
564            }
565            let canonical = std::fs::canonicalize(&lexical).unwrap_or_else(|_| lexical.clone());
566            [lexical, canonical]
567                .iter()
568                .any(|location| path.starts_with(location) || location.starts_with(path))
569        })
570        .map(|location| format!("~/{}", location.path))
571        .collect()
572}
573
574/// The `W-SEC-035` line for an entry reaching a credential location, or `None` when it reaches
575/// none. Shared by `mur run` and `mur doctor` so the two cannot word it differently.
576#[must_use]
577pub fn credential_warning(host_path: &HostPath) -> Option<String> {
578    use murmur_artifact::{security_warning_link, W_SEC_035};
579    let locations = host_path.credential_location()?;
580    let verb = match host_path.access {
581        HostPathAccess::Read => "read",
582        HostPathAccess::ReadWrite => "read and change",
583    };
584    Some(format!(
585        "warning[{W_SEC_035}]: host_paths entry {} ({}) reaches the credential location {}, so \
586         every scoped or sealed capsule on this host can {verb} it ({})",
587        host_path.declared,
588        host_path.access,
589        locations,
590        security_warning_link(W_SEC_035)
591    ))
592}
593
594/// A directory the session may run something out of: the directory of an executable file its
595/// Landlock grants name, or an executable directory grant.
596#[derive(Debug, Clone, PartialEq, Eq)]
597pub struct ExecuteRoot {
598    pub dir: PathBuf,
599    /// The executable file or directory grant that makes `dir` an execute root.
600    pub because: PathBuf,
601}
602
603/// Checks the `read-write` entries against what the session may run, under `mechanism`.
604///
605/// On both Landlock mechanisms a `read-write` entry that is or lies under an execute root is
606/// refused. On `landlock` a `read-write` entry that contains one is refused too: nothing keeps
607/// the capsule from rewriting a binary it may run in place. On `bind` such an entry is allowed,
608/// and the execute roots inside it are returned, each to be bound read-only on top of it (a
609/// required bind). Below both, nothing is scoped and nothing is checked.
610pub fn check_execute_roots(
611    host_paths: &[HostPath],
612    roots: &[ExecuteRoot],
613    mechanism: HostPathMechanism,
614) -> Result<Vec<PathBuf>, HostPathRefusal> {
615    if mechanism == HostPathMechanism::None {
616        return Ok(Vec::new());
617    }
618    let mut protected: Vec<PathBuf> = Vec::new();
619    for (index, host_path) in host_paths.iter().enumerate() {
620        if host_path.access != HostPathAccess::ReadWrite {
621            continue;
622        }
623        for root in roots {
624            let refuse = |reason: String| HostPathRefusal {
625                index,
626                declared: host_path.declared.clone(),
627                resolved: Some(host_path.path.clone()),
628                reason,
629            };
630            if host_path.path.starts_with(&root.dir) {
631                return Err(refuse(format!(
632                    "is read-write inside {}, a folder this session may run {} from; name a \
633                     folder outside it, or make the entry `access: read`",
634                    root.dir.display(),
635                    root.because.display()
636                )));
637            }
638            if root.dir.starts_with(&host_path.path) {
639                if mechanism == HostPathMechanism::Landlock {
640                    return Err(refuse(format!(
641                        "is read-write over {}, a folder this session may run {} from, so the \
642                         capsule could rewrite what it runs; at scoped nothing prevents that. \
643                         Name a folder that does not contain it, make the entry `access: read`, \
644                         or declare containment: sealed, which binds it read-only",
645                        root.dir.display(),
646                        root.because.display()
647                    )));
648                }
649                if !protected.contains(&root.dir) {
650                    protected.push(root.dir.clone());
651                }
652            }
653        }
654    }
655    Ok(protected)
656}
657
658/// [`check_execute_roots`] for `policy`'s own host paths against the execute roots its grants
659/// resolve to on this host. `stage_session` calls it on the applied tier, and
660/// `mur run --explain-scope` on the tier its report names, so both refuse the same entry.
661pub fn check_policy_execute_roots(
662    policy: &crate::types::CapabilityPolicy,
663    mechanism: HostPathMechanism,
664) -> Result<(), HostPathRefusal> {
665    if mechanism == HostPathMechanism::None
666        || !policy
667            .host_paths
668            .iter()
669            .any(|host_path| host_path.access == HostPathAccess::ReadWrite)
670    {
671        return Ok(());
672    }
673    check_execute_roots(
674        &policy.host_paths,
675        &crate::sandbox::host_path_execute_roots(policy),
676        mechanism,
677    )
678    .map(|_| ())
679}
680
681#[cfg(test)]
682mod tests {
683    use super::*;
684
685    fn entry(path: &str, access: &str) -> HostPathEntry {
686        HostPathEntry {
687            path: Some(path.to_string()),
688            access: Some(access.to_string()),
689        }
690    }
691
692    fn refusal(entries: &[HostPathEntry]) -> HostPathRefusal {
693        declare_host_paths(entries, Some(Path::new("/home/op"))).unwrap_err()
694    }
695
696    /// A scratch directory outside `/tmp`, which a host path may not be under: the workspace's
697    /// `target/` when there is one, else the crate directory.
698    fn scratch() -> tempfile::TempDir {
699        let target = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../target");
700        let base = if target.is_dir() {
701            target
702        } else {
703            PathBuf::from(env!("CARGO_MANIFEST_DIR"))
704        };
705        tempfile::tempdir_in(base).unwrap()
706    }
707
708    #[test]
709    fn host_paths_two_entries_parse_and_tilde_expands_against_home() {
710        let declared = declare_host_paths(
711            &[entry("~", "read"), entry("~/x/./y/../z", "read-write")],
712            Some(Path::new("/home/op")),
713        )
714        .unwrap();
715        assert_eq!(declared[0].expanded, PathBuf::from("/home/op"));
716        assert_eq!(declared[0].access, HostPathAccess::Read);
717        assert_eq!(declared[1].expanded, PathBuf::from("/home/op/x/z"));
718        assert_eq!(declared[1].access, HostPathAccess::ReadWrite);
719        assert_eq!(declared[1].declared, "~/x/./y/../z");
720        assert_eq!(declared[1].index, 1);
721    }
722
723    #[test]
724    fn host_paths_shape_refusals_name_the_index_and_the_path_as_written() {
725        let cases: Vec<(Vec<HostPathEntry>, &str, &str)> = vec![
726            (
727                vec![entry("relative/dir", "read")],
728                "host_paths[0] (relative/dir)",
729                "is relative",
730            ),
731            (
732                vec![entry("~user/x", "read")],
733                "host_paths[0] (~user/x)",
734                "~user is not expanded",
735            ),
736            (
737                vec![entry("$HOME/x", "read")],
738                "host_paths[0] ($HOME/x)",
739                "is relative",
740            ),
741            (vec![entry("/", "read")], "host_paths[0] (/)", "names /"),
742            (
743                vec![entry("/home/..", "read")],
744                "host_paths[0] (/home/..)",
745                "names /",
746            ),
747            (
748                vec![HostPathEntry {
749                    path: Some("/srv".to_string()),
750                    access: None,
751                }],
752                "host_paths[0] (/srv)",
753                "access is required",
754            ),
755            (
756                vec![entry("/srv", "write")],
757                "host_paths[0] (/srv)",
758                "access 'write'",
759            ),
760            (
761                vec![entry("/srv", "read"), entry("/srv/", "read-write")],
762                "host_paths[1] (/srv/)",
763                "same directory as host_paths[0]",
764            ),
765            (
766                vec![HostPathEntry {
767                    path: None,
768                    access: Some("read".to_string()),
769                }],
770                "host_paths[0] (<none>)",
771                "no path",
772            ),
773        ];
774        for (entries, head, reason) in cases {
775            let text = refusal(&entries).to_string();
776            assert!(text.starts_with(head), "{text}");
777            assert!(text.contains(reason), "{text}");
778        }
779    }
780
781    #[test]
782    fn host_paths_tilde_needs_an_absolute_home() {
783        let error = declare_host_paths(&[entry("~/x", "read")], None).unwrap_err();
784        assert!(error.reason.contains("HOME"), "{error}");
785    }
786
787    fn resolve_one(
788        path: &Path,
789        access: HostPathAccess,
790        murmur_home: Option<&Path>,
791        workdir: &Path,
792    ) -> Result<Vec<HostPath>, HostPathRefusal> {
793        let declared = vec![DeclaredHostPath {
794            index: 0,
795            declared: path.display().to_string(),
796            expanded: path.to_path_buf(),
797            access,
798        }];
799        resolve_host_paths(
800            &declared,
801            &HostPathContext {
802                home: None,
803                murmur_home,
804                workdir,
805            },
806        )
807    }
808
809    #[test]
810    fn host_paths_launch_refusals_name_the_entry_its_resolution_and_the_reason() {
811        let dir = scratch();
812        let root = dir.path().canonicalize().unwrap();
813        let workdir = root.join("project/workdir");
814        std::fs::create_dir_all(workdir.join("inner")).unwrap();
815        let home = root.join("home");
816        let murmur_home = home.join(".murmur");
817        std::fs::create_dir_all(murmur_home.join("records")).unwrap();
818        let file = root.join("file.txt");
819        std::fs::write(&file, "x").unwrap();
820        let to_root = root.join("to-root");
821        std::os::unix::fs::symlink("/", &to_root).unwrap();
822        let tmp_dir = tempfile::tempdir_in("/tmp").unwrap();
823
824        let read = HostPathAccess::Read;
825        let read_write = HostPathAccess::ReadWrite;
826        let cases: Vec<(PathBuf, HostPathAccess, &str)> = vec![
827            (root.join("missing"), read, "does not exist"),
828            (to_root.clone(), read, "resolves to /"),
829            (file.clone(), read, "is not a directory"),
830            (PathBuf::from("/usr/share"), read, "lies under /usr"),
831            (PathBuf::from("/etc"), read, "lies under /etc"),
832            (PathBuf::from("/proc"), read, "lies under /proc"),
833            (tmp_dir.path().to_path_buf(), read, "/tmp"),
834            (murmur_home.clone(), read_write, "murmur's own home"),
835            (murmur_home.join("records"), read_write, "murmur's own home"),
836            (home.clone(), read_write, "murmur's own home"),
837            (workdir.join("inner"), read, "inside the work folder"),
838            (workdir.clone(), read, "inside the work folder"),
839        ];
840        for (path, access, reason) in cases {
841            let error = resolve_one(&path, access, Some(&murmur_home), &workdir)
842                .expect_err(&format!("{} should be refused", path.display()));
843            let text = error.to_string();
844            assert!(
845                text.starts_with(&format!("host_paths[0] ({}", path.display())),
846                "{text}"
847            );
848            assert!(text.contains(reason), "{reason}: {text}");
849        }
850        // A symlink names its resolution as well as what was written.
851        let error = resolve_one(&to_root, read, None, &workdir).unwrap_err();
852        assert_eq!(error.resolved.as_deref(), Some(Path::new("/")));
853        assert!(error.to_string().contains("resolved to /"), "{error}");
854    }
855
856    #[test]
857    fn host_paths_murmur_home_read_and_a_symlinked_entry_resolve() {
858        let dir = scratch();
859        let root = dir.path().canonicalize().unwrap();
860        let murmur_home = root.join("home/.murmur");
861        std::fs::create_dir_all(&murmur_home).unwrap();
862        let target = root.join("real");
863        std::fs::create_dir(&target).unwrap();
864        let link = root.join("link");
865        std::os::unix::fs::symlink(&target, &link).unwrap();
866        let workdir = root.join("project/workdir");
867
868        let resolved = resolve_one(
869            &murmur_home,
870            HostPathAccess::Read,
871            Some(&murmur_home),
872            &workdir,
873        )
874        .unwrap();
875        assert_eq!(resolved[0].path, murmur_home);
876
877        let resolved = resolve_one(&link, HostPathAccess::ReadWrite, None, &workdir).unwrap();
878        assert_eq!(resolved[0].path, target);
879        assert_eq!(resolved[0].declared, link.display().to_string());
880    }
881
882    #[test]
883    fn host_paths_two_entries_resolving_to_one_directory_are_refused() {
884        let dir = scratch();
885        let root = dir.path().canonicalize().unwrap();
886        let target = root.join("real");
887        std::fs::create_dir(&target).unwrap();
888        let link = root.join("link");
889        std::os::unix::fs::symlink(&target, &link).unwrap();
890        let declared = declare_host_paths(
891            &[
892                entry(&target.display().to_string(), "read"),
893                entry(&link.display().to_string(), "read"),
894            ],
895            None,
896        )
897        .unwrap();
898        let error = resolve_host_paths(
899            &declared,
900            &HostPathContext {
901                home: None,
902                murmur_home: None,
903                workdir: &root.join("workdir"),
904            },
905        )
906        .unwrap_err();
907        assert_eq!(error.index, 1);
908        assert!(error.reason.contains("host_paths[0]"), "{error}");
909    }
910
911    #[test]
912    fn host_paths_credential_locations_warn_when_is_under_or_contains() {
913        let dir = scratch();
914        let home = dir.path().canonicalize().unwrap();
915        std::fs::create_dir_all(home.join(".ssh/keys")).unwrap();
916        std::fs::create_dir_all(home.join(".cargo")).unwrap();
917        std::fs::write(home.join(".cargo/credentials.toml"), "").unwrap();
918        std::fs::create_dir_all(home.join("plain")).unwrap();
919
920        // is
921        assert_eq!(credential_locations(&home.join(".ssh"), &home), ["~/.ssh"]);
922        // lies under
923        assert_eq!(
924            credential_locations(&home.join(".ssh/keys"), &home),
925            ["~/.ssh"]
926        );
927        // contains
928        assert_eq!(
929            credential_locations(&home.join(".cargo"), &home),
930            ["~/.cargo/credentials.toml"]
931        );
932        assert_eq!(
933            credential_locations(&home, &home),
934            ["~/.ssh", "~/.cargo/credentials.toml"]
935        );
936        // none
937        assert!(credential_locations(&home.join("plain"), &home).is_empty());
938        // A location absent from this host is never named: no `.aws` here.
939        assert!(!credential_locations(&home, &home).contains(&"~/.aws".to_string()));
940
941        let host_path = HostPath {
942            declared: "~/.ssh".to_string(),
943            path: home.join(".ssh"),
944            access: HostPathAccess::Read,
945            credential_locations: vec!["~/.ssh".to_string()],
946        };
947        let warning = credential_warning(&host_path).unwrap();
948        assert!(warning.starts_with("warning[W-SEC-035]: host_paths entry ~/.ssh (read)"));
949        assert!(warning.contains("diagnostics/#w-sec-035"), "{warning}");
950        let silent = HostPath {
951            credential_locations: Vec::new(),
952            ..host_path
953        };
954        assert!(credential_warning(&silent).is_none());
955    }
956
957    #[test]
958    fn host_paths_credential_backstops_are_credential_patterns() {
959        for location in CREDENTIAL_LOCATIONS {
960            for pattern in location.backstop {
961                assert!(
962                    crate::shell::CREDENTIAL_ENV_PATTERNS.contains(pattern),
963                    "{pattern} for ~/{} is not a credential backstop pattern",
964                    location.path
965                );
966            }
967        }
968    }
969
970    fn host_path(path: &str, access: HostPathAccess) -> HostPath {
971        HostPath {
972            declared: path.to_string(),
973            path: PathBuf::from(path),
974            access,
975            credential_locations: Vec::new(),
976        }
977    }
978
979    fn root(dir: &str, because: &str) -> ExecuteRoot {
980        ExecuteRoot {
981            dir: PathBuf::from(dir),
982            because: PathBuf::from(because),
983        }
984    }
985
986    #[test]
987    fn host_paths_execute_root_rules_per_mechanism() {
988        let roots = [root("/opt/tools/bin", "/opt/tools/bin/tool")];
989        let rw = HostPathAccess::ReadWrite;
990        // Containing an execute root: refused on landlock, protected on bind.
991        let tools = [host_path("/opt/tools", rw)];
992        let error = check_execute_roots(&tools, &roots, HostPathMechanism::Landlock).unwrap_err();
993        assert!(error.to_string().contains("/opt/tools/bin/tool"), "{error}");
994        assert_eq!(
995            check_execute_roots(&tools, &roots, HostPathMechanism::Bind).unwrap(),
996            [PathBuf::from("/opt/tools/bin")]
997        );
998        // Is or lies under an execute root: refused on both.
999        for path in ["/opt/tools/bin", "/opt/tools/bin/sub"] {
1000            let entry = [host_path(path, rw)];
1001            for mechanism in [HostPathMechanism::Landlock, HostPathMechanism::Bind] {
1002                let error = check_execute_roots(&entry, &roots, mechanism).unwrap_err();
1003                assert!(error.reason.contains("/opt/tools/bin"), "{error}");
1004            }
1005        }
1006        // Read entries and weak tiers are never checked.
1007        let read = [host_path("/opt/tools/bin", HostPathAccess::Read)];
1008        assert!(
1009            check_execute_roots(&read, &roots, HostPathMechanism::Landlock)
1010                .unwrap()
1011                .is_empty()
1012        );
1013        assert!(check_execute_roots(&tools, &roots, HostPathMechanism::None)
1014            .unwrap()
1015            .is_empty());
1016        // A disjoint entry is untouched.
1017        let other = [host_path("/srv/data", rw)];
1018        assert!(
1019            check_execute_roots(&other, &roots, HostPathMechanism::Landlock)
1020                .unwrap()
1021                .is_empty()
1022        );
1023    }
1024
1025    #[test]
1026    fn host_paths_reports_carry_every_key_in_config_order() {
1027        let paths = [
1028            HostPath {
1029                declared: "~/.cargo".to_string(),
1030                path: PathBuf::from("/home/op/.cargo"),
1031                access: HostPathAccess::ReadWrite,
1032                credential_locations: vec!["~/.cargo/credentials.toml".to_string()],
1033            },
1034            host_path("/srv/data", HostPathAccess::Read),
1035        ];
1036        let reports = host_path_reports(&paths, HostPathMechanism::Landlock);
1037        let json = serde_json::to_value(&reports).unwrap();
1038        assert_eq!(
1039            json,
1040            serde_json::json!([
1041                {
1042                    "path": "/home/op/.cargo",
1043                    "declared": "~/.cargo",
1044                    "access": "read-write",
1045                    "mechanism": "landlock",
1046                    "credential_location": "~/.cargo/credentials.toml"
1047                },
1048                {
1049                    "path": "/srv/data",
1050                    "declared": "/srv/data",
1051                    "access": "read",
1052                    "mechanism": "landlock",
1053                    "credential_location": null
1054                }
1055            ])
1056        );
1057        assert_eq!(
1058            reports[0].render(),
1059            "~/.cargo → /home/op/.cargo (read-write), reaches credential location \
1060             ~/.cargo/credentials.toml"
1061        );
1062        for (tier, mechanism) in [
1063            ("mountns+pivot_root+landlock+seccomp", "bind"),
1064            ("landlock+seccomp", "landlock"),
1065            ("seccomp-only", "none"),
1066            ("none", "none"),
1067        ] {
1068            assert_eq!(
1069                HostPathMechanism::for_enforcement_tier(tier).wire_name(),
1070                mechanism
1071            );
1072        }
1073    }
1074}